@intentius/chant-lexicon-aws 0.124.51 → 0.124.52
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/components/apply.d.ts +33 -2
- package/dist/components/apply.d.ts.map +1 -1
- package/dist/components/apply.js +241 -16
- package/dist/components/cloud-executor.d.ts +13 -0
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/components/cloud-executor.js +9 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/apply.ts +276 -16
- package/src/components/cfn-plan-floci.e2e.test.ts +53 -0
- package/src/components/cfn-plan.test.ts +328 -9
- package/src/components/cloud-executor.ts +17 -1
package/src/components/apply.ts
CHANGED
|
@@ -24,6 +24,16 @@ import { readFileSync } from "node:fs";
|
|
|
24
24
|
import type { Capability, CapabilityPlan, DeployContext, PlannedOutputs } from "@intentius/chant/components/capability";
|
|
25
25
|
import type { ChangeSetEntry, ChangeSetHole, ChangeSetPart } from "@intentius/chant/change-set";
|
|
26
26
|
import { computePlanDigest } from "@intentius/chant/lifecycle/plan-digest";
|
|
27
|
+
import {
|
|
28
|
+
normalizePlanShape,
|
|
29
|
+
PlanShapeMovedError,
|
|
30
|
+
planShapeDifferences,
|
|
31
|
+
planShapeDigest,
|
|
32
|
+
type PlanShape,
|
|
33
|
+
type PlanShapeArtifact,
|
|
34
|
+
type PlanShapeEntry,
|
|
35
|
+
type ShapeReplacement,
|
|
36
|
+
} from "@intentius/chant/components/covered-round";
|
|
27
37
|
import { defaultCloudExecutor, type CfnChange, type CfnChangeSet, type CloudExecutor } from "./cloud-executor";
|
|
28
38
|
|
|
29
39
|
/** CloudFormation's wording when a change set has nothing to apply — the stack
|
|
@@ -65,8 +75,11 @@ export function cfnChangeReplaces(change: CfnChange): boolean {
|
|
|
65
75
|
* replace, `Remove` a delete, `Import` an import that changes nothing else,
|
|
66
76
|
* and `Dynamic` an update CloudFormation could not describe further.
|
|
67
77
|
*/
|
|
68
|
-
export function cfnChangeEntry(member: string, change: CfnChange): ChangeSetEntry {
|
|
69
|
-
|
|
78
|
+
export function cfnChangeEntry(member: string, change: CfnChange, unknown: readonly string[] = []): ChangeSetEntry {
|
|
79
|
+
// A property an unknown input drives is known after apply (#4419); one the
|
|
80
|
+
// change set did not list (a create names no properties) is added for it.
|
|
81
|
+
const paths = [...new Set([...(change.details ?? []), ...unknown])].sort();
|
|
82
|
+
const attributes = paths.map((path) => (unknown.includes(path) ? { path, unknown: true as const } : { path }));
|
|
70
83
|
// A change inside a nested stack (#3997) sits under the nested stacks'
|
|
71
84
|
// logical ids, the way a terraform address sits under its module.
|
|
72
85
|
const module = change.nestedStack?.length ? change.nestedStack.join("/") : undefined;
|
|
@@ -177,6 +190,8 @@ function cfnPlanDigest(
|
|
|
177
190
|
input: CfnDeployInput,
|
|
178
191
|
parameters: Record<string, string | null>,
|
|
179
192
|
unresolved: string[],
|
|
193
|
+
/** 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. */
|
|
194
|
+
preview?: string,
|
|
180
195
|
): string {
|
|
181
196
|
return computePlanDigest("cfn-deploy-plan", {
|
|
182
197
|
env: ctx.env,
|
|
@@ -185,6 +200,7 @@ function cfnPlanDigest(
|
|
|
185
200
|
templateDigest: templateDigest(input.template),
|
|
186
201
|
parameters,
|
|
187
202
|
unresolved,
|
|
203
|
+
...(preview ? { preview } : {}),
|
|
188
204
|
});
|
|
189
205
|
}
|
|
190
206
|
|
|
@@ -227,7 +243,7 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
227
243
|
} else parameters[key] = typeof value === "string" ? value : JSON.stringify(value);
|
|
228
244
|
}
|
|
229
245
|
const unresolved = [...(ctx.unresolvedInputs ?? [])].sort();
|
|
230
|
-
|
|
246
|
+
let planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved);
|
|
231
247
|
const member = (holes: ChangeSetHole[]): ChangeSetPart["member"] => ({
|
|
232
248
|
member: stack,
|
|
233
249
|
lexicon: "aws",
|
|
@@ -238,15 +254,25 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
238
254
|
});
|
|
239
255
|
|
|
240
256
|
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 {
|
|
257
|
+
const hole = (why: string): CapabilityPlan => ({
|
|
246
258
|
member: stack,
|
|
247
259
|
planDigest,
|
|
248
|
-
changeSet: { member: member([{ address: stack, type: "AWS::CloudFormation::Stack", reason }]), entries: [] },
|
|
249
|
-
};
|
|
260
|
+
changeSet: { member: member([{ address: stack, type: "AWS::CloudFormation::Stack", reason: why }]), entries: [] },
|
|
261
|
+
});
|
|
262
|
+
const fallback =
|
|
263
|
+
unresolved.length > 0
|
|
264
|
+
? `inputs from ${unresolved.join(", ")}; re-checked on apply`
|
|
265
|
+
: `inputs ${[...missing].sort().join(", ")} are known only at apply; re-checked on apply`;
|
|
266
|
+
// Which parameters are unknown: the inputs that hold nothing yet. When the
|
|
267
|
+
// upstream is unresolved but no input says which, nothing can be previewed.
|
|
268
|
+
if (missing.length === 0) return hole(fallback);
|
|
269
|
+
const preview = await previewCfnDeploy(executor, stack, input, [...missing].sort(), unresolved.length > 0 ? unresolved : undefined);
|
|
270
|
+
if (typeof preview === "string") return hole(`${fallback}; no preview: ${preview}`);
|
|
271
|
+
const { entries, shape, outputs } = preview;
|
|
272
|
+
const artifact: CfnShapeArtifact = { kind: "cfn-deploy-shape", member: stack, shape, digest: planShapeDigest(shape) };
|
|
273
|
+
// The preview's shape enters the digest (#4423); `member` reads it when called below.
|
|
274
|
+
planDigest = cfnPlanDigest(ctx, stack, input, parameters, unresolved, artifact.digest);
|
|
275
|
+
return { member: stack, planDigest, artifact, shape, changeSet: { member: member([]), entries }, ...(outputs ? { outputs } : {}) };
|
|
250
276
|
}
|
|
251
277
|
|
|
252
278
|
const changeSet = await executor.cloudformation.createChangeSet({
|
|
@@ -271,8 +297,184 @@ async function planCfnDeploy(executor: CloudExecutor, ctx: DeployContext, input:
|
|
|
271
297
|
if (blocked.length > 0 && (input.onReplace ?? "block") === "block") throw new CfnReplacementBlockedError(stack, blocked);
|
|
272
298
|
const entries = changeSet.changes.map((c) => cfnChangeEntry(stack, c));
|
|
273
299
|
const artifact: CfnPlanArtifact = { kind: "cfn-deploy-plan", stack, changesDigest: cfnChangesDigest(stack, changeSet) };
|
|
274
|
-
const outputs = changeSet.stackCreated ? undefined : await plannedStackOutputs(executor, stack, input.template, changeSet.changes);
|
|
275
|
-
|
|
300
|
+
const outputs = changeSet.stackCreated ? undefined : await plannedStackOutputs(executor, stack, input.template, changeSet.changes, knownParameters(parameters));
|
|
301
|
+
// The real change set's shape, with the replacement CloudFormation reports,
|
|
302
|
+
// for a round that checks it against a preview (#4419).
|
|
303
|
+
const shape = cfnPlanShape(stack, changeSet, []);
|
|
304
|
+
return { member: stack, planDigest, artifact, shape, changeSet: { member: member([]), entries }, ...(outputs ? { outputs } : {}) };
|
|
305
|
+
} finally {
|
|
306
|
+
await cleanUpPlanChangeSet(executor, changeSet);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── preview of a member whose inputs are unknown (#4419) ────────────────────
|
|
311
|
+
|
|
312
|
+
/** What a previewed member hands `run`: the shape its change set had against stand-in values. */
|
|
313
|
+
export interface CfnShapeArtifact extends PlanShapeArtifact {
|
|
314
|
+
kind: "cfn-deploy-shape";
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function isCfnShapeArtifact(value: unknown): value is CfnShapeArtifact {
|
|
318
|
+
const v = value as Partial<CfnShapeArtifact> | undefined;
|
|
319
|
+
return v?.kind === "cfn-deploy-shape" && typeof v.digest === "string" && typeof v.shape === "object" && v.shape !== null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The marker every stand-in carries, so a reader of a change set can tell it from a real value. */
|
|
323
|
+
export const CFN_UNKNOWN_MARK = "known-after-apply";
|
|
324
|
+
|
|
325
|
+
const ID_PREFIX: Record<string, string> = {
|
|
326
|
+
"AWS::EC2::Subnet::Id": "subnet",
|
|
327
|
+
"AWS::EC2::VPC::Id": "vpc",
|
|
328
|
+
"AWS::EC2::SecurityGroup::Id": "sg",
|
|
329
|
+
"AWS::EC2::Instance::Id": "i",
|
|
330
|
+
"AWS::EC2::Image::Id": "ami",
|
|
331
|
+
"AWS::EC2::Volume::Id": "vol",
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A stand-in for a parameter whose value is not known yet: valid for the
|
|
336
|
+
* parameter's type and recognisable. An EC2 id type gets the id's prefix
|
|
337
|
+
* (`subnet-00000000known-after-apply`), a number gets 0, anything else gets
|
|
338
|
+
* `known-after-apply`. Real CloudFormation checks the AWS-specific types
|
|
339
|
+
* against the account and refuses an id that does not exist; the preview
|
|
340
|
+
* then falls back to a hole. Floci does not check.
|
|
341
|
+
*/
|
|
342
|
+
export function cfnPlaceholder(type: string | undefined): string {
|
|
343
|
+
if (type && ID_PREFIX[type]) return `${ID_PREFIX[type]}-00000000${CFN_UNKNOWN_MARK}`;
|
|
344
|
+
if (type === "Number") return "0";
|
|
345
|
+
if (type?.startsWith("AWS::SSM::Parameter::Value<")) return `/${CFN_UNKNOWN_MARK}`;
|
|
346
|
+
return CFN_UNKNOWN_MARK;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
interface TemplateFacts {
|
|
350
|
+
parameterTypes: Record<string, string>;
|
|
351
|
+
/** Per logical id, the top-level properties whose value reads one of the keys. */
|
|
352
|
+
driven: Map<string, Set<string>>;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function readsParameter(value: unknown, key: string): boolean {
|
|
356
|
+
if (typeof value === "string") return false;
|
|
357
|
+
if (Array.isArray(value)) return value.some((v) => readsParameter(v, key));
|
|
358
|
+
if (value && typeof value === "object") {
|
|
359
|
+
const obj = value as Record<string, unknown>;
|
|
360
|
+
if (obj.Ref === key) return true;
|
|
361
|
+
const sub = obj["Fn::Sub"];
|
|
362
|
+
const text = typeof sub === "string" ? sub : Array.isArray(sub) && typeof sub[0] === "string" ? sub[0] : undefined;
|
|
363
|
+
if (text !== undefined && (text.includes("${" + key + "}") || text.includes("${" + key + "."))) return true;
|
|
364
|
+
return Object.values(obj).some((v) => readsParameter(v, key));
|
|
365
|
+
}
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The parameter types and the properties that read `keys`, from the template. A template that cannot be read gives no facts. */
|
|
370
|
+
function templateFacts(path: string, keys: string[]): TemplateFacts {
|
|
371
|
+
const facts: TemplateFacts = { parameterTypes: {}, driven: new Map() };
|
|
372
|
+
try {
|
|
373
|
+
const t = JSON.parse(readFileSync(path, "utf8")) as {
|
|
374
|
+
Parameters?: Record<string, { Type?: string }>;
|
|
375
|
+
Resources?: Record<string, { Properties?: Record<string, unknown> }>;
|
|
376
|
+
};
|
|
377
|
+
for (const k of keys) if (t.Parameters?.[k]?.Type) facts.parameterTypes[k] = t.Parameters[k].Type!;
|
|
378
|
+
for (const [id, r] of Object.entries(t.Resources ?? {})) {
|
|
379
|
+
for (const [prop, value] of Object.entries(r.Properties ?? {})) {
|
|
380
|
+
if (keys.some((k) => readsParameter(value, k))) (facts.driven.get(id) ?? facts.driven.set(id, new Set()).get(id)!).add(prop);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
/* unreadable or not JSON: the change set reports why, and no property is attributed */
|
|
385
|
+
}
|
|
386
|
+
return facts;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The properties of one change that the unknown parameters drive. */
|
|
390
|
+
function drivenProperties(change: CfnChange, keys: string[], facts: TemplateFacts): string[] {
|
|
391
|
+
const out = new Set<string>();
|
|
392
|
+
for (const d of change.drivers ?? []) {
|
|
393
|
+
if (d.changeSource === "ParameterReference" && d.causingEntity && keys.some((k) => d.causingEntity === k || d.causingEntity!.startsWith(`${k}.`))) {
|
|
394
|
+
out.add(d.property);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const scanned = facts.driven.get(change.logicalResourceId);
|
|
398
|
+
if (scanned && change.action !== "Remove") {
|
|
399
|
+
const listed = change.details ?? [];
|
|
400
|
+
for (const p of scanned) if (change.action === "Add" || listed.length === 0 || listed.includes(p)) out.add(p);
|
|
401
|
+
}
|
|
402
|
+
return [...out].sort();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function shapeReplacement(change: CfnChange): ShapeReplacement {
|
|
406
|
+
if (change.replacementMode === "True" || change.replacement) return "true";
|
|
407
|
+
return change.replacementMode === "Conditional" ? "conditional" : "false";
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* A change set's shape: each change's resource, action, replacement and
|
|
412
|
+
* properties, with the ones the `unknown` parameters drive marked. `unknown`
|
|
413
|
+
* is empty for the change set made against real values. `refs` are the
|
|
414
|
+
* inputs the shape names as unknown (`<component>.<output>`), the parameters
|
|
415
|
+
* themselves when nothing says better.
|
|
416
|
+
*/
|
|
417
|
+
export function cfnPlanShape(stack: string, changeSet: CfnChangeSet, unknown: string[], templatePath?: string, refs: string[] = unknown): PlanShape {
|
|
418
|
+
const facts = unknown.length > 0 && templatePath ? templateFacts(templatePath, unknown) : { parameterTypes: {}, driven: new Map() };
|
|
419
|
+
const entries: PlanShapeEntry[] = isNoopChangeSet(changeSet)
|
|
420
|
+
? []
|
|
421
|
+
: changeSet.changes.map((c) => {
|
|
422
|
+
const e = cfnChangeEntry(stack, c);
|
|
423
|
+
return {
|
|
424
|
+
logicalId: e.address,
|
|
425
|
+
action: e.action,
|
|
426
|
+
replacement: shapeReplacement(c),
|
|
427
|
+
type: c.resourceType,
|
|
428
|
+
properties: [...new Set(c.details ?? [])],
|
|
429
|
+
unknown: drivenProperties(c, unknown, facts),
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
return normalizePlanShape({ member: stack, unknownInputs: refs, entries });
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Preview a stack whose parameters `unknown` are not known yet: create its
|
|
437
|
+
* change set with a stand-in for each, read the changes into entries that
|
|
438
|
+
* mark the properties those parameters drive as known after apply, take the
|
|
439
|
+
* shape, and delete the change set. Returns why instead when CloudFormation
|
|
440
|
+
* would not make the change set (real CloudFormation refuses an id that does
|
|
441
|
+
* not exist), so the caller plans a hole.
|
|
442
|
+
*
|
|
443
|
+
* Floci accepts a change set that real CloudFormation refuses (a missing
|
|
444
|
+
* export, an id that does not exist), reports `Parameters: null`, and says
|
|
445
|
+
* `Replacement: False` for a change that replaces. So a cross-stack value
|
|
446
|
+
* goes through a parameter here, never an export, and the replacement a
|
|
447
|
+
* preview reports is whatever the backend says; the shape check at apply
|
|
448
|
+
* compares like with like.
|
|
449
|
+
*/
|
|
450
|
+
async function previewCfnDeploy(
|
|
451
|
+
executor: CloudExecutor,
|
|
452
|
+
stack: string,
|
|
453
|
+
input: CfnDeployInput,
|
|
454
|
+
unknown: string[],
|
|
455
|
+
refs: string[] = unknown,
|
|
456
|
+
): Promise<{ entries: ChangeSetEntry[]; shape: PlanShape; outputs?: PlannedOutputs } | string> {
|
|
457
|
+
const facts = templateFacts(input.template, unknown);
|
|
458
|
+
const parameters: Record<string, string> = { ...(flattenCfnInputs(input) ?? {}) };
|
|
459
|
+
for (const k of unknown) parameters[k] = cfnPlaceholder(facts.parameterTypes[k]);
|
|
460
|
+
let changeSet: CfnChangeSet;
|
|
461
|
+
try {
|
|
462
|
+
changeSet = await executor.cloudformation.createChangeSet({ stackName: stack, templatePath: input.template, parameters });
|
|
463
|
+
} catch (err) {
|
|
464
|
+
return `CloudFormation refused the change set with stand-in values: ${err instanceof Error ? err.message : String(err)}`;
|
|
465
|
+
}
|
|
466
|
+
try {
|
|
467
|
+
if (changeSet.status === "FAILED" && !isNoopChangeSet(changeSet)) {
|
|
468
|
+
return `CloudFormation could not create the change set with stand-in values${changeSet.statusReason ? `: ${changeSet.statusReason}` : ""}`;
|
|
469
|
+
}
|
|
470
|
+
const noop = isNoopChangeSet(changeSet);
|
|
471
|
+
const entries = noop ? [] : changeSet.changes.map((c) => cfnChangeEntry(stack, c, drivenProperties(c, unknown, facts)));
|
|
472
|
+
// What the stack's outputs will be, for the members that read them: an
|
|
473
|
+
// output that reads one of the unknown parameters is unknown, one that
|
|
474
|
+
// reads a known parameter or states its value is not (#4419).
|
|
475
|
+
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);
|
|
477
|
+
return { entries, shape: cfnPlanShape(stack, changeSet, unknown, input.template, refs), ...(outputs ? { outputs } : {}) };
|
|
276
478
|
} finally {
|
|
277
479
|
await cleanUpPlanChangeSet(executor, changeSet);
|
|
278
480
|
}
|
|
@@ -312,11 +514,17 @@ async function standingOutputs(executor: CloudExecutor, stack: string): Promise<
|
|
|
312
514
|
* What the plan says of the stack's outputs once `changes` apply: none to
|
|
313
515
|
* say for a template with no `Outputs`, which needs no `DescribeStacks`.
|
|
314
516
|
*/
|
|
315
|
-
async function plannedStackOutputs(
|
|
517
|
+
async function plannedStackOutputs(
|
|
518
|
+
executor: CloudExecutor,
|
|
519
|
+
stack: string,
|
|
520
|
+
templatePath: string,
|
|
521
|
+
changes: readonly CfnChange[],
|
|
522
|
+
parameters: Record<string, string> = {},
|
|
523
|
+
): Promise<PlannedOutputs | undefined> {
|
|
316
524
|
const template = readTemplate(templatePath) as { Outputs?: Record<string, unknown> } | undefined;
|
|
317
525
|
if (!template || typeof template !== "object") return undefined;
|
|
318
526
|
if (Object.keys(template.Outputs ?? {}).length === 0) return { known: {}, unknown: [] };
|
|
319
|
-
return cfnPlannedOutputs(template, changes, await standingOutputs(executor, stack));
|
|
527
|
+
return cfnPlannedOutputs(template, changes, await standingOutputs(executor, stack), parameters);
|
|
320
528
|
}
|
|
321
529
|
|
|
322
530
|
/** The template at `path` as JSON, or `undefined` when it cannot be read or parsed here. */
|
|
@@ -386,9 +594,21 @@ function valueReads(value: unknown): { ids: Set<string>; opaque: boolean } {
|
|
|
386
594
|
* `Dynamic`, no replacement, no change in a nested stack under it). One
|
|
387
595
|
* that reads a parameter, an import or a condition, one the stack does not
|
|
388
596
|
* have yet, and every output of a template this cannot read, is unknown.
|
|
597
|
+
*
|
|
598
|
+
* Two kinds of output need no standing value. An output whose `Value` is a
|
|
599
|
+
* plain string is known at that string, changed or new: the template says it.
|
|
600
|
+
* An output that is a `Ref` to a parameter the plan was given a value for is
|
|
601
|
+
* known at that value (`given`, the inputs of this plan). Both are what
|
|
602
|
+
* the stack's outputs will be, whatever they are now.
|
|
603
|
+
*
|
|
389
604
|
* `undefined` when there is no template or no standing stack to read from.
|
|
390
605
|
*/
|
|
391
|
-
export function cfnPlannedOutputs(
|
|
606
|
+
export function cfnPlannedOutputs(
|
|
607
|
+
template: unknown,
|
|
608
|
+
changes: readonly CfnChange[],
|
|
609
|
+
standing: Record<string, string> | undefined,
|
|
610
|
+
given: Record<string, string> = {},
|
|
611
|
+
): PlannedOutputs | undefined {
|
|
392
612
|
if (!standing || template === null || typeof template !== "object") return undefined;
|
|
393
613
|
const t = template as { Outputs?: Record<string, { Value?: unknown; Condition?: unknown }>; Parameters?: Record<string, unknown> };
|
|
394
614
|
const parameters = new Set(Object.keys(t.Parameters ?? {}));
|
|
@@ -401,6 +621,11 @@ export function cfnPlannedOutputs(template: unknown, changes: readonly CfnChange
|
|
|
401
621
|
const known: Record<string, unknown> = {};
|
|
402
622
|
const unknown: string[] = [];
|
|
403
623
|
for (const [name, output] of Object.entries(t.Outputs ?? {}).sort(([a], [b]) => a.localeCompare(b))) {
|
|
624
|
+
const stated = statedOutput(output, given, t.Parameters);
|
|
625
|
+
if (stated !== undefined) {
|
|
626
|
+
known[name] = stated;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
404
629
|
const { ids, opaque } = valueReads(output?.Value);
|
|
405
630
|
const stays =
|
|
406
631
|
!opaque && output?.Condition === undefined && name in standing &&
|
|
@@ -411,6 +636,32 @@ export function cfnPlannedOutputs(template: unknown, changes: readonly CfnChange
|
|
|
411
636
|
return { known, unknown };
|
|
412
637
|
}
|
|
413
638
|
|
|
639
|
+
/** The inputs a plan was given a value for: every parameter that is not null. */
|
|
640
|
+
function knownParameters(parameters: Record<string, string | null>): Record<string, string> {
|
|
641
|
+
return Object.fromEntries(Object.entries(parameters).filter((e): e is [string, string] => e[1] !== null));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* What an output's `Value` says outright: a plain string, or a `Ref` to a
|
|
646
|
+
* parameter this plan has a value for. `undefined` for anything that reads a
|
|
647
|
+
* resource, an import or a condition, which the standing outputs speak for.
|
|
648
|
+
*/
|
|
649
|
+
function statedOutput(
|
|
650
|
+
output: { Value?: unknown; Condition?: unknown } | undefined,
|
|
651
|
+
parameters: Record<string, string>,
|
|
652
|
+
declared: Record<string, unknown> | undefined,
|
|
653
|
+
): string | undefined {
|
|
654
|
+
if (output?.Condition !== undefined) return undefined;
|
|
655
|
+
const value = output?.Value;
|
|
656
|
+
if (typeof value === "string") return value;
|
|
657
|
+
if (value !== null && typeof value === "object") {
|
|
658
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
659
|
+
const [key, arg] = entries[0] ?? [];
|
|
660
|
+
if (entries.length === 1 && key === "Ref" && typeof arg === "string" && declared && arg in declared && arg in parameters) return parameters[arg];
|
|
661
|
+
}
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
|
|
414
665
|
/** Whether a CloudFormation error says the stack does not exist. */
|
|
415
666
|
const STACK_MISSING = /does not exist/i;
|
|
416
667
|
|
|
@@ -530,7 +781,16 @@ export function createCfnDeployCapability(
|
|
|
530
781
|
// set that proposes the same changes. Anything else means the stack,
|
|
531
782
|
// the template or the parameters moved since the plan.
|
|
532
783
|
const planned = ctx.plans?.[stack];
|
|
533
|
-
if (
|
|
784
|
+
if (isCfnShapeArtifact(planned)) {
|
|
785
|
+
// A member planned before its inputs were known (#4419): the change
|
|
786
|
+
// set made now is against the real values. It must have the shape the
|
|
787
|
+
// preview had, differing only in what the unknown inputs drive.
|
|
788
|
+
const differences = planShapeDifferences(planned.shape, cfnPlanShape(stack, changeSet, []));
|
|
789
|
+
if (differences.length > 0) {
|
|
790
|
+
await cleanUpPlanChangeSet(executor, changeSet);
|
|
791
|
+
throw new PlanShapeMovedError(stack, differences);
|
|
792
|
+
}
|
|
793
|
+
} else if (isCfnPlanArtifact(planned)) {
|
|
534
794
|
const now = cfnChangesDigest(stack, changeSet);
|
|
535
795
|
if (now !== planned.changesDigest) {
|
|
536
796
|
await cleanUpPlanChangeSet(executor, changeSet);
|
|
@@ -11,6 +11,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { CfnPlanMovedError, createCfnDeployCapability } from "./apply";
|
|
14
|
+
import { PlanShapeMovedError } from "@intentius/chant/components/covered-round";
|
|
14
15
|
import { defaultCloudExecutor } from "./cloud-executor";
|
|
15
16
|
|
|
16
17
|
const endpoint = process.env.CHANT_FLOCI_ENDPOINT;
|
|
@@ -74,3 +75,55 @@ describe.skipIf(!endpoint)("cfn-deploy plan against Floci (#3997)", () => {
|
|
|
74
75
|
expect(changeSets()).toEqual([]);
|
|
75
76
|
}, 600_000); // each `aws` call takes seconds, and a plan or a run makes several
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
describe.skipIf(!endpoint)("cfn-deploy preview against Floci (#4423)", () => {
|
|
80
|
+
const stack = `zz-4419-prev-${process.pid}`;
|
|
81
|
+
const ctx = { env: "local", component: stack, unresolvedInputs: ["network"] };
|
|
82
|
+
const saved = { ...process.env };
|
|
83
|
+
let dir: string;
|
|
84
|
+
let template: string;
|
|
85
|
+
const aws = (...args: string[]): string => execFileSync("aws", ["--endpoint-url", endpoint!, ...args], { encoding: "utf8" });
|
|
86
|
+
const open = (): string[] =>
|
|
87
|
+
((JSON.parse(aws("cloudformation", "list-change-sets", "--stack-name", stack)) as { Summaries?: Array<{ ChangeSetName: string; ExecutionStatus?: string }> }).Summaries ?? [])
|
|
88
|
+
.filter((c) => c.ExecutionStatus !== "EXECUTE_COMPLETE")
|
|
89
|
+
.map((c) => c.ChangeSetName);
|
|
90
|
+
const queue = (extra: Record<string, unknown> = {}) => ({
|
|
91
|
+
Parameters: { Prefix: { Type: "String" } },
|
|
92
|
+
Resources: { Queue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": "${Prefix}-q" } } }, ...extra },
|
|
93
|
+
});
|
|
94
|
+
const input = (prefix?: string) => ({ stack, template, inputs: { Prefix: prefix as never } });
|
|
95
|
+
|
|
96
|
+
beforeAll(() => {
|
|
97
|
+
Object.assign(process.env, { AWS_ENDPOINT_URL: endpoint, AWS_ACCESS_KEY_ID: "test", AWS_SECRET_ACCESS_KEY: "test", AWS_REGION: "us-east-1" });
|
|
98
|
+
dir = mkdtempSync(join(tmpdir(), "cfn-preview-floci-"));
|
|
99
|
+
template = join(dir, "stack.template.json");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(() => {
|
|
103
|
+
try {
|
|
104
|
+
aws("cloudformation", "delete-stack", "--stack-name", stack);
|
|
105
|
+
} catch { /* never created */ }
|
|
106
|
+
process.env = saved;
|
|
107
|
+
rmSync(dir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("previews a stack that does not exist yet, leaves nothing behind, refuses a real change set that adds a resource, and executes one with the previewed shape", async () => {
|
|
111
|
+
const capability = createCfnDeployCapability(defaultCloudExecutor());
|
|
112
|
+
writeFileSync(template, JSON.stringify(queue()));
|
|
113
|
+
const planned = await capability.plan!(ctx, input());
|
|
114
|
+
expect(planned.artifact).toMatchObject({ kind: "cfn-deploy-shape" });
|
|
115
|
+
expect(planned.changeSet!.entries.map((e) => [e.address, e.action])).toEqual([["Queue", "create"]]);
|
|
116
|
+
expect(open()).toEqual([]);
|
|
117
|
+
await expect(capability.outputs!(ctx, { stack, template })).resolves.toEqual({});
|
|
118
|
+
// The same template and inputs plan to the same digest.
|
|
119
|
+
expect((await capability.plan!(ctx, input())).planDigest).toBe(planned.planDigest);
|
|
120
|
+
|
|
121
|
+
writeFileSync(template, JSON.stringify(queue({ Extra: { Type: "AWS::SQS::Queue" } })));
|
|
122
|
+
await expect(capability.run({ ...ctx, plans: { [stack]: planned.artifact } }, input("p"))).rejects.toBeInstanceOf(PlanShapeMovedError);
|
|
123
|
+
expect(open()).toEqual([]);
|
|
124
|
+
|
|
125
|
+
writeFileSync(template, JSON.stringify(queue()));
|
|
126
|
+
const done = await capability.run({ ...ctx, plans: { [stack]: planned.artifact } }, input("p"));
|
|
127
|
+
expect(done.stackStatus).toBe("CREATE_COMPLETE");
|
|
128
|
+
}, 600_000);
|
|
129
|
+
});
|