@intentius/chant-lexicon-aws 0.124.52 → 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.
- package/dist/codegen/generate-lexicon.d.ts +2 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/codegen/generate-lexicon.js +1 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts +4 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -1
- package/dist/components/__tests__/mock-cloud-executor.js +18 -1
- package/dist/components/apply.d.ts +44 -2
- package/dist/components/apply.d.ts.map +1 -1
- package/dist/components/apply.js +260 -29
- package/dist/components/cfn-adopt.d.ts +99 -0
- package/dist/components/cfn-adopt.d.ts.map +1 -0
- package/dist/components/cfn-adopt.js +278 -0
- package/dist/components/cfn-imports.d.ts +84 -0
- package/dist/components/cfn-imports.d.ts.map +1 -0
- package/dist/components/cfn-imports.js +239 -0
- package/dist/components/cloud-executor.d.ts +31 -2
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/components/cloud-executor.js +64 -2
- package/dist/importable.d.ts +30 -0
- package/dist/importable.d.ts.map +1 -0
- package/dist/importable.js +72 -0
- package/dist/index-identity.d.ts +19 -0
- package/dist/index-identity.d.ts.map +1 -0
- package/dist/index-identity.js +79 -0
- package/dist/index-import.d.ts +17 -0
- package/dist/index-import.d.ts.map +1 -0
- package/dist/index-import.js +115 -0
- package/dist/integrity.json +3 -3
- package/dist/listed-types.d.ts +11 -0
- package/dist/listed-types.d.ts.map +1 -0
- package/dist/listed-types.js +52 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +13206 -1237
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +9 -0
- package/dist/reference-catalog.d.ts.map +1 -1
- package/dist/reference-catalog.js +62 -20
- package/dist/spec/fetch.d.ts +5 -0
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +8 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/spec/parse.js +3 -0
- package/package.json +2 -2
- package/src/codegen/generate-lexicon.ts +3 -0
- package/src/components/__tests__/mock-cloud-executor.ts +20 -1
- package/src/components/apply.ts +282 -29
- package/src/components/cfn-adopt.test.ts +284 -0
- package/src/components/cfn-adopt.ts +340 -0
- package/src/components/cfn-exports-in-use.test.ts +146 -0
- package/src/components/cfn-imports-preview.test.ts +178 -0
- package/src/components/cfn-imports-three-stacks.test.ts +107 -0
- package/src/components/cfn-imports.test.ts +213 -0
- package/src/components/cfn-imports.ts +246 -0
- package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
- package/src/components/cloud-executor.ts +102 -5
- package/src/components/demo-composed-stacks-floci.e2e.test.ts +235 -0
- package/src/derived-edges.test.ts +142 -0
- package/src/generated/lexicon-aws.json +13206 -1237
- package/src/importable.test.ts +86 -0
- package/src/importable.ts +83 -0
- package/src/index-identity.test.ts +134 -0
- package/src/index-identity.ts +98 -0
- package/src/index-import.test.ts +242 -0
- package/src/index-import.ts +140 -0
- package/src/index-search.test.ts +87 -0
- package/src/listed-types.test.ts +97 -0
- package/src/listed-types.ts +56 -0
- package/src/plugin.ts +10 -0
- package/src/reference-catalog.ts +63 -20
- package/src/spec/fetch.ts +5 -0
- package/src/spec/parse.test.ts +15 -0
- package/src/spec/parse.ts +12 -0
- package/src/testdata/indexed/identity.listing.json +65 -0
- package/src/testdata/indexed/import.listing.json +126 -0
- package/src/testdata/indexed/listing.json +69 -0
- package/src/testdata/indexed/topology.listing.json +350 -0
- package/src/testdata/three-cloud/dns.graph.json +336 -0
- package/src/three-cloud-parity.test.ts +49 -0
package/src/components/apply.ts
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { createHash } from "node:crypto";
|
|
23
|
-
import { readFileSync } from "node:fs";
|
|
24
|
-
import
|
|
23
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import type { Capability, CapabilityPlan, DeclaredRead, DeployContext, PlannedOutputs } from "@intentius/chant/components/capability";
|
|
25
27
|
import type { ChangeSetEntry, ChangeSetHole, ChangeSetPart } from "@intentius/chant/change-set";
|
|
26
28
|
import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
|
|
27
29
|
import {
|
|
@@ -34,7 +36,8 @@ import {
|
|
|
34
36
|
type PlanShapeEntry,
|
|
35
37
|
type ShapeReplacement,
|
|
36
38
|
} from "@intentius/chant/components/covered-round";
|
|
37
|
-
import { defaultCloudExecutor, type CfnChange, type CfnChangeSet, type CloudExecutor } from "./cloud-executor";
|
|
39
|
+
import { defaultCloudExecutor, type CfnChange, type CfnChangeSet, type CfnStackStatus, type CloudExecutor } from "./cloud-executor";
|
|
40
|
+
import { cfnExports, cfnImportPointer, cfnImports, cfnKnownValues, cfnTemplateWithStandIns, readCfnTemplate, resolveCfnName } from "./cfn-imports";
|
|
38
41
|
|
|
39
42
|
/** CloudFormation's wording when a change set has nothing to apply — the stack
|
|
40
43
|
* is already current. AWS phrases it "The submitted information didn't contain
|
|
@@ -200,6 +203,8 @@ function cfnPlanDigest(
|
|
|
200
203
|
templateDigest: templateDigest(input.template),
|
|
201
204
|
parameters,
|
|
202
205
|
unresolved,
|
|
206
|
+
// #4466: the exports the template imports from stacks in the change, as resolved.
|
|
207
|
+
...(input.imports && Object.keys(input.imports).length > 0 ? { imports: importValues(input.imports) } : {}),
|
|
203
208
|
...(preview ? { preview } : {}),
|
|
204
209
|
});
|
|
205
210
|
}
|
|
@@ -211,10 +216,14 @@ function cfnPlanDigest(
|
|
|
211
216
|
* exist yet gets a CREATE change set, so every resource reads as a create,
|
|
212
217
|
* and the empty `REVIEW_IN_PROGRESS` stack CloudFormation made to hold it is
|
|
213
218
|
* deleted too. A change set with nothing to apply (`isNoopChangeSet`) gives
|
|
214
|
-
* a member with no entries. A step whose parameters read outputs
|
|
215
|
-
* not known yet, from a stack the same run applies first,
|
|
216
|
-
*
|
|
217
|
-
*
|
|
219
|
+
* a member with no entries. A step whose parameters or imports read outputs
|
|
220
|
+
* that are not known yet, from a stack the same run applies first, is
|
|
221
|
+
* previewed: its change set is made against a stand-in for each unknown
|
|
222
|
+
* value (#4423), an import standing in as a parameter of a template copy
|
|
223
|
+
* (#4466b), and `run` checks the real change set's shape against the
|
|
224
|
+
* preview's before it executes. When no preview can be made, its member gets
|
|
225
|
+
* a hole saying whose outputs it waits on, and no artifact, so `run` plans
|
|
226
|
+
* it afresh.
|
|
218
227
|
*
|
|
219
228
|
* With `onReplace: "block"` (the default), a change set that replaces a
|
|
220
229
|
* resource fails the plan with {@link CfnReplacementBlockedError}, so the
|
|
@@ -244,14 +253,19 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
244
253
|
}
|
|
245
254
|
const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
|
|
246
255
|
let planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved);
|
|
256
|
+
// #4466: an import whose name the plan cannot resolve is a hole that says why.
|
|
257
|
+
const importHoles = unresolvedImportHoles(stack, input, knownParameters(parameters));
|
|
258
|
+
// #4466c: an export another stack imports that the plan may change, said where it cannot tell.
|
|
259
|
+
const exportHoles: ChangeSetHole[] = [];
|
|
247
260
|
const member = (holes: ChangeSetHole[]): ChangeSetPart["member"] => ({
|
|
248
261
|
member: stack,
|
|
249
262
|
lexicon: "aws",
|
|
250
263
|
planner: "chant",
|
|
251
264
|
status: "planned",
|
|
252
265
|
planDigest,
|
|
253
|
-
holes,
|
|
266
|
+
holes: [...holes, ...importHoles, ...exportHoles],
|
|
254
267
|
});
|
|
268
|
+
const standing = standingReader(executor, stack);
|
|
255
269
|
|
|
256
270
|
if (unresolved.length > 0 || missing.length > 0) {
|
|
257
271
|
const hole = (why: string): CapabilityPlan => ({
|
|
@@ -259,16 +273,29 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
259
273
|
planDigest,
|
|
260
274
|
changeSet: { member: member([{ address: stack, type: "AWS::CloudFormation::Stack", reason: why }]), entries: [] },
|
|
261
275
|
});
|
|
276
|
+
const waiting = unknownImports(input);
|
|
262
277
|
const fallback =
|
|
263
278
|
unresolved.length > 0
|
|
264
|
-
?
|
|
279
|
+
? missing.length === 0 && waiting.length > 0
|
|
280
|
+
? `imports ${waiting.join(", ")}, exported by ${unresolved.join(", ")}, which apply first; re-checked on apply`
|
|
281
|
+
: `inputs from ${unresolved.join(", ")}; re-checked on apply`
|
|
265
282
|
: `inputs ${[...missing].sort().join(", ")} are known only at apply; re-checked on apply`;
|
|
266
|
-
// Which parameters are unknown: the inputs that hold nothing yet
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
283
|
+
// Which parameters are unknown: the inputs that hold nothing yet, and a
|
|
284
|
+
// stand-in for each import of an export not known yet (#4466b). When the
|
|
285
|
+
// upstream is unresolved but neither says which, nothing can be previewed.
|
|
286
|
+
if (missing.length === 0 && waiting.length === 0) return hole(fallback);
|
|
287
|
+
const copy = waiting.length > 0 ? standInTemplate(stack, input, waiting, knownParameters(parameters)) : undefined;
|
|
288
|
+
if (typeof copy === "string") return hole(`${fallback}; no preview: ${copy}`);
|
|
289
|
+
let preview: Awaited<ReturnType<typeof previewCfnDeploy>>;
|
|
290
|
+
try {
|
|
291
|
+
const previewed = copy ? { ...input, template: copy.path } : input;
|
|
292
|
+
preview = await previewCfnDeploy(executor, stack, previewed, [...missing, ...(copy?.parameters ?? [])].sort(), unresolved.length > 0 ? unresolved : undefined);
|
|
293
|
+
} finally {
|
|
294
|
+
copy?.remove();
|
|
295
|
+
}
|
|
270
296
|
if (typeof preview === "string") return hole(`${fallback}; no preview: ${preview}`);
|
|
271
297
|
const { entries, shape, outputs } = preview;
|
|
298
|
+
exportHoles.push(...(await exportsInUse(executor, stack, input, knownParameters(parameters), outputs, standing)));
|
|
272
299
|
const artifact: CfnShapeArtifact = { kind: "cfn-deploy-shape", member: stack, shape, digest: planShapeDigest(shape) };
|
|
273
300
|
// The preview's shape enters the digest (#4423); `member` reads it when called below.
|
|
274
301
|
planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved, artifact.digest);
|
|
@@ -284,8 +311,8 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
284
311
|
if (isNoopChangeSet(changeSet)) {
|
|
285
312
|
const artifact: CfnPlanArtifact = { kind: "cfn-deploy-plan", stack, changesDigest: cfnChangesDigest(stack, changeSet) };
|
|
286
313
|
// Nothing to apply, so the outputs the stack has are the ones it will have (#3999).
|
|
287
|
-
const
|
|
288
|
-
const outputs =
|
|
314
|
+
const now = await standing();
|
|
315
|
+
const outputs = now ? { known: { ...now.outputs }, unknown: [] } : undefined;
|
|
289
316
|
return { member: stack, planDigest, artifact, changeSet: { member: member([]), entries: [] }, ...(outputs ? { outputs } : {}) };
|
|
290
317
|
}
|
|
291
318
|
if (changeSet.status === "FAILED") {
|
|
@@ -297,7 +324,11 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
297
324
|
if (blocked.length > 0 && (input.onReplace ?? "block") === "block") throw new CfnReplacementBlockedError(stack, blocked);
|
|
298
325
|
const entries = changeSet.changes.map((c) => cfnChangeEntry(stack, c));
|
|
299
326
|
const artifact: CfnPlanArtifact = { kind: "cfn-deploy-plan", stack, changesDigest: cfnChangesDigest(stack, changeSet) };
|
|
300
|
-
const outputs = changeSet.stackCreated
|
|
327
|
+
const outputs = changeSet.stackCreated
|
|
328
|
+
? undefined
|
|
329
|
+
: await plannedStackOutputs(standing, input.template, changeSet.changes, knownParameters(parameters), importValues(input.imports ?? {}));
|
|
330
|
+
// #4466c: CloudFormation refuses to change or remove an export another stack imports; say so now, not at apply.
|
|
331
|
+
if (!changeSet.stackCreated) exportHoles.push(...(await exportsInUse(executor, stack, input, knownParameters(parameters), outputs, standing)));
|
|
301
332
|
// The real change set's shape, with the replacement CloudFormation reports,
|
|
302
333
|
// for a round that checks it against a preview (#4419).
|
|
303
334
|
const shape = cfnPlanShape(stack, changeSet, []);
|
|
@@ -443,7 +474,9 @@ export function cfnPlanShape(stack: string, changeSet: CfnChangeSet, unknown: st
|
|
|
443
474
|
* Floci accepts a change set that real CloudFormation refuses (a missing
|
|
444
475
|
* export, an id that does not exist), reports `Parameters: null`, and says
|
|
445
476
|
* `Replacement: False` for a change that replaces. So a cross-stack value
|
|
446
|
-
* goes through a parameter here, never an export
|
|
477
|
+
* goes through a parameter here, never an export: an import of an export
|
|
478
|
+
* not known yet is planned from a copy of the template in which it is a
|
|
479
|
+
* stand-in parameter (#4466b, `cfnTemplateWithStandIns`). The replacement a
|
|
447
480
|
* preview reports is whatever the backend says; the shape check at apply
|
|
448
481
|
* compares like with like.
|
|
449
482
|
*/
|
|
@@ -473,7 +506,7 @@ async function previewCfnDeploy(
|
|
|
473
506
|
// output that reads one of the unknown parameters is unknown, one that
|
|
474
507
|
// reads a known parameter or states its value is not (#4419).
|
|
475
508
|
const given = Object.fromEntries(Object.entries(parameters).filter(([k]) => !unknown.includes(k)));
|
|
476
|
-
const outputs = noop || changeSet.stackCreated ? undefined : await plannedStackOutputs(executor, stack, input.template, changeSet.changes, given);
|
|
509
|
+
const outputs = noop || changeSet.stackCreated ? undefined : await plannedStackOutputs(standingReader(executor, stack), input.template, changeSet.changes, given);
|
|
477
510
|
return { entries, shape: cfnPlanShape(stack, changeSet, unknown, input.template, refs), ...(outputs ? { outputs } : {}) };
|
|
478
511
|
} finally {
|
|
479
512
|
await cleanUpPlanChangeSet(executor, changeSet);
|
|
@@ -501,13 +534,14 @@ async function cleanUpPlanChangeSet(executor: CloudExecutor, changeSet: CfnChang
|
|
|
501
534
|
}
|
|
502
535
|
}
|
|
503
536
|
|
|
504
|
-
/** The stack
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
537
|
+
/** The stack as it stands, read once however often it is asked for, or `undefined` when it cannot be read. */
|
|
538
|
+
function standingReader(executor: CloudExecutor, stack: string): () => Promise<CfnStackStatus | undefined> {
|
|
539
|
+
let read: Promise<CfnStackStatus | undefined> | undefined;
|
|
540
|
+
return () =>
|
|
541
|
+
(read ??= executor.cloudformation.describeStack(stack).then(
|
|
542
|
+
(s) => s,
|
|
543
|
+
() => undefined,
|
|
544
|
+
));
|
|
511
545
|
}
|
|
512
546
|
|
|
513
547
|
/**
|
|
@@ -515,16 +549,17 @@ async function standingOutputs(executor: CloudExecutor, stack: string): Promise<
|
|
|
515
549
|
* say for a template with no `Outputs`, which needs no `DescribeStacks`.
|
|
516
550
|
*/
|
|
517
551
|
async function plannedStackOutputs(
|
|
518
|
-
|
|
519
|
-
stack: string,
|
|
552
|
+
standing: () => Promise<CfnStackStatus | undefined>,
|
|
520
553
|
templatePath: string,
|
|
521
554
|
changes: readonly CfnChange[],
|
|
522
555
|
parameters: Record<string, string> = {},
|
|
556
|
+
imports: Record<string, string> = {},
|
|
523
557
|
): Promise<PlannedOutputs | undefined> {
|
|
524
558
|
const template = readTemplate(templatePath) as { Outputs?: Record<string, unknown> } | undefined;
|
|
525
559
|
if (!template || typeof template !== "object") return undefined;
|
|
526
560
|
if (Object.keys(template.Outputs ?? {}).length === 0) return { known: {}, unknown: [] };
|
|
527
|
-
|
|
561
|
+
const now = await standing();
|
|
562
|
+
return cfnPlannedOutputs(template, changes, now ? { ...now.outputs } : undefined, parameters, imports);
|
|
528
563
|
}
|
|
529
564
|
|
|
530
565
|
/** The template at `path` as JSON, or `undefined` when it cannot be read or parsed here. */
|
|
@@ -601,6 +636,10 @@ function valueReads(value: unknown): { ids: Set<string>; opaque: boolean } {
|
|
|
601
636
|
* known at that value (`given`, the inputs of this plan). Both are what
|
|
602
637
|
* the stack's outputs will be, whatever they are now.
|
|
603
638
|
*
|
|
639
|
+
* An output that passes an import through (`Fn::ImportValue` of a name
|
|
640
|
+
* `imports` holds, #4466) is known at the value the exporting stack's plan
|
|
641
|
+
* gave it, which is the value core read for the import.
|
|
642
|
+
*
|
|
604
643
|
* `undefined` when there is no template or no standing stack to read from.
|
|
605
644
|
*/
|
|
606
645
|
export function cfnPlannedOutputs(
|
|
@@ -608,6 +647,7 @@ export function cfnPlannedOutputs(
|
|
|
608
647
|
changes: readonly CfnChange[],
|
|
609
648
|
standing: Record<string, string> | undefined,
|
|
610
649
|
given: Record<string, string> = {},
|
|
650
|
+
imports: Record<string, string> = {},
|
|
611
651
|
): PlannedOutputs | undefined {
|
|
612
652
|
if (!standing || template === null || typeof template !== "object") return undefined;
|
|
613
653
|
const t = template as { Outputs?: Record<string, { Value?: unknown; Condition?: unknown }>; Parameters?: Record<string, unknown> };
|
|
@@ -621,7 +661,7 @@ export function cfnPlannedOutputs(
|
|
|
621
661
|
const known: Record<string, unknown> = {};
|
|
622
662
|
const unknown: string[] = [];
|
|
623
663
|
for (const [name, output] of Object.entries(t.Outputs ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
624
|
-
const stated = statedOutput(output, given, t.Parameters);
|
|
664
|
+
const stated = statedOutput(output, given, t.Parameters, imports);
|
|
625
665
|
if (stated !== undefined) {
|
|
626
666
|
known[name] = stated;
|
|
627
667
|
continue;
|
|
@@ -650,6 +690,7 @@ function statedOutput(
|
|
|
650
690
|
output: { Value?: unknown; Condition?: unknown } | undefined,
|
|
651
691
|
parameters: Record<string, string>,
|
|
652
692
|
declared: Record<string, unknown> | undefined,
|
|
693
|
+
imports: Record<string, string> = {},
|
|
653
694
|
): string | undefined {
|
|
654
695
|
if (output?.Condition !== undefined) return undefined;
|
|
655
696
|
const value = output?.Value;
|
|
@@ -658,10 +699,114 @@ function statedOutput(
|
|
|
658
699
|
const entries = Object.entries(value as Record<string, unknown>);
|
|
659
700
|
const [key, arg] = entries[0] ?? [];
|
|
660
701
|
if (entries.length === 1 && key === "Ref" && typeof arg === "string" && declared && arg in declared && arg in parameters) return parameters[arg];
|
|
702
|
+
if (entries.length === 1 && key === "Fn::ImportValue") {
|
|
703
|
+
const name = resolveCfnName(arg, parameters);
|
|
704
|
+
if ("name" in name && name.name in imports) return imports[name.name];
|
|
705
|
+
}
|
|
661
706
|
}
|
|
662
707
|
return undefined;
|
|
663
708
|
}
|
|
664
709
|
|
|
710
|
+
// ── Fn::ImportValue between stacks (#4466) ──────────────────────────────────
|
|
711
|
+
|
|
712
|
+
/** The imports core read a value for, as strings; one still unknown is left out. */
|
|
713
|
+
function importValues(imports: Record<string, unknown>): Record<string, string> {
|
|
714
|
+
const out: Record<string, string> = {};
|
|
715
|
+
for (const [name, v] of Object.entries(imports).sort(([a], [b]) => a.localeCompare(b))) {
|
|
716
|
+
if (v === undefined || v === null) continue;
|
|
717
|
+
out[name] = typeof v === "string" ? v : JSON.stringify(v);
|
|
718
|
+
}
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** The export names the step imports from a stack in the change whose value is not known yet, sorted. */
|
|
723
|
+
function unknownImports(input: CfnDeployInput): string[] {
|
|
724
|
+
return Object.entries(input.imports ?? {})
|
|
725
|
+
.filter(([, v]) => v === undefined || v === null)
|
|
726
|
+
.map(([name]) => name)
|
|
727
|
+
.sort();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* A hole for each `Fn::ImportValue` in the template whose name the plan
|
|
732
|
+
* cannot resolve (#4466): it may name an export of a stack in this change,
|
|
733
|
+
* and then nothing orders the two, so the plan says it cannot tell.
|
|
734
|
+
*/
|
|
735
|
+
function unresolvedImportHoles(stack: string, input: CfnDeployInput, parameters: Record<string, string>): ChangeSetHole[] {
|
|
736
|
+
const template = readCfnTemplate(input.template);
|
|
737
|
+
if (!template) return [];
|
|
738
|
+
const resources = (template.Resources ?? {}) as Record<string, { Type?: unknown } | undefined>;
|
|
739
|
+
const holes: ChangeSetHole[] = [];
|
|
740
|
+
for (const i of cfnImports(template, cfnKnownValues(template, stack, parameters))) {
|
|
741
|
+
if (!("unresolved" in i)) continue;
|
|
742
|
+
const type = typeof resources[i.logicalId]?.Type === "string" ? (resources[i.logicalId]!.Type as string) : undefined;
|
|
743
|
+
holes.push({
|
|
744
|
+
address: i.logicalId,
|
|
745
|
+
...(type ? { type } : {}),
|
|
746
|
+
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`,
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
return holes;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The importer's template with each import of `names` as a stand-in
|
|
754
|
+
* parameter (#4466b), written to a scratch file the preview creates its
|
|
755
|
+
* change set from; or why there is none. `remove` deletes the file.
|
|
756
|
+
*/
|
|
757
|
+
function standInTemplate(
|
|
758
|
+
stack: string,
|
|
759
|
+
input: CfnDeployInput,
|
|
760
|
+
names: readonly string[],
|
|
761
|
+
parameters: Record<string, string>,
|
|
762
|
+
): { path: string; parameters: string[]; remove: () => void } | string {
|
|
763
|
+
const template = readCfnTemplate(input.template);
|
|
764
|
+
if (!template) return "the template could not be read as JSON to stand in for its imports";
|
|
765
|
+
const { template: copy, standIns } = cfnTemplateWithStandIns(template, names, cfnKnownValues(template, stack, parameters));
|
|
766
|
+
const params = Object.keys(standIns).sort();
|
|
767
|
+
if (params.length === 0) return `the template imports none of ${names.join(", ")} by a name the plan resolves`;
|
|
768
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-cfn-preview-"));
|
|
769
|
+
const path = join(dir, `${stack}.preview.template.json`);
|
|
770
|
+
writeFileSync(path, JSON.stringify(copy, null, 2));
|
|
771
|
+
return { path, parameters: params, remove: () => rmSync(dir, { recursive: true, force: true }) };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* `cfn-deploy`'s `declaredReads` (#4466): each `Fn::ImportValue` of a name
|
|
776
|
+
* exactly one other `cfn-deploy` step's template exports is a read of that
|
|
777
|
+
* component's output, placed under the step's `imports`. A name two steps
|
|
778
|
+
* export is left alone (CloudFormation refuses the second export), and so
|
|
779
|
+
* is a name no step in the project exports: it reads a stack outside it.
|
|
780
|
+
*/
|
|
781
|
+
export function cfnDeclaredReads(root: string, members: readonly { component: string; index: number; step: Record<string, unknown> }[]): DeclaredRead[] {
|
|
782
|
+
const seen = members.map((m) => {
|
|
783
|
+
const template = readCfnTemplate(m.step.template, root);
|
|
784
|
+
const stack = typeof m.step.stack === "string" ? m.step.stack : m.component;
|
|
785
|
+
const inputs = (m.step.inputs ?? {}) as Record<string, unknown>;
|
|
786
|
+
const known = cfnKnownValues(template, stack, typeof m.step.imageRef === "string" ? { ...inputs, ImageRef: m.step.imageRef } : inputs);
|
|
787
|
+
return { member: m, template, known };
|
|
788
|
+
});
|
|
789
|
+
const exporters = new Map<string, Array<{ component: string; output: string }>>();
|
|
790
|
+
for (const { member, template, known } of seen) {
|
|
791
|
+
for (const e of cfnExports(template, known)) {
|
|
792
|
+
if (!("name" in e)) continue;
|
|
793
|
+
(exporters.get(e.name) ?? exporters.set(e.name, []).get(e.name)!).push({ component: member.component, output: e.output });
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const out: DeclaredRead[] = [];
|
|
797
|
+
for (const { member, template, known } of seen) {
|
|
798
|
+
const done = new Set<string>();
|
|
799
|
+
for (const i of cfnImports(template, known)) {
|
|
800
|
+
if (!("name" in i) || done.has(i.name)) continue;
|
|
801
|
+
done.add(i.name);
|
|
802
|
+
const owners = (exporters.get(i.name) ?? []).filter((o) => o.component !== member.component);
|
|
803
|
+
if (owners.length !== 1 || (exporters.get(i.name) ?? []).length !== 1) continue;
|
|
804
|
+
out.push({ reader: member.component, index: member.index, upstream: owners[0]!.component, output: owners[0]!.output, at: cfnImportPointer(i.name) });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return out;
|
|
808
|
+
}
|
|
809
|
+
|
|
665
810
|
/** Whether a CloudFormation error says the stack does not exist. */
|
|
666
811
|
const STACK_MISSING = /does not exist/i;
|
|
667
812
|
|
|
@@ -694,6 +839,14 @@ export interface CfnDeployInput {
|
|
|
694
839
|
onReplace?: CfnReplacePolicy;
|
|
695
840
|
/** Stage DynamoDB GSI changes (add-then-backfill-then-remove) instead of an in-place replace. Default: false. */
|
|
696
841
|
stageGsi?: boolean;
|
|
842
|
+
/**
|
|
843
|
+
* The exports the template imports (`Fn::ImportValue`) from another
|
|
844
|
+
* `cfn-deploy` step of the project, by export name (#4466). Core fills it
|
|
845
|
+
* from `declaredReads` with a `stackOutput()` of the exporting component's
|
|
846
|
+
* output, so a value is `undefined` while that stack's plan cannot say it.
|
|
847
|
+
* Never passed to CloudFormation: the template imports the export itself.
|
|
848
|
+
*/
|
|
849
|
+
imports?: Record<string, string | undefined>;
|
|
697
850
|
}
|
|
698
851
|
|
|
699
852
|
export interface CfnDeployOutput {
|
|
@@ -705,6 +858,103 @@ export interface CfnDeployOutput {
|
|
|
705
858
|
snapshotId?: string;
|
|
706
859
|
}
|
|
707
860
|
|
|
861
|
+
/** One export a plan changes or removes while other stacks import it (#4466c). */
|
|
862
|
+
export interface CfnExportInUse {
|
|
863
|
+
exportName: string;
|
|
864
|
+
/** What the plan does to it, as the refusal says it: "removes", or "changes ... from A to B". */
|
|
865
|
+
change: string;
|
|
866
|
+
/** The stacks that import it now, sorted. */
|
|
867
|
+
importers: string[];
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Thrown at plan time when a change set would change or remove an export
|
|
872
|
+
* that another stack still imports (#4466c). CloudFormation refuses that
|
|
873
|
+
* update at apply; the plan refuses it first, naming both stacks.
|
|
874
|
+
*/
|
|
875
|
+
export class CfnExportInUseError extends Error {
|
|
876
|
+
constructor(
|
|
877
|
+
public readonly stack: string,
|
|
878
|
+
public readonly uses: CfnExportInUse[],
|
|
879
|
+
) {
|
|
880
|
+
const importers = [...new Set(uses.flatMap((u) => u.importers))].sort();
|
|
881
|
+
super(
|
|
882
|
+
`cfn-deploy "${stack}": refusing the plan: ` +
|
|
883
|
+
uses
|
|
884
|
+
.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"}`)
|
|
885
|
+
.join("; ") +
|
|
886
|
+
`. CloudFormation refuses to change or remove an export another stack imports. Change ${importers.join(", ")} to stop importing it first, then change ${stack}.`,
|
|
887
|
+
);
|
|
888
|
+
this.name = "CfnExportInUseError";
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The exports `stack` has now that this plan changes or removes while
|
|
894
|
+
* another stack imports them (#4466c). An export the new template no longer
|
|
895
|
+
* declares is removed; one whose planned value differs from the value it
|
|
896
|
+
* has is changed: both throw {@link CfnExportInUseError}. One the plan
|
|
897
|
+
* cannot say about (its output's value is known only after apply, or an
|
|
898
|
+
* export name in the new template does not resolve) is a hole naming the
|
|
899
|
+
* importers. Asks `ListImports` only for exports at stake, and nothing at
|
|
900
|
+
* all when the executor cannot list imports or the stack exports nothing.
|
|
901
|
+
*/
|
|
902
|
+
async function exportsInUse(
|
|
903
|
+
executor: CloudExecutor,
|
|
904
|
+
stack: string,
|
|
905
|
+
input: CfnDeployInput,
|
|
906
|
+
parameters: Record<string, string>,
|
|
907
|
+
planned: PlannedOutputs | undefined,
|
|
908
|
+
standing: () => Promise<CfnStackStatus | undefined>,
|
|
909
|
+
): Promise<ChangeSetHole[]> {
|
|
910
|
+
const listImports = executor.cloudformation.listImports?.bind(executor.cloudformation);
|
|
911
|
+
if (!listImports) return [];
|
|
912
|
+
const was = (await standing())?.exports;
|
|
913
|
+
if (!was || Object.keys(was).length === 0) return [];
|
|
914
|
+
const template = readCfnTemplate(input.template);
|
|
915
|
+
if (!template) return [];
|
|
916
|
+
const declared = cfnExports(template, cfnKnownValues(template, stack, parameters));
|
|
917
|
+
const now = new Map(declared.filter((e): e is { output: string; name: string } => "name" in e).map((e) => [e.name, e.output]));
|
|
918
|
+
const unresolvedNow = declared.some((e) => "unresolved" in e);
|
|
919
|
+
const refused: CfnExportInUse[] = [];
|
|
920
|
+
const holes: ChangeSetHole[] = [];
|
|
921
|
+
for (const [exportName, { output, value }] of Object.entries(was).sort(([a], [b]) => a.localeCompare(b))) {
|
|
922
|
+
const at = now.get(exportName);
|
|
923
|
+
let change: string | undefined;
|
|
924
|
+
let maybe: string | undefined;
|
|
925
|
+
if (at === undefined) {
|
|
926
|
+
if (unresolvedNow) maybe = `may remove export ${exportName}: an export name the template declares does not resolve at plan`;
|
|
927
|
+
else change = "removes {export}";
|
|
928
|
+
} else {
|
|
929
|
+
const next = planned?.known[at];
|
|
930
|
+
if (next === undefined) maybe = `may change export ${exportName}: the value of output ${at} is known only after apply`;
|
|
931
|
+
else if ((typeof next === "string" ? next : JSON.stringify(next)) !== value) change = `changes {export} from ${value} to ${typeof next === "string" ? next : JSON.stringify(next)}`;
|
|
932
|
+
}
|
|
933
|
+
if (change === undefined && maybe === undefined) continue;
|
|
934
|
+
let importers: string[];
|
|
935
|
+
try {
|
|
936
|
+
importers = (await listImports(exportName)).filter((s) => s !== stack);
|
|
937
|
+
} catch (err) {
|
|
938
|
+
// A backend without ListImports (an emulator, say) cannot say who imports it; the plan says so instead of failing.
|
|
939
|
+
holes.push({
|
|
940
|
+
address: `Outputs.${at ?? output}`,
|
|
941
|
+
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)}`,
|
|
942
|
+
});
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (importers.length === 0) continue;
|
|
946
|
+
if (change !== undefined) refused.push({ exportName, change, importers });
|
|
947
|
+
else {
|
|
948
|
+
holes.push({
|
|
949
|
+
address: `Outputs.${at ?? output}`,
|
|
950
|
+
reason: `the plan ${maybe}, and ${importers.join(", ")} still ${importers.length === 1 ? "imports" : "import"} it; CloudFormation refuses the apply if it changes`,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (refused.length > 0) throw new CfnExportInUseError(stack, refused);
|
|
955
|
+
return holes;
|
|
956
|
+
}
|
|
957
|
+
|
|
708
958
|
/**
|
|
709
959
|
* Thrown when a changeset proposes a resource replacement and `onReplace` is
|
|
710
960
|
* `"block"` (the default) — refusing to apply rather than silently destroying
|
|
@@ -758,6 +1008,9 @@ export function createCfnDeployCapability(
|
|
|
758
1008
|
plan(ctx, input) {
|
|
759
1009
|
return planCfnDeploy(executor, ctx, input);
|
|
760
1010
|
},
|
|
1011
|
+
declaredReads(ctx, members) {
|
|
1012
|
+
return cfnDeclaredReads(ctx.root, members);
|
|
1013
|
+
},
|
|
761
1014
|
async outputs(ctx, input) {
|
|
762
1015
|
// The outputs the stack has now, for a dependent planned before it
|
|
763
1016
|
// applies (#3183). A stack that does not exist yet has none.
|