@intentius/chant-lexicon-aws 0.124.51 → 0.124.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/codegen/generate-lexicon.d.ts +2 -0
  2. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  3. package/dist/codegen/generate-lexicon.js +1 -0
  4. package/dist/components/__tests__/mock-cloud-executor.d.ts +4 -0
  5. package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -1
  6. package/dist/components/__tests__/mock-cloud-executor.js +18 -1
  7. package/dist/components/apply.d.ts +76 -3
  8. package/dist/components/apply.d.ts.map +1 -1
  9. package/dist/components/apply.js +488 -32
  10. package/dist/components/cfn-adopt.d.ts +99 -0
  11. package/dist/components/cfn-adopt.d.ts.map +1 -0
  12. package/dist/components/cfn-adopt.js +278 -0
  13. package/dist/components/cfn-imports.d.ts +84 -0
  14. package/dist/components/cfn-imports.d.ts.map +1 -0
  15. package/dist/components/cfn-imports.js +239 -0
  16. package/dist/components/cloud-executor.d.ts +44 -2
  17. package/dist/components/cloud-executor.d.ts.map +1 -1
  18. package/dist/components/cloud-executor.js +73 -2
  19. package/dist/importable.d.ts +30 -0
  20. package/dist/importable.d.ts.map +1 -0
  21. package/dist/importable.js +72 -0
  22. package/dist/index-identity.d.ts +19 -0
  23. package/dist/index-identity.d.ts.map +1 -0
  24. package/dist/index-identity.js +79 -0
  25. package/dist/index-import.d.ts +17 -0
  26. package/dist/index-import.d.ts.map +1 -0
  27. package/dist/index-import.js +115 -0
  28. package/dist/integrity.json +3 -3
  29. package/dist/listed-types.d.ts +11 -0
  30. package/dist/listed-types.d.ts.map +1 -0
  31. package/dist/listed-types.js +52 -0
  32. package/dist/manifest.json +1 -1
  33. package/dist/meta.json +13206 -1237
  34. package/dist/plugin.d.ts.map +1 -1
  35. package/dist/plugin.js +9 -0
  36. package/dist/reference-catalog.d.ts.map +1 -1
  37. package/dist/reference-catalog.js +62 -20
  38. package/dist/spec/fetch.d.ts +5 -0
  39. package/dist/spec/fetch.d.ts.map +1 -1
  40. package/dist/spec/parse.d.ts +8 -0
  41. package/dist/spec/parse.d.ts.map +1 -1
  42. package/dist/spec/parse.js +3 -0
  43. package/package.json +2 -2
  44. package/src/codegen/generate-lexicon.ts +3 -0
  45. package/src/components/__tests__/mock-cloud-executor.ts +20 -1
  46. package/src/components/apply.ts +546 -33
  47. package/src/components/cfn-adopt.test.ts +284 -0
  48. package/src/components/cfn-adopt.ts +340 -0
  49. package/src/components/cfn-exports-in-use.test.ts +146 -0
  50. package/src/components/cfn-imports-preview.test.ts +178 -0
  51. package/src/components/cfn-imports-three-stacks.test.ts +107 -0
  52. package/src/components/cfn-imports.test.ts +213 -0
  53. package/src/components/cfn-imports.ts +246 -0
  54. package/src/components/cfn-plan-floci.e2e.test.ts +53 -0
  55. package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
  56. package/src/components/cfn-plan.test.ts +328 -9
  57. package/src/components/cloud-executor.ts +119 -6
  58. package/src/components/demo-composed-stacks-floci.e2e.test.ts +235 -0
  59. package/src/derived-edges.test.ts +142 -0
  60. package/src/generated/lexicon-aws.json +13206 -1237
  61. package/src/importable.test.ts +86 -0
  62. package/src/importable.ts +83 -0
  63. package/src/index-identity.test.ts +134 -0
  64. package/src/index-identity.ts +98 -0
  65. package/src/index-import.test.ts +242 -0
  66. package/src/index-import.ts +140 -0
  67. package/src/index-search.test.ts +87 -0
  68. package/src/listed-types.test.ts +97 -0
  69. package/src/listed-types.ts +56 -0
  70. package/src/plugin.ts +10 -0
  71. package/src/reference-catalog.ts +63 -20
  72. package/src/spec/fetch.ts +5 -0
  73. package/src/spec/parse.test.ts +15 -0
  74. package/src/spec/parse.ts +12 -0
  75. package/src/testdata/indexed/identity.listing.json +65 -0
  76. package/src/testdata/indexed/import.listing.json +126 -0
  77. package/src/testdata/indexed/listing.json +69 -0
  78. package/src/testdata/indexed/topology.listing.json +350 -0
  79. package/src/testdata/three-cloud/dns.graph.json +336 -0
  80. package/src/three-cloud-parity.test.ts +49 -0
@@ -19,9 +19,13 @@
19
19
  * invoke, or SSM host command).
20
20
  */
21
21
  import { createHash } from "node:crypto";
22
- import { readFileSync } from "node:fs";
22
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
23
25
  import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
26
+ import { normalizePlanShape, PlanShapeMovedError, planShapeDifferences, planShapeDigest, } from "@intentius/chant/components/covered-round";
24
27
  import { defaultCloudExecutor } from "./cloud-executor.js";
28
+ import { cfnExports, cfnImportPointer, cfnImports, cfnKnownValues, cfnTemplateWithStandIns, readCfnTemplate, resolveCfnName } from "./cfn-imports.js";
25
29
  /** CloudFormation's wording when a change set has nothing to apply — the stack
26
30
  * is already current. AWS phrases it "The submitted information didn't contain
27
31
  * changes."; `aws cloudformation deploy` reports "No updates are to be
@@ -58,8 +62,11 @@ export function cfnChangeReplaces(change) {
58
62
  * replace, `Remove` a delete, `Import` an import that changes nothing else,
59
63
  * and `Dynamic` an update CloudFormation could not describe further.
60
64
  */
61
- export function cfnChangeEntry(member, change) {
62
- const attributes = [...new Set(change.details ?? [])].sort().map((path) => ({ path }));
65
+ export function cfnChangeEntry(member, change, unknown = []) {
66
+ // A property an unknown input drives is known after apply (#4419); one the
67
+ // change set did not list (a create names no properties) is added for it.
68
+ const paths = [...new Set([...(change.details ?? []), ...unknown])].sort();
69
+ const attributes = paths.map((path) => (unknown.includes(path) ? { path, unknown: true } : { path }));
63
70
  // A change inside a nested stack (#3997) sits under the nested stacks'
64
71
  // logical ids, the way a terraform address sits under its module.
65
72
  const module = change.nestedStack?.length ? change.nestedStack.join("/") : undefined;
@@ -143,7 +150,9 @@ function templateDigest(path) {
143
150
  * not known yet. The proposed changes are not in it; they are the member's
144
151
  * entries, which the change-set document's digest binds (#4084).
145
152
  */
146
- function cfnPlanDigest(ctx, stack, input, parameters, unresolved) {
153
+ function cfnPlanDigest(ctx, stack, input, parameters, unresolved,
154
+ /** The digest of the preview's shape (#4423), so an approval binds what the reviewer saw of a member planned before its inputs were known. */
155
+ preview) {
147
156
  return computePlanDigest("cfn-deploy-plan", {
148
157
  env: ctx.env,
149
158
  stack,
@@ -151,6 +160,9 @@ function cfnPlanDigest(ctx, stack, input, parameters, unresolved) {
151
160
  templateDigest: templateDigest(input.template),
152
161
  parameters,
153
162
  unresolved,
163
+ // #4466: the exports the template imports from stacks in the change, as resolved.
164
+ ...(input.imports && Object.keys(input.imports).length > 0 ? { imports: importValues(input.imports) } : {}),
165
+ ...(preview ? { preview } : {}),
154
166
  });
155
167
  }
156
168
  /**
@@ -160,10 +172,14 @@ function cfnPlanDigest(ctx, stack, input, parameters, unresolved) {
160
172
  * exist yet gets a CREATE change set, so every resource reads as a create,
161
173
  * and the empty `REVIEW_IN_PROGRESS` stack CloudFormation made to hold it is
162
174
  * deleted too. A change set with nothing to apply (`isNoopChangeSet`) gives
163
- * a member with no entries. A step whose parameters read outputs that are
164
- * not known yet, from a stack the same run applies first, creates no change
165
- * set: its member gets a hole saying whose outputs it waits on, and no
166
- * artifact, so `run` plans it afresh.
175
+ * a member with no entries. A step whose parameters or imports read outputs
176
+ * that are not known yet, from a stack the same run applies first, is
177
+ * previewed: its change set is made against a stand-in for each unknown
178
+ * value (#4423), an import standing in as a parameter of a template copy
179
+ * (#4466b), and `run` checks the real change set's shape against the
180
+ * preview's before it executes. When no preview can be made, its member gets
181
+ * a hole saying whose outputs it waits on, and no artifact, so `run` plans
182
+ * it afresh.
167
183
  *
168
184
  * With `onReplace: "block"` (the default), a change set that replaces a
169
185
  * resource fails the plan with {@link CfnReplacementBlockedError}, so the
@@ -194,24 +210,56 @@ async function planCfnDeploy(executor, ctx, input) {
194
210
  parameters[key] = typeof value === "string" ? value : JSON.stringify(value);
195
211
  }
196
212
  const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
197
- const planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved);
213
+ let planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved);
214
+ // #4466: an import whose name the plan cannot resolve is a hole that says why.
215
+ const importHoles = unresolvedImportHoles(stack, input, knownParameters(parameters));
216
+ // #4466c: an export another stack imports that the plan may change, said where it cannot tell.
217
+ const exportHoles = [];
198
218
  const member = (holes) => ({
199
219
  member: stack,
200
220
  lexicon: "aws",
201
221
  planner: "chant",
202
222
  status: "planned",
203
223
  planDigest,
204
- holes,
224
+ holes: [...holes, ...importHoles, ...exportHoles],
205
225
  });
226
+ const standing = standingReader(executor, stack);
206
227
  if (unresolved.length > 0 || missing.length > 0) {
207
- const reason = unresolved.length > 0
208
- ? `inputs from ${unresolved.join(", ")}; re-checked on apply`
209
- : `inputs ${missing.sort().join(", ")} are known only at apply; re-checked on apply`;
210
- return {
228
+ const hole = (why) => ({
211
229
  member: stack,
212
230
  planDigest,
213
- changeSet: { member: member([{ address: stack, type: "AWS::CloudFormation::Stack", reason }]), entries: [] },
214
- };
231
+ changeSet: { member: member([{ address: stack, type: "AWS::CloudFormation::Stack", reason: why }]), entries: [] },
232
+ });
233
+ const waiting = unknownImports(input);
234
+ const fallback = unresolved.length > 0
235
+ ? missing.length === 0 && waiting.length > 0
236
+ ? `imports ${waiting.join(", ")}, exported by ${unresolved.join(", ")}, which apply first; re-checked on apply`
237
+ : `inputs from ${unresolved.join(", ")}; re-checked on apply`
238
+ : `inputs ${[...missing].sort().join(", ")} are known only at apply; re-checked on apply`;
239
+ // Which parameters are unknown: the inputs that hold nothing yet, and a
240
+ // stand-in for each import of an export not known yet (#4466b). When the
241
+ // upstream is unresolved but neither says which, nothing can be previewed.
242
+ if (missing.length === 0 && waiting.length === 0)
243
+ return hole(fallback);
244
+ const copy = waiting.length > 0 ? standInTemplate(stack, input, waiting, knownParameters(parameters)) : undefined;
245
+ if (typeof copy === "string")
246
+ return hole(`${fallback}; no preview: ${copy}`);
247
+ let preview;
248
+ try {
249
+ const previewed = copy ? { ...input, template: copy.path } : input;
250
+ preview = await previewCfnDeploy(executor, stack, previewed, [...missing, ...(copy?.parameters ?? [])].sort(), unresolved.length > 0 ? unresolved : undefined);
251
+ }
252
+ finally {
253
+ copy?.remove();
254
+ }
255
+ if (typeof preview === "string")
256
+ return hole(`${fallback}; no preview: ${preview}`);
257
+ const { entries, shape, outputs } = preview;
258
+ exportHoles.push(...(await exportsInUse(executor, stack, input, knownParameters(parameters), outputs, standing)));
259
+ const artifact = { kind: "cfn-deploy-shape", member: stack, shape, digest: planShapeDigest(shape) };
260
+ // The preview's shape enters the digest (#4423); `member` reads it when called below.
261
+ planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved, artifact.digest);
262
+ return { member: stack, planDigest, artifact, shape, changeSet: { member: member([]), entries }, ...(outputs ? { outputs } : {}) };
215
263
  }
216
264
  const changeSet = await executor.cloudformation.createChangeSet({
217
265
  stackName: stack,
@@ -222,8 +270,8 @@ async function planCfnDeploy(executor, ctx, input) {
222
270
  if (isNoopChangeSet(changeSet)) {
223
271
  const artifact = { kind: "cfn-deploy-plan", stack, changesDigest: cfnChangesDigest(stack, changeSet) };
224
272
  // Nothing to apply, so the outputs the stack has are the ones it will have (#3999).
225
- const standing = await standingOutputs(executor, stack);
226
- const outputs = standing ? { known: { ...standing }, unknown: [] } : undefined;
273
+ const now = await standing();
274
+ const outputs = now ? { known: { ...now.outputs }, unknown: [] } : undefined;
227
275
  return { member: stack, planDigest, artifact, changeSet: { member: member([]), entries: [] }, ...(outputs ? { outputs } : {}) };
228
276
  }
229
277
  if (changeSet.status === "FAILED") {
@@ -234,8 +282,176 @@ async function planCfnDeploy(executor, ctx, input) {
234
282
  throw new CfnReplacementBlockedError(stack, blocked);
235
283
  const entries = changeSet.changes.map((c) => cfnChangeEntry(stack, c));
236
284
  const artifact = { kind: "cfn-deploy-plan", stack, changesDigest: cfnChangesDigest(stack, changeSet) };
237
- const outputs = changeSet.stackCreated ? undefined : await plannedStackOutputs(executor, stack, input.template, changeSet.changes);
238
- return { member: stack, planDigest, artifact, changeSet: { member: member([]), entries }, ...(outputs ? { outputs } : {}) };
285
+ const outputs = changeSet.stackCreated
286
+ ? undefined
287
+ : await plannedStackOutputs(standing, input.template, changeSet.changes, knownParameters(parameters), importValues(input.imports ?? {}));
288
+ // #4466c: CloudFormation refuses to change or remove an export another stack imports; say so now, not at apply.
289
+ if (!changeSet.stackCreated)
290
+ exportHoles.push(...(await exportsInUse(executor, stack, input, knownParameters(parameters), outputs, standing)));
291
+ // The real change set's shape, with the replacement CloudFormation reports,
292
+ // for a round that checks it against a preview (#4419).
293
+ const shape = cfnPlanShape(stack, changeSet, []);
294
+ return { member: stack, planDigest, artifact, shape, changeSet: { member: member([]), entries }, ...(outputs ? { outputs } : {}) };
295
+ }
296
+ finally {
297
+ await cleanUpPlanChangeSet(executor, changeSet);
298
+ }
299
+ }
300
+ function isCfnShapeArtifact(value) {
301
+ const v = value;
302
+ return v?.kind === "cfn-deploy-shape" && typeof v.digest === "string" && typeof v.shape === "object" && v.shape !== null;
303
+ }
304
+ /** The marker every stand-in carries, so a reader of a change set can tell it from a real value. */
305
+ export const CFN_UNKNOWN_MARK = "known-after-apply";
306
+ const ID_PREFIX = {
307
+ "AWS::EC2::Subnet::Id": "subnet",
308
+ "AWS::EC2::VPC::Id": "vpc",
309
+ "AWS::EC2::SecurityGroup::Id": "sg",
310
+ "AWS::EC2::Instance::Id": "i",
311
+ "AWS::EC2::Image::Id": "ami",
312
+ "AWS::EC2::Volume::Id": "vol",
313
+ };
314
+ /**
315
+ * A stand-in for a parameter whose value is not known yet: valid for the
316
+ * parameter's type and recognisable. An EC2 id type gets the id's prefix
317
+ * (`subnet-00000000known-after-apply`), a number gets 0, anything else gets
318
+ * `known-after-apply`. Real CloudFormation checks the AWS-specific types
319
+ * against the account and refuses an id that does not exist; the preview
320
+ * then falls back to a hole. Floci does not check.
321
+ */
322
+ export function cfnPlaceholder(type) {
323
+ if (type && ID_PREFIX[type])
324
+ return `${ID_PREFIX[type]}-00000000${CFN_UNKNOWN_MARK}`;
325
+ if (type === "Number")
326
+ return "0";
327
+ if (type?.startsWith("AWS::SSM::Parameter::Value<"))
328
+ return `/${CFN_UNKNOWN_MARK}`;
329
+ return CFN_UNKNOWN_MARK;
330
+ }
331
+ function readsParameter(value, key) {
332
+ if (typeof value === "string")
333
+ return false;
334
+ if (Array.isArray(value))
335
+ return value.some((v) => readsParameter(v, key));
336
+ if (value && typeof value === "object") {
337
+ const obj = value;
338
+ if (obj.Ref === key)
339
+ return true;
340
+ const sub = obj["Fn::Sub"];
341
+ const text = typeof sub === "string" ? sub : Array.isArray(sub) && typeof sub[0] === "string" ? sub[0] : undefined;
342
+ if (text !== undefined && (text.includes("${" + key + "}") || text.includes("${" + key + ".")))
343
+ return true;
344
+ return Object.values(obj).some((v) => readsParameter(v, key));
345
+ }
346
+ return false;
347
+ }
348
+ /** The parameter types and the properties that read `keys`, from the template. A template that cannot be read gives no facts. */
349
+ function templateFacts(path, keys) {
350
+ const facts = { parameterTypes: {}, driven: new Map() };
351
+ try {
352
+ const t = JSON.parse(readFileSync(path, "utf8"));
353
+ for (const k of keys)
354
+ if (t.Parameters?.[k]?.Type)
355
+ facts.parameterTypes[k] = t.Parameters[k].Type;
356
+ for (const [id, r] of Object.entries(t.Resources ?? {})) {
357
+ for (const [prop, value] of Object.entries(r.Properties ?? {})) {
358
+ if (keys.some((k) => readsParameter(value, k)))
359
+ (facts.driven.get(id) ?? facts.driven.set(id, new Set()).get(id)).add(prop);
360
+ }
361
+ }
362
+ }
363
+ catch {
364
+ /* unreadable or not JSON: the change set reports why, and no property is attributed */
365
+ }
366
+ return facts;
367
+ }
368
+ /** The properties of one change that the unknown parameters drive. */
369
+ function drivenProperties(change, keys, facts) {
370
+ const out = new Set();
371
+ for (const d of change.drivers ?? []) {
372
+ if (d.changeSource === "ParameterReference" && d.causingEntity && keys.some((k) => d.causingEntity === k || d.causingEntity.startsWith(`${k}.`))) {
373
+ out.add(d.property);
374
+ }
375
+ }
376
+ const scanned = facts.driven.get(change.logicalResourceId);
377
+ if (scanned && change.action !== "Remove") {
378
+ const listed = change.details ?? [];
379
+ for (const p of scanned)
380
+ if (change.action === "Add" || listed.length === 0 || listed.includes(p))
381
+ out.add(p);
382
+ }
383
+ return [...out].sort();
384
+ }
385
+ function shapeReplacement(change) {
386
+ if (change.replacementMode === "True" || change.replacement)
387
+ return "true";
388
+ return change.replacementMode === "Conditional" ? "conditional" : "false";
389
+ }
390
+ /**
391
+ * A change set's shape: each change's resource, action, replacement and
392
+ * properties, with the ones the `unknown` parameters drive marked. `unknown`
393
+ * is empty for the change set made against real values. `refs` are the
394
+ * inputs the shape names as unknown (`<component>.<output>`), the parameters
395
+ * themselves when nothing says better.
396
+ */
397
+ export function cfnPlanShape(stack, changeSet, unknown, templatePath, refs = unknown) {
398
+ const facts = unknown.length > 0 && templatePath ? templateFacts(templatePath, unknown) : { parameterTypes: {}, driven: new Map() };
399
+ const entries = isNoopChangeSet(changeSet)
400
+ ? []
401
+ : changeSet.changes.map((c) => {
402
+ const e = cfnChangeEntry(stack, c);
403
+ return {
404
+ logicalId: e.address,
405
+ action: e.action,
406
+ replacement: shapeReplacement(c),
407
+ type: c.resourceType,
408
+ properties: [...new Set(c.details ?? [])],
409
+ unknown: drivenProperties(c, unknown, facts),
410
+ };
411
+ });
412
+ return normalizePlanShape({ member: stack, unknownInputs: refs, entries });
413
+ }
414
+ /**
415
+ * Preview a stack whose parameters `unknown` are not known yet: create its
416
+ * change set with a stand-in for each, read the changes into entries that
417
+ * mark the properties those parameters drive as known after apply, take the
418
+ * shape, and delete the change set. Returns why instead when CloudFormation
419
+ * would not make the change set (real CloudFormation refuses an id that does
420
+ * not exist), so the caller plans a hole.
421
+ *
422
+ * Floci accepts a change set that real CloudFormation refuses (a missing
423
+ * export, an id that does not exist), reports `Parameters: null`, and says
424
+ * `Replacement: False` for a change that replaces. So a cross-stack value
425
+ * goes through a parameter here, never an export: an import of an export
426
+ * not known yet is planned from a copy of the template in which it is a
427
+ * stand-in parameter (#4466b, `cfnTemplateWithStandIns`). The replacement a
428
+ * preview reports is whatever the backend says; the shape check at apply
429
+ * compares like with like.
430
+ */
431
+ async function previewCfnDeploy(executor, stack, input, unknown, refs = unknown) {
432
+ const facts = templateFacts(input.template, unknown);
433
+ const parameters = { ...(flattenCfnInputs(input) ?? {}) };
434
+ for (const k of unknown)
435
+ parameters[k] = cfnPlaceholder(facts.parameterTypes[k]);
436
+ let changeSet;
437
+ try {
438
+ changeSet = await executor.cloudformation.createChangeSet({ stackName: stack, templatePath: input.template, parameters });
439
+ }
440
+ catch (err) {
441
+ return `CloudFormation refused the change set with stand-in values: ${err instanceof Error ? err.message : String(err)}`;
442
+ }
443
+ try {
444
+ if (changeSet.status === "FAILED" && !isNoopChangeSet(changeSet)) {
445
+ return `CloudFormation could not create the change set with stand-in values${changeSet.statusReason ? `: ${changeSet.statusReason}` : ""}`;
446
+ }
447
+ const noop = isNoopChangeSet(changeSet);
448
+ const entries = noop ? [] : changeSet.changes.map((c) => cfnChangeEntry(stack, c, drivenProperties(c, unknown, facts)));
449
+ // What the stack's outputs will be, for the members that read them: an
450
+ // output that reads one of the unknown parameters is unknown, one that
451
+ // reads a known parameter or states its value is not (#4419).
452
+ const given = Object.fromEntries(Object.entries(parameters).filter(([k]) => !unknown.includes(k)));
453
+ const outputs = noop || changeSet.stackCreated ? undefined : await plannedStackOutputs(standingReader(executor, stack), input.template, changeSet.changes, given);
454
+ return { entries, shape: cfnPlanShape(stack, changeSet, unknown, input.template, refs), ...(outputs ? { outputs } : {}) };
239
455
  }
240
456
  finally {
241
457
  await cleanUpPlanChangeSet(executor, changeSet);
@@ -263,26 +479,23 @@ async function cleanUpPlanChangeSet(executor, changeSet) {
263
479
  }
264
480
  }
265
481
  }
266
- /** The stack's outputs as they stand, or `undefined` when they cannot be read. */
267
- async function standingOutputs(executor, stack) {
268
- try {
269
- return { ...(await executor.cloudformation.describeStack(stack)).outputs };
270
- }
271
- catch {
272
- return undefined;
273
- }
482
+ /** The stack as it stands, read once however often it is asked for, or `undefined` when it cannot be read. */
483
+ function standingReader(executor, stack) {
484
+ let read;
485
+ return () => (read ?? (read = executor.cloudformation.describeStack(stack).then((s) => s, () => undefined)));
274
486
  }
275
487
  /**
276
488
  * What the plan says of the stack's outputs once `changes` apply: none to
277
489
  * say for a template with no `Outputs`, which needs no `DescribeStacks`.
278
490
  */
279
- async function plannedStackOutputs(executor, stack, templatePath, changes) {
491
+ async function plannedStackOutputs(standing, templatePath, changes, parameters = {}, imports = {}) {
280
492
  const template = readTemplate(templatePath);
281
493
  if (!template || typeof template !== "object")
282
494
  return undefined;
283
495
  if (Object.keys(template.Outputs ?? {}).length === 0)
284
496
  return { known: {}, unknown: [] };
285
- return cfnPlannedOutputs(template, changes, await standingOutputs(executor, stack));
497
+ const now = await standing();
498
+ return cfnPlannedOutputs(template, changes, now ? { ...now.outputs } : undefined, parameters, imports);
286
499
  }
287
500
  /** The template at `path` as JSON, or `undefined` when it cannot be read or parsed here. */
288
501
  function readTemplate(path) {
@@ -364,9 +577,20 @@ function valueReads(value) {
364
577
  * `Dynamic`, no replacement, no change in a nested stack under it). One
365
578
  * that reads a parameter, an import or a condition, one the stack does not
366
579
  * have yet, and every output of a template this cannot read, is unknown.
580
+ *
581
+ * Two kinds of output need no standing value. An output whose `Value` is a
582
+ * plain string is known at that string, changed or new: the template says it.
583
+ * An output that is a `Ref` to a parameter the plan was given a value for is
584
+ * known at that value (`given`, the inputs of this plan). Both are what
585
+ * the stack's outputs will be, whatever they are now.
586
+ *
587
+ * An output that passes an import through (`Fn::ImportValue` of a name
588
+ * `imports` holds, #4466) is known at the value the exporting stack's plan
589
+ * gave it, which is the value core read for the import.
590
+ *
367
591
  * `undefined` when there is no template or no standing stack to read from.
368
592
  */
369
- export function cfnPlannedOutputs(template, changes, standing) {
593
+ export function cfnPlannedOutputs(template, changes, standing, given = {}, imports = {}) {
370
594
  if (!standing || template === null || typeof template !== "object")
371
595
  return undefined;
372
596
  const t = template;
@@ -381,6 +605,11 @@ export function cfnPlannedOutputs(template, changes, standing) {
381
605
  const known = {};
382
606
  const unknown = [];
383
607
  for (const [name, output] of Object.entries(t.Outputs ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
608
+ const stated = statedOutput(output, given, t.Parameters, imports);
609
+ if (stated !== undefined) {
610
+ known[name] = stated;
611
+ continue;
612
+ }
384
613
  const { ids, opaque } = valueReads(output?.Value);
385
614
  const stays = !opaque && output?.Condition === undefined && name in standing &&
386
615
  [...ids].every((id) => id.startsWith("AWS::") || (!parameters.has(id) && !moved.has(id)));
@@ -391,8 +620,222 @@ export function cfnPlannedOutputs(template, changes, standing) {
391
620
  }
392
621
  return { known, unknown };
393
622
  }
623
+ /** The inputs a plan was given a value for: every parameter that is not null. */
624
+ function knownParameters(parameters) {
625
+ return Object.fromEntries(Object.entries(parameters).filter((e) => e[1] !== null));
626
+ }
627
+ /**
628
+ * What an output's `Value` says outright: a plain string, or a `Ref` to a
629
+ * parameter this plan has a value for. `undefined` for anything that reads a
630
+ * resource, an import or a condition, which the standing outputs speak for.
631
+ */
632
+ function statedOutput(output, parameters, declared, imports = {}) {
633
+ if (output?.Condition !== undefined)
634
+ return undefined;
635
+ const value = output?.Value;
636
+ if (typeof value === "string")
637
+ return value;
638
+ if (value !== null && typeof value === "object") {
639
+ const entries = Object.entries(value);
640
+ const [key, arg] = entries[0] ?? [];
641
+ if (entries.length === 1 && key === "Ref" && typeof arg === "string" && declared && arg in declared && arg in parameters)
642
+ return parameters[arg];
643
+ if (entries.length === 1 && key === "Fn::ImportValue") {
644
+ const name = resolveCfnName(arg, parameters);
645
+ if ("name" in name && name.name in imports)
646
+ return imports[name.name];
647
+ }
648
+ }
649
+ return undefined;
650
+ }
651
+ // ── Fn::ImportValue between stacks (#4466) ──────────────────────────────────
652
+ /** The imports core read a value for, as strings; one still unknown is left out. */
653
+ function importValues(imports) {
654
+ const out = {};
655
+ for (const [name, v] of Object.entries(imports).sort(([a], [b]) => a.localeCompare(b))) {
656
+ if (v === undefined || v === null)
657
+ continue;
658
+ out[name] = typeof v === "string" ? v : JSON.stringify(v);
659
+ }
660
+ return out;
661
+ }
662
+ /** The export names the step imports from a stack in the change whose value is not known yet, sorted. */
663
+ function unknownImports(input) {
664
+ return Object.entries(input.imports ?? {})
665
+ .filter(([, v]) => v === undefined || v === null)
666
+ .map(([name]) => name)
667
+ .sort();
668
+ }
669
+ /**
670
+ * A hole for each `Fn::ImportValue` in the template whose name the plan
671
+ * cannot resolve (#4466): it may name an export of a stack in this change,
672
+ * and then nothing orders the two, so the plan says it cannot tell.
673
+ */
674
+ function unresolvedImportHoles(stack, input, parameters) {
675
+ const template = readCfnTemplate(input.template);
676
+ if (!template)
677
+ return [];
678
+ const resources = (template.Resources ?? {});
679
+ const holes = [];
680
+ for (const i of cfnImports(template, cfnKnownValues(template, stack, parameters))) {
681
+ if (!("unresolved" in i))
682
+ continue;
683
+ const type = typeof resources[i.logicalId]?.Type === "string" ? resources[i.logicalId].Type : undefined;
684
+ holes.push({
685
+ address: i.logicalId,
686
+ ...(type ? { type } : {}),
687
+ reason: `the Fn::ImportValue at ${i.at} ${i.unresolved}, so the plan cannot say which export it reads or whether a stack in this change exports it`,
688
+ });
689
+ }
690
+ return holes;
691
+ }
692
+ /**
693
+ * The importer's template with each import of `names` as a stand-in
694
+ * parameter (#4466b), written to a scratch file the preview creates its
695
+ * change set from; or why there is none. `remove` deletes the file.
696
+ */
697
+ function standInTemplate(stack, input, names, parameters) {
698
+ const template = readCfnTemplate(input.template);
699
+ if (!template)
700
+ return "the template could not be read as JSON to stand in for its imports";
701
+ const { template: copy, standIns } = cfnTemplateWithStandIns(template, names, cfnKnownValues(template, stack, parameters));
702
+ const params = Object.keys(standIns).sort();
703
+ if (params.length === 0)
704
+ return `the template imports none of ${names.join(", ")} by a name the plan resolves`;
705
+ const dir = mkdtempSync(join(tmpdir(), "chant-cfn-preview-"));
706
+ const path = join(dir, `${stack}.preview.template.json`);
707
+ writeFileSync(path, JSON.stringify(copy, null, 2));
708
+ return { path, parameters: params, remove: () => rmSync(dir, { recursive: true, force: true }) };
709
+ }
710
+ /**
711
+ * `cfn-deploy`'s `declaredReads` (#4466): each `Fn::ImportValue` of a name
712
+ * exactly one other `cfn-deploy` step's template exports is a read of that
713
+ * component's output, placed under the step's `imports`. A name two steps
714
+ * export is left alone (CloudFormation refuses the second export), and so
715
+ * is a name no step in the project exports: it reads a stack outside it.
716
+ */
717
+ export function cfnDeclaredReads(root, members) {
718
+ const seen = members.map((m) => {
719
+ const template = readCfnTemplate(m.step.template, root);
720
+ const stack = typeof m.step.stack === "string" ? m.step.stack : m.component;
721
+ const inputs = (m.step.inputs ?? {});
722
+ const known = cfnKnownValues(template, stack, typeof m.step.imageRef === "string" ? { ...inputs, ImageRef: m.step.imageRef } : inputs);
723
+ return { member: m, template, known };
724
+ });
725
+ const exporters = new Map();
726
+ for (const { member, template, known } of seen) {
727
+ for (const e of cfnExports(template, known)) {
728
+ if (!("name" in e))
729
+ continue;
730
+ (exporters.get(e.name) ?? exporters.set(e.name, []).get(e.name)).push({ component: member.component, output: e.output });
731
+ }
732
+ }
733
+ const out = [];
734
+ for (const { member, template, known } of seen) {
735
+ const done = new Set();
736
+ for (const i of cfnImports(template, known)) {
737
+ if (!("name" in i) || done.has(i.name))
738
+ continue;
739
+ done.add(i.name);
740
+ const owners = (exporters.get(i.name) ?? []).filter((o) => o.component !== member.component);
741
+ if (owners.length !== 1 || (exporters.get(i.name) ?? []).length !== 1)
742
+ continue;
743
+ out.push({ reader: member.component, index: member.index, upstream: owners[0].component, output: owners[0].output, at: cfnImportPointer(i.name) });
744
+ }
745
+ }
746
+ return out;
747
+ }
394
748
  /** Whether a CloudFormation error says the stack does not exist. */
395
749
  const STACK_MISSING = /does not exist/i;
750
+ /**
751
+ * Thrown at plan time when a change set would change or remove an export
752
+ * that another stack still imports (#4466c). CloudFormation refuses that
753
+ * update at apply; the plan refuses it first, naming both stacks.
754
+ */
755
+ export class CfnExportInUseError extends Error {
756
+ constructor(stack, uses) {
757
+ const importers = [...new Set(uses.flatMap((u) => u.importers))].sort();
758
+ super(`cfn-deploy "${stack}": refusing the plan: ` +
759
+ uses
760
+ .map((u) => `it ${u.change.replace("{export}", `export ${u.exportName}`)}, which ${u.importers.length === 1 ? "stack" : "stacks"} ${u.importers.join(", ")} still ${u.importers.length === 1 ? "imports" : "import"}`)
761
+ .join("; ") +
762
+ `. CloudFormation refuses to change or remove an export another stack imports. Change ${importers.join(", ")} to stop importing it first, then change ${stack}.`);
763
+ this.stack = stack;
764
+ this.uses = uses;
765
+ this.name = "CfnExportInUseError";
766
+ }
767
+ }
768
+ /**
769
+ * The exports `stack` has now that this plan changes or removes while
770
+ * another stack imports them (#4466c). An export the new template no longer
771
+ * declares is removed; one whose planned value differs from the value it
772
+ * has is changed: both throw {@link CfnExportInUseError}. One the plan
773
+ * cannot say about (its output's value is known only after apply, or an
774
+ * export name in the new template does not resolve) is a hole naming the
775
+ * importers. Asks `ListImports` only for exports at stake, and nothing at
776
+ * all when the executor cannot list imports or the stack exports nothing.
777
+ */
778
+ async function exportsInUse(executor, stack, input, parameters, planned, standing) {
779
+ const listImports = executor.cloudformation.listImports?.bind(executor.cloudformation);
780
+ if (!listImports)
781
+ return [];
782
+ const was = (await standing())?.exports;
783
+ if (!was || Object.keys(was).length === 0)
784
+ return [];
785
+ const template = readCfnTemplate(input.template);
786
+ if (!template)
787
+ return [];
788
+ const declared = cfnExports(template, cfnKnownValues(template, stack, parameters));
789
+ const now = new Map(declared.filter((e) => "name" in e).map((e) => [e.name, e.output]));
790
+ const unresolvedNow = declared.some((e) => "unresolved" in e);
791
+ const refused = [];
792
+ const holes = [];
793
+ for (const [exportName, { output, value }] of Object.entries(was).sort(([a], [b]) => a.localeCompare(b))) {
794
+ const at = now.get(exportName);
795
+ let change;
796
+ let maybe;
797
+ if (at === undefined) {
798
+ if (unresolvedNow)
799
+ maybe = `may remove export ${exportName}: an export name the template declares does not resolve at plan`;
800
+ else
801
+ change = "removes {export}";
802
+ }
803
+ else {
804
+ const next = planned?.known[at];
805
+ if (next === undefined)
806
+ maybe = `may change export ${exportName}: the value of output ${at} is known only after apply`;
807
+ else if ((typeof next === "string" ? next : JSON.stringify(next)) !== value)
808
+ change = `changes {export} from ${value} to ${typeof next === "string" ? next : JSON.stringify(next)}`;
809
+ }
810
+ if (change === undefined && maybe === undefined)
811
+ continue;
812
+ let importers;
813
+ try {
814
+ importers = (await listImports(exportName)).filter((s) => s !== stack);
815
+ }
816
+ catch (err) {
817
+ // A backend without ListImports (an emulator, say) cannot say who imports it; the plan says so instead of failing.
818
+ holes.push({
819
+ address: `Outputs.${at ?? output}`,
820
+ reason: `the plan ${change?.replace("{export}", `export ${exportName}`) ?? maybe}, and the stacks that import it could not be listed: ${err instanceof Error ? err.message : String(err)}`,
821
+ });
822
+ continue;
823
+ }
824
+ if (importers.length === 0)
825
+ continue;
826
+ if (change !== undefined)
827
+ refused.push({ exportName, change, importers });
828
+ else {
829
+ holes.push({
830
+ address: `Outputs.${at ?? output}`,
831
+ reason: `the plan ${maybe}, and ${importers.join(", ")} still ${importers.length === 1 ? "imports" : "import"} it; CloudFormation refuses the apply if it changes`,
832
+ });
833
+ }
834
+ }
835
+ if (refused.length > 0)
836
+ throw new CfnExportInUseError(stack, refused);
837
+ return holes;
838
+ }
396
839
  /**
397
840
  * Thrown when a changeset proposes a resource replacement and `onReplace` is
398
841
  * `"block"` (the default) — refusing to apply rather than silently destroying
@@ -440,6 +883,9 @@ export function createCfnDeployCapability(executor = defaultCloudExecutor()) {
440
883
  plan(ctx, input) {
441
884
  return planCfnDeploy(executor, ctx, input);
442
885
  },
886
+ declaredReads(ctx, members) {
887
+ return cfnDeclaredReads(ctx.root, members);
888
+ },
443
889
  async outputs(ctx, input) {
444
890
  // The outputs the stack has now, for a dependent planned before it
445
891
  // applies (#3183). A stack that does not exist yet has none.
@@ -464,7 +910,17 @@ export function createCfnDeployCapability(executor = defaultCloudExecutor()) {
464
910
  // set that proposes the same changes. Anything else means the stack,
465
911
  // the template or the parameters moved since the plan.
466
912
  const planned = ctx.plans?.[stack];
467
- if (isCfnPlanArtifact(planned)) {
913
+ if (isCfnShapeArtifact(planned)) {
914
+ // A member planned before its inputs were known (#4419): the change
915
+ // set made now is against the real values. It must have the shape the
916
+ // preview had, differing only in what the unknown inputs drive.
917
+ const differences = planShapeDifferences(planned.shape, cfnPlanShape(stack, changeSet, []));
918
+ if (differences.length > 0) {
919
+ await cleanUpPlanChangeSet(executor, changeSet);
920
+ throw new PlanShapeMovedError(stack, differences);
921
+ }
922
+ }
923
+ else if (isCfnPlanArtifact(planned)) {
468
924
  const now = cfnChangesDigest(stack, changeSet);
469
925
  if (now !== planned.changesDigest) {
470
926
  await cleanUpPlanChangeSet(executor, changeSet);