@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.
- 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 +76 -3
- package/dist/components/apply.d.ts.map +1 -1
- package/dist/components/apply.js +488 -32
- 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 +44 -2
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/components/cloud-executor.js +73 -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 +546 -33
- 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-floci.e2e.test.ts +53 -0
- package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
- package/src/components/cfn-plan.test.ts +328 -9
- package/src/components/cloud-executor.ts +119 -6
- 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,11 +20,24 @@
|
|
|
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
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
normalizePlanShape,
|
|
31
|
+
PlanShapeMovedError,
|
|
32
|
+
planShapeDifferences,
|
|
33
|
+
planShapeDigest,
|
|
34
|
+
type PlanShape,
|
|
35
|
+
type PlanShapeArtifact,
|
|
36
|
+
type PlanShapeEntry,
|
|
37
|
+
type ShapeReplacement,
|
|
38
|
+
} from "@intentius/chant/components/covered-round";
|
|
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";
|
|
28
41
|
|
|
29
42
|
/** CloudFormation's wording when a change set has nothing to apply — the stack
|
|
30
43
|
* is already current. AWS phrases it "The submitted information didn't contain
|
|
@@ -65,8 +78,11 @@ export function cfnChangeReplaces(change: CfnChange): boolean {
|
|
|
65
78
|
* replace, `Remove` a delete, `Import` an import that changes nothing else,
|
|
66
79
|
* and `Dynamic` an update CloudFormation could not describe further.
|
|
67
80
|
*/
|
|
68
|
-
export function cfnChangeEntry(member: string, change: CfnChange): ChangeSetEntry {
|
|
69
|
-
|
|
81
|
+
export function cfnChangeEntry(member: string, change: CfnChange, unknown: readonly string[] = []): ChangeSetEntry {
|
|
82
|
+
// A property an unknown input drives is known after apply (#4419); one the
|
|
83
|
+
// change set did not list (a create names no properties) is added for it.
|
|
84
|
+
const paths = [...new Set([...(change.details ?? []), ...unknown])].sort();
|
|
85
|
+
const attributes = paths.map((path) => (unknown.includes(path) ? { path, unknown: true as const } : { path }));
|
|
70
86
|
// A change inside a nested stack (#3997) sits under the nested stacks'
|
|
71
87
|
// logical ids, the way a terraform address sits under its module.
|
|
72
88
|
const module = change.nestedStack?.length ? change.nestedStack.join("/") : undefined;
|
|
@@ -177,6 +193,8 @@ function cfnPlanDigest(
|
|
|
177
193
|
input: CfnDeployInput,
|
|
178
194
|
parameters: Record<string, string | null>,
|
|
179
195
|
unresolved: string[],
|
|
196
|
+
/** 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. */
|
|
197
|
+
preview?: string,
|
|
180
198
|
): string {
|
|
181
199
|
return computePlanDigest("cfn-deploy-plan", {
|
|
182
200
|
env: ctx.env,
|
|
@@ -185,6 +203,9 @@ function cfnPlanDigest(
|
|
|
185
203
|
templateDigest: templateDigest(input.template),
|
|
186
204
|
parameters,
|
|
187
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) } : {}),
|
|
208
|
+
...(preview ? { preview } : {}),
|
|
188
209
|
});
|
|
189
210
|
}
|
|
190
211
|
|
|
@@ -195,10 +216,14 @@ function cfnPlanDigest(
|
|
|
195
216
|
* exist yet gets a CREATE change set, so every resource reads as a create,
|
|
196
217
|
* and the empty `REVIEW_IN_PROGRESS` stack CloudFormation made to hold it is
|
|
197
218
|
* deleted too. A change set with nothing to apply (`isNoopChangeSet`) gives
|
|
198
|
-
* a member with no entries. A step whose parameters read outputs
|
|
199
|
-
* not known yet, from a stack the same run applies first,
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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.
|
|
202
227
|
*
|
|
203
228
|
* With `onReplace: "block"` (the default), a change set that replaces a
|
|
204
229
|
* resource fails the plan with {@link CfnReplacementBlockedError}, so the
|
|
@@ -227,26 +252,54 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
227
252
|
} else parameters[key] = typeof value === "string" ? value : JSON.stringify(value);
|
|
228
253
|
}
|
|
229
254
|
const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
|
|
230
|
-
|
|
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[] = [];
|
|
231
260
|
const member = (holes: ChangeSetHole[]): ChangeSetPart["member"] => ({
|
|
232
261
|
member: stack,
|
|
233
262
|
lexicon: "aws",
|
|
234
263
|
planner: "chant",
|
|
235
264
|
status: "planned",
|
|
236
265
|
planDigest,
|
|
237
|
-
holes,
|
|
266
|
+
holes: [...holes, ...importHoles, ...exportHoles],
|
|
238
267
|
});
|
|
268
|
+
const standing = standingReader(executor, stack);
|
|
239
269
|
|
|
240
270
|
if (unresolved.length > 0 || missing.length > 0) {
|
|
241
|
-
const
|
|
242
|
-
unresolved.length > 0
|
|
243
|
-
? `inputs from ${unresolved.join(", ")}; re-checked on apply`
|
|
244
|
-
: `inputs ${missing.sort().join(", ")} are known only at apply; re-checked on apply`;
|
|
245
|
-
return {
|
|
271
|
+
const hole = (why: string): CapabilityPlan => ({
|
|
246
272
|
member: stack,
|
|
247
273
|
planDigest,
|
|
248
|
-
changeSet: { member: member([{ address: stack, type: "AWS::CloudFormation::Stack", reason }]), entries: [] },
|
|
249
|
-
};
|
|
274
|
+
changeSet: { member: member([{ address: stack, type: "AWS::CloudFormation::Stack", reason: why }]), entries: [] },
|
|
275
|
+
});
|
|
276
|
+
const waiting = unknownImports(input);
|
|
277
|
+
const fallback =
|
|
278
|
+
unresolved.length > 0
|
|
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`
|
|
282
|
+
: `inputs ${[...missing].sort().join(", ")} are known only at apply; re-checked on apply`;
|
|
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
|
+
}
|
|
296
|
+
if (typeof preview === "string") return hole(`${fallback}; no preview: ${preview}`);
|
|
297
|
+
const { entries, shape, outputs } = preview;
|
|
298
|
+
exportHoles.push(...(await exportsInUse(executor, stack, input, knownParameters(parameters), outputs, standing)));
|
|
299
|
+
const artifact: CfnShapeArtifact = { kind: "cfn-deploy-shape", member: stack, shape, digest: planShapeDigest(shape) };
|
|
300
|
+
// The preview's shape enters the digest (#4423); `member` reads it when called below.
|
|
301
|
+
planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved, artifact.digest);
|
|
302
|
+
return { member: stack, planDigest, artifact, shape, changeSet: { member: member([]), entries }, ...(outputs ? { outputs } : {}) };
|
|
250
303
|
}
|
|
251
304
|
|
|
252
305
|
const changeSet = await executor.cloudformation.createChangeSet({
|
|
@@ -258,8 +311,8 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
258
311
|
if (isNoopChangeSet(changeSet)) {
|
|
259
312
|
const artifact: CfnPlanArtifact = { kind: "cfn-deploy-plan", stack, changesDigest: cfnChangesDigest(stack, changeSet) };
|
|
260
313
|
// Nothing to apply, so the outputs the stack has are the ones it will have (#3999).
|
|
261
|
-
const
|
|
262
|
-
const outputs =
|
|
314
|
+
const now = await standing();
|
|
315
|
+
const outputs = now ? { known: { ...now.outputs }, unknown: [] } : undefined;
|
|
263
316
|
return { member: stack, planDigest, artifact, changeSet: { member: member([]), entries: [] }, ...(outputs ? { outputs } : {}) };
|
|
264
317
|
}
|
|
265
318
|
if (changeSet.status === "FAILED") {
|
|
@@ -271,8 +324,190 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
271
324
|
if (blocked.length > 0 && (input.onReplace ?? "block") === "block") throw new CfnReplacementBlockedError(stack, blocked);
|
|
272
325
|
const entries = changeSet.changes.map((c) => cfnChangeEntry(stack, c));
|
|
273
326
|
const artifact: CfnPlanArtifact = { kind: "cfn-deploy-plan", stack, changesDigest: cfnChangesDigest(stack, changeSet) };
|
|
274
|
-
const outputs = changeSet.stackCreated
|
|
275
|
-
|
|
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)));
|
|
332
|
+
// The real change set's shape, with the replacement CloudFormation reports,
|
|
333
|
+
// for a round that checks it against a preview (#4419).
|
|
334
|
+
const shape = cfnPlanShape(stack, changeSet, []);
|
|
335
|
+
return { member: stack, planDigest, artifact, shape, changeSet: { member: member([]), entries }, ...(outputs ? { outputs } : {}) };
|
|
336
|
+
} finally {
|
|
337
|
+
await cleanUpPlanChangeSet(executor, changeSet);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── preview of a member whose inputs are unknown (#4419) ────────────────────
|
|
342
|
+
|
|
343
|
+
/** What a previewed member hands `run`: the shape its change set had against stand-in values. */
|
|
344
|
+
export interface CfnShapeArtifact extends PlanShapeArtifact {
|
|
345
|
+
kind: "cfn-deploy-shape";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function isCfnShapeArtifact(value: unknown): value is CfnShapeArtifact {
|
|
349
|
+
const v = value as Partial<CfnShapeArtifact> | undefined;
|
|
350
|
+
return v?.kind === "cfn-deploy-shape" && typeof v.digest === "string" && typeof v.shape === "object" && v.shape !== null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The marker every stand-in carries, so a reader of a change set can tell it from a real value. */
|
|
354
|
+
export const CFN_UNKNOWN_MARK = "known-after-apply";
|
|
355
|
+
|
|
356
|
+
const ID_PREFIX: Record<string, string> = {
|
|
357
|
+
"AWS::EC2::Subnet::Id": "subnet",
|
|
358
|
+
"AWS::EC2::VPC::Id": "vpc",
|
|
359
|
+
"AWS::EC2::SecurityGroup::Id": "sg",
|
|
360
|
+
"AWS::EC2::Instance::Id": "i",
|
|
361
|
+
"AWS::EC2::Image::Id": "ami",
|
|
362
|
+
"AWS::EC2::Volume::Id": "vol",
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* A stand-in for a parameter whose value is not known yet: valid for the
|
|
367
|
+
* parameter's type and recognisable. An EC2 id type gets the id's prefix
|
|
368
|
+
* (`subnet-00000000known-after-apply`), a number gets 0, anything else gets
|
|
369
|
+
* `known-after-apply`. Real CloudFormation checks the AWS-specific types
|
|
370
|
+
* against the account and refuses an id that does not exist; the preview
|
|
371
|
+
* then falls back to a hole. Floci does not check.
|
|
372
|
+
*/
|
|
373
|
+
export function cfnPlaceholder(type: string | undefined): string {
|
|
374
|
+
if (type && ID_PREFIX[type]) return `${ID_PREFIX[type]}-00000000${CFN_UNKNOWN_MARK}`;
|
|
375
|
+
if (type === "Number") return "0";
|
|
376
|
+
if (type?.startsWith("AWS::SSM::Parameter::Value<")) return `/${CFN_UNKNOWN_MARK}`;
|
|
377
|
+
return CFN_UNKNOWN_MARK;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface TemplateFacts {
|
|
381
|
+
parameterTypes: Record<string, string>;
|
|
382
|
+
/** Per logical id, the top-level properties whose value reads one of the keys. */
|
|
383
|
+
driven: Map<string, Set<string>>;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function readsParameter(value: unknown, key: string): boolean {
|
|
387
|
+
if (typeof value === "string") return false;
|
|
388
|
+
if (Array.isArray(value)) return value.some((v) => readsParameter(v, key));
|
|
389
|
+
if (value && typeof value === "object") {
|
|
390
|
+
const obj = value as Record<string, unknown>;
|
|
391
|
+
if (obj.Ref === key) return true;
|
|
392
|
+
const sub = obj["Fn::Sub"];
|
|
393
|
+
const text = typeof sub === "string" ? sub : Array.isArray(sub) && typeof sub[0] === "string" ? sub[0] : undefined;
|
|
394
|
+
if (text !== undefined && (text.includes("${" + key + "}") || text.includes("${" + key + "."))) return true;
|
|
395
|
+
return Object.values(obj).some((v) => readsParameter(v, key));
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The parameter types and the properties that read `keys`, from the template. A template that cannot be read gives no facts. */
|
|
401
|
+
function templateFacts(path: string, keys: string[]): TemplateFacts {
|
|
402
|
+
const facts: TemplateFacts = { parameterTypes: {}, driven: new Map() };
|
|
403
|
+
try {
|
|
404
|
+
const t = JSON.parse(readFileSync(path, "utf8")) as {
|
|
405
|
+
Parameters?: Record<string, { Type?: string }>;
|
|
406
|
+
Resources?: Record<string, { Properties?: Record<string, unknown> }>;
|
|
407
|
+
};
|
|
408
|
+
for (const k of keys) if (t.Parameters?.[k]?.Type) facts.parameterTypes[k] = t.Parameters[k].Type!;
|
|
409
|
+
for (const [id, r] of Object.entries(t.Resources ?? {})) {
|
|
410
|
+
for (const [prop, value] of Object.entries(r.Properties ?? {})) {
|
|
411
|
+
if (keys.some((k) => readsParameter(value, k))) (facts.driven.get(id) ?? facts.driven.set(id, new Set()).get(id)!).add(prop);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
/* unreadable or not JSON: the change set reports why, and no property is attributed */
|
|
416
|
+
}
|
|
417
|
+
return facts;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The properties of one change that the unknown parameters drive. */
|
|
421
|
+
function drivenProperties(change: CfnChange, keys: string[], facts: TemplateFacts): string[] {
|
|
422
|
+
const out = new Set<string>();
|
|
423
|
+
for (const d of change.drivers ?? []) {
|
|
424
|
+
if (d.changeSource === "ParameterReference" && d.causingEntity && keys.some((k) => d.causingEntity === k || d.causingEntity!.startsWith(`${k}.`))) {
|
|
425
|
+
out.add(d.property);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const scanned = facts.driven.get(change.logicalResourceId);
|
|
429
|
+
if (scanned && change.action !== "Remove") {
|
|
430
|
+
const listed = change.details ?? [];
|
|
431
|
+
for (const p of scanned) if (change.action === "Add" || listed.length === 0 || listed.includes(p)) out.add(p);
|
|
432
|
+
}
|
|
433
|
+
return [...out].sort();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function shapeReplacement(change: CfnChange): ShapeReplacement {
|
|
437
|
+
if (change.replacementMode === "True" || change.replacement) return "true";
|
|
438
|
+
return change.replacementMode === "Conditional" ? "conditional" : "false";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* A change set's shape: each change's resource, action, replacement and
|
|
443
|
+
* properties, with the ones the `unknown` parameters drive marked. `unknown`
|
|
444
|
+
* is empty for the change set made against real values. `refs` are the
|
|
445
|
+
* inputs the shape names as unknown (`<component>.<output>`), the parameters
|
|
446
|
+
* themselves when nothing says better.
|
|
447
|
+
*/
|
|
448
|
+
export function cfnPlanShape(stack: string, changeSet: CfnChangeSet, unknown: string[], templatePath?: string, refs: string[] = unknown): PlanShape {
|
|
449
|
+
const facts = unknown.length > 0 && templatePath ? templateFacts(templatePath, unknown) : { parameterTypes: {}, driven: new Map() };
|
|
450
|
+
const entries: PlanShapeEntry[] = isNoopChangeSet(changeSet)
|
|
451
|
+
? []
|
|
452
|
+
: changeSet.changes.map((c) => {
|
|
453
|
+
const e = cfnChangeEntry(stack, c);
|
|
454
|
+
return {
|
|
455
|
+
logicalId: e.address,
|
|
456
|
+
action: e.action,
|
|
457
|
+
replacement: shapeReplacement(c),
|
|
458
|
+
type: c.resourceType,
|
|
459
|
+
properties: [...new Set(c.details ?? [])],
|
|
460
|
+
unknown: drivenProperties(c, unknown, facts),
|
|
461
|
+
};
|
|
462
|
+
});
|
|
463
|
+
return normalizePlanShape({ member: stack, unknownInputs: refs, entries });
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Preview a stack whose parameters `unknown` are not known yet: create its
|
|
468
|
+
* change set with a stand-in for each, read the changes into entries that
|
|
469
|
+
* mark the properties those parameters drive as known after apply, take the
|
|
470
|
+
* shape, and delete the change set. Returns why instead when CloudFormation
|
|
471
|
+
* would not make the change set (real CloudFormation refuses an id that does
|
|
472
|
+
* not exist), so the caller plans a hole.
|
|
473
|
+
*
|
|
474
|
+
* Floci accepts a change set that real CloudFormation refuses (a missing
|
|
475
|
+
* export, an id that does not exist), reports `Parameters: null`, and says
|
|
476
|
+
* `Replacement: False` for a change that replaces. So a cross-stack value
|
|
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
|
|
480
|
+
* preview reports is whatever the backend says; the shape check at apply
|
|
481
|
+
* compares like with like.
|
|
482
|
+
*/
|
|
483
|
+
async function previewCfnDeploy(
|
|
484
|
+
executor: CloudExecutor,
|
|
485
|
+
stack: string,
|
|
486
|
+
input: CfnDeployInput,
|
|
487
|
+
unknown: string[],
|
|
488
|
+
refs: string[] = unknown,
|
|
489
|
+
): Promise<{ entries: ChangeSetEntry[]; shape: PlanShape; outputs?: PlannedOutputs } | string> {
|
|
490
|
+
const facts = templateFacts(input.template, unknown);
|
|
491
|
+
const parameters: Record<string, string> = { ...(flattenCfnInputs(input) ?? {}) };
|
|
492
|
+
for (const k of unknown) parameters[k] = cfnPlaceholder(facts.parameterTypes[k]);
|
|
493
|
+
let changeSet: CfnChangeSet;
|
|
494
|
+
try {
|
|
495
|
+
changeSet = await executor.cloudformation.createChangeSet({ stackName: stack, templatePath: input.template, parameters });
|
|
496
|
+
} catch (err) {
|
|
497
|
+
return `CloudFormation refused the change set with stand-in values: ${err instanceof Error ? err.message : String(err)}`;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
if (changeSet.status === "FAILED" && !isNoopChangeSet(changeSet)) {
|
|
501
|
+
return `CloudFormation could not create the change set with stand-in values${changeSet.statusReason ? `: ${changeSet.statusReason}` : ""}`;
|
|
502
|
+
}
|
|
503
|
+
const noop = isNoopChangeSet(changeSet);
|
|
504
|
+
const entries = noop ? [] : changeSet.changes.map((c) => cfnChangeEntry(stack, c, drivenProperties(c, unknown, facts)));
|
|
505
|
+
// What the stack's outputs will be, for the members that read them: an
|
|
506
|
+
// output that reads one of the unknown parameters is unknown, one that
|
|
507
|
+
// reads a known parameter or states its value is not (#4419).
|
|
508
|
+
const given = Object.fromEntries(Object.entries(parameters).filter(([k]) => !unknown.includes(k)));
|
|
509
|
+
const outputs = noop || changeSet.stackCreated ? undefined : await plannedStackOutputs(standingReader(executor, stack), input.template, changeSet.changes, given);
|
|
510
|
+
return { entries, shape: cfnPlanShape(stack, changeSet, unknown, input.template, refs), ...(outputs ? { outputs } : {}) };
|
|
276
511
|
} finally {
|
|
277
512
|
await cleanUpPlanChangeSet(executor, changeSet);
|
|
278
513
|
}
|
|
@@ -299,24 +534,32 @@ async function cleanUpPlanChangeSet(executor: CloudExecutor, changeSet: CfnChang
|
|
|
299
534
|
}
|
|
300
535
|
}
|
|
301
536
|
|
|
302
|
-
/** The stack
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
+
));
|
|
309
545
|
}
|
|
310
546
|
|
|
311
547
|
/**
|
|
312
548
|
* What the plan says of the stack's outputs once `changes` apply: none to
|
|
313
549
|
* say for a template with no `Outputs`, which needs no `DescribeStacks`.
|
|
314
550
|
*/
|
|
315
|
-
async function plannedStackOutputs(
|
|
551
|
+
async function plannedStackOutputs(
|
|
552
|
+
standing: () => Promise<CfnStackStatus | undefined>,
|
|
553
|
+
templatePath: string,
|
|
554
|
+
changes: readonly CfnChange[],
|
|
555
|
+
parameters: Record<string, string> = {},
|
|
556
|
+
imports: Record<string, string> = {},
|
|
557
|
+
): Promise<PlannedOutputs | undefined> {
|
|
316
558
|
const template = readTemplate(templatePath) as { Outputs?: Record<string, unknown> } | undefined;
|
|
317
559
|
if (!template || typeof template !== "object") return undefined;
|
|
318
560
|
if (Object.keys(template.Outputs ?? {}).length === 0) return { known: {}, unknown: [] };
|
|
319
|
-
|
|
561
|
+
const now = await standing();
|
|
562
|
+
return cfnPlannedOutputs(template, changes, now ? { ...now.outputs } : undefined, parameters, imports);
|
|
320
563
|
}
|
|
321
564
|
|
|
322
565
|
/** The template at `path` as JSON, or `undefined` when it cannot be read or parsed here. */
|
|
@@ -386,9 +629,26 @@ function valueReads(value: unknown): { ids: Set<string>; opaque: boolean } {
|
|
|
386
629
|
* `Dynamic`, no replacement, no change in a nested stack under it). One
|
|
387
630
|
* that reads a parameter, an import or a condition, one the stack does not
|
|
388
631
|
* have yet, and every output of a template this cannot read, is unknown.
|
|
632
|
+
*
|
|
633
|
+
* Two kinds of output need no standing value. An output whose `Value` is a
|
|
634
|
+
* plain string is known at that string, changed or new: the template says it.
|
|
635
|
+
* An output that is a `Ref` to a parameter the plan was given a value for is
|
|
636
|
+
* known at that value (`given`, the inputs of this plan). Both are what
|
|
637
|
+
* the stack's outputs will be, whatever they are now.
|
|
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
|
+
*
|
|
389
643
|
* `undefined` when there is no template or no standing stack to read from.
|
|
390
644
|
*/
|
|
391
|
-
export function cfnPlannedOutputs(
|
|
645
|
+
export function cfnPlannedOutputs(
|
|
646
|
+
template: unknown,
|
|
647
|
+
changes: readonly CfnChange[],
|
|
648
|
+
standing: Record<string, string> | undefined,
|
|
649
|
+
given: Record<string, string> = {},
|
|
650
|
+
imports: Record<string, string> = {},
|
|
651
|
+
): PlannedOutputs | undefined {
|
|
392
652
|
if (!standing || template === null || typeof template !== "object") return undefined;
|
|
393
653
|
const t = template as { Outputs?: Record<string, { Value?: unknown; Condition?: unknown }>; Parameters?: Record<string, unknown> };
|
|
394
654
|
const parameters = new Set(Object.keys(t.Parameters ?? {}));
|
|
@@ -401,6 +661,11 @@ export function cfnPlannedOutputs(template: unknown, changes: readonly CfnChange
|
|
|
401
661
|
const known: Record<string, unknown> = {};
|
|
402
662
|
const unknown: string[] = [];
|
|
403
663
|
for (const [name, output] of Object.entries(t.Outputs ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
664
|
+
const stated = statedOutput(output, given, t.Parameters, imports);
|
|
665
|
+
if (stated !== undefined) {
|
|
666
|
+
known[name] = stated;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
404
669
|
const { ids, opaque } = valueReads(output?.Value);
|
|
405
670
|
const stays =
|
|
406
671
|
!opaque && output?.Condition === undefined && name in standing &&
|
|
@@ -411,6 +676,137 @@ export function cfnPlannedOutputs(template: unknown, changes: readonly CfnChange
|
|
|
411
676
|
return { known, unknown };
|
|
412
677
|
}
|
|
413
678
|
|
|
679
|
+
/** The inputs a plan was given a value for: every parameter that is not null. */
|
|
680
|
+
function knownParameters(parameters: Record<string, string | null>): Record<string, string> {
|
|
681
|
+
return Object.fromEntries(Object.entries(parameters).filter((e): e is [string, string] => e[1] !== null));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* What an output's `Value` says outright: a plain string, or a `Ref` to a
|
|
686
|
+
* parameter this plan has a value for. `undefined` for anything that reads a
|
|
687
|
+
* resource, an import or a condition, which the standing outputs speak for.
|
|
688
|
+
*/
|
|
689
|
+
function statedOutput(
|
|
690
|
+
output: { Value?: unknown; Condition?: unknown } | undefined,
|
|
691
|
+
parameters: Record<string, string>,
|
|
692
|
+
declared: Record<string, unknown> | undefined,
|
|
693
|
+
imports: Record<string, string> = {},
|
|
694
|
+
): string | undefined {
|
|
695
|
+
if (output?.Condition !== undefined) return undefined;
|
|
696
|
+
const value = output?.Value;
|
|
697
|
+
if (typeof value === "string") return value;
|
|
698
|
+
if (value !== null && typeof value === "object") {
|
|
699
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
700
|
+
const [key, arg] = entries[0] ?? [];
|
|
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
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
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
|
+
|
|
414
810
|
/** Whether a CloudFormation error says the stack does not exist. */
|
|
415
811
|
const STACK_MISSING = /does not exist/i;
|
|
416
812
|
|
|
@@ -443,6 +839,14 @@ export interface CfnDeployInput {
|
|
|
443
839
|
onReplace?: CfnReplacePolicy;
|
|
444
840
|
/** Stage DynamoDB GSI changes (add-then-backfill-then-remove) instead of an in-place replace. Default: false. */
|
|
445
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>;
|
|
446
850
|
}
|
|
447
851
|
|
|
448
852
|
export interface CfnDeployOutput {
|
|
@@ -454,6 +858,103 @@ export interface CfnDeployOutput {
|
|
|
454
858
|
snapshotId?: string;
|
|
455
859
|
}
|
|
456
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
|
+
|
|
457
958
|
/**
|
|
458
959
|
* Thrown when a changeset proposes a resource replacement and `onReplace` is
|
|
459
960
|
* `"block"` (the default) — refusing to apply rather than silently destroying
|
|
@@ -507,6 +1008,9 @@ export function createCfnDeployCapability(
|
|
|
507
1008
|
plan(ctx, input) {
|
|
508
1009
|
return planCfnDeploy(executor, ctx, input);
|
|
509
1010
|
},
|
|
1011
|
+
declaredReads(ctx, members) {
|
|
1012
|
+
return cfnDeclaredReads(ctx.root, members);
|
|
1013
|
+
},
|
|
510
1014
|
async outputs(ctx, input) {
|
|
511
1015
|
// The outputs the stack has now, for a dependent planned before it
|
|
512
1016
|
// applies (#3183). A stack that does not exist yet has none.
|
|
@@ -530,7 +1034,16 @@ export function createCfnDeployCapability(
|
|
|
530
1034
|
// set that proposes the same changes. Anything else means the stack,
|
|
531
1035
|
// the template or the parameters moved since the plan.
|
|
532
1036
|
const planned = ctx.plans?.[stack];
|
|
533
|
-
if (
|
|
1037
|
+
if (isCfnShapeArtifact(planned)) {
|
|
1038
|
+
// A member planned before its inputs were known (#4419): the change
|
|
1039
|
+
// set made now is against the real values. It must have the shape the
|
|
1040
|
+
// preview had, differing only in what the unknown inputs drive.
|
|
1041
|
+
const differences = planShapeDifferences(planned.shape, cfnPlanShape(stack, changeSet, []));
|
|
1042
|
+
if (differences.length > 0) {
|
|
1043
|
+
await cleanUpPlanChangeSet(executor, changeSet);
|
|
1044
|
+
throw new PlanShapeMovedError(stack, differences);
|
|
1045
|
+
}
|
|
1046
|
+
} else if (isCfnPlanArtifact(planned)) {
|
|
534
1047
|
const now = cfnChangesDigest(stack, changeSet);
|
|
535
1048
|
if (now !== planned.changesDigest) {
|
|
536
1049
|
await cleanUpPlanChangeSet(executor, changeSet);
|