@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.
@@ -15,6 +15,7 @@ import { applyPrSet, planPrSet, prReport } from "@intentius/chant/components/pr-
15
15
  import { composeChangeSet } from "@intentius/chant/change-set";
16
16
  import { groupChangeSet, planSummaryHeadline } from "@intentius/chant/plan-summary";
17
17
  import { memberCounts, prStatusDescription, renderPrNote } from "@intentius/chant/pr-loop";
18
+ import { PlanShapeMovedError } from "@intentius/chant/components/covered-round";
18
19
  import { CfnPlanMovedError, CfnReplacementBlockedError, cfnChangeEntry, createCfnDeployCapability, type CfnDeployInput } from "./apply";
19
20
  import { isPlanMovedError } from "@intentius/chant/components/capability";
20
21
  import { planMovedIn } from "@intentius/chant/components/driver";
@@ -122,6 +123,31 @@ describe("cfn-deploy plan (#4084)", () => {
122
123
  expect(planned.outputs).toEqual({ known: { Region: "us-east-1", TopicArn: "arn:topic" }, unknown: ["EnvName", "Imported", "QueueUrl", "TableArn"] });
123
124
  });
124
125
 
126
+ it("an output that is a plain string, or a Ref to a parameter this plan has a value for, is known at that value, changed or new (#4419)", async () => {
127
+ writeFileSync(
128
+ template,
129
+ JSON.stringify({
130
+ Parameters: { Port: { Type: "String" }, Later: { Type: "String" } },
131
+ Resources: { Setting: { Type: "AWS::SSM::Parameter" } },
132
+ Outputs: {
133
+ Literal: { Value: "8090" },
134
+ NewLiteral: { Value: "9000" },
135
+ Passed: { Value: { Ref: "Port" } },
136
+ NotYet: { Value: { Ref: "Later" } },
137
+ Setting: { Value: { Ref: "Setting" } },
138
+ },
139
+ }),
140
+ );
141
+ const { planned } = await planOne(
142
+ {
143
+ outputs: { Literal: "8080", Passed: "8080", NotYet: "x", Setting: "/setting" },
144
+ changes: [{ action: "Modify", logicalResourceId: "Setting", resourceType: "AWS::SSM::Parameter", replacement: false, replacementMode: "False" }],
145
+ },
146
+ { inputs: { Port: "8090" } },
147
+ );
148
+ expect(planned.outputs).toEqual({ known: { Literal: "8090", NewLiteral: "9000", Passed: "8090", Setting: "/setting" }, unknown: ["NotYet"] });
149
+ });
150
+
125
151
  it("a stack the change set creates says nothing of its outputs (#3999)", async () => {
126
152
  const { planned } = await planOne({ missing: true, changes: [{ action: "Add", logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false }] });
127
153
  expect(planned.outputs).toBeUndefined();
@@ -240,7 +266,8 @@ describe("pr-plan over cfn-deploy members (#4084)", () => {
240
266
  expect(set.plannedOutputs).toEqual({ network: [] });
241
267
  expect(set.members.find((m) => m.member === "cluster-a")?.unknownInputs).toEqual(["network.TopicArn"]);
242
268
  const note = renderPrNote(report);
243
- expect(note).toContain("| `cluster-a` | cluster-a | planned; inputs from network; re-checked on apply | ? | ? | ? | ? |");
269
+ // The member is previewed against a stand-in, so it counts its changes (#4419).
270
+ expect(note).toContain("| `cluster-a` | cluster-a | planned | 1 | 0 | 0 | 0 |");
244
271
  expect(note).toContain("Known only after apply: `cluster-a` reads `network.TopicArn`.");
245
272
  });
246
273
 
@@ -259,19 +286,28 @@ describe("pr-plan over cfn-deploy members (#4084)", () => {
259
286
  expect(created.find((c) => c.stackName === "cluster-a")?.parameters).toEqual({ TopicArn: "arn:topic" });
260
287
  });
261
288
 
262
- it("a member whose inputs come from a stack the run creates first says so, with no counts and no change set", async () => {
289
+ it("a member whose inputs come from a stack the run creates first is previewed against a stand-in, and the stand-in is deleted (#4419)", async () => {
263
290
  const { mock, set, report } = await planBoth({
264
291
  network: { missing: true, changes: [{ action: "Add", logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false }] },
265
- "cluster-a": { missing: true },
292
+ "cluster-a": { missing: true, changes: [{ action: "Add", logicalResourceId: "clusterAQueue", resourceType: "AWS::SQS::Queue", replacement: false }] },
266
293
  });
267
294
  const clusterA = set.doc.members.find((m) => m.member === "cluster-a")!;
268
- expect(clusterA).toMatchObject({ status: "planned", holes: [{ address: "cluster-a", reason: "inputs from network; re-checked on apply" }] });
269
- expect(set.doc.entries.filter((e) => e.member === "cluster-a")).toEqual([]);
270
- expect(mock.calls.filter((c) => c.method === "createChangeSet").map((c) => (c.args as { stackName: string }).stackName)).toEqual(["network"]);
295
+ expect(clusterA).toMatchObject({ status: "planned", holes: [] });
296
+ expect(set.doc.entries.filter((e) => e.member === "cluster-a")).toHaveLength(1);
297
+ const created = mock.calls.filter((c) => c.method === "createChangeSet").map((c) => c.args as { stackName: string; parameters?: Record<string, string> });
298
+ expect(created.find((c) => c.stackName === "cluster-a")?.parameters).toEqual({ TopicArn: "known-after-apply" });
299
+ const deleted = mock.calls.filter((c) => c.method === "deleteChangeSet").map((c) => (c.args as { stackName: string }).stackName);
300
+ expect(deleted.sort()).toEqual(["cluster-a", "network"]);
271
301
  const note = renderPrNote(report);
272
- expect(note).toContain("| `cluster-a` | cluster-a | planned; inputs from network; re-checked on apply | ? | ? | ? | ? |");
302
+ expect(note).toContain("| `cluster-a` | cluster-a | planned | 1 | 0 | 0 | 0 |");
273
303
  expect(note).toContain("| `network` | network | planned | 1 | 0 | 0 | 0 |");
274
- expect(prStatusDescription(report)).toContain("1 with holes");
304
+ });
305
+
306
+ it("a member whose upstream is unresolved but names no unknown input stays a hole", async () => {
307
+ const mock = createMockCloudExecutor({ stacks: { network: {} } });
308
+ const planned = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["up"] }, { stack: "network", template });
309
+ expect(planned.changeSet?.member.holes).toMatchObject([{ reason: "inputs from up; re-checked on apply" }]);
310
+ expect(mock.calls.filter((c) => c.method === "createChangeSet")).toEqual([]);
275
311
  });
276
312
 
277
313
  it("planning the same state twice gives the same digest, so the apply's plan matches the approved one", async () => {
@@ -335,7 +371,7 @@ describe("cfn-deploy applies the change set it planned (#3997)", () => {
335
371
  expect(methods(mock).slice(3)).toEqual(["createChangeSet", "deleteChangeSet", "deleteStackShell"]);
336
372
  });
337
373
 
338
- it("a member with holes hands run no artifact, so run plans it afresh", async () => {
374
+ it("a member with a hole it could not preview hands run no artifact, so run plans it afresh", async () => {
339
375
  const mock = createMockCloudExecutor({ stacks: { network: { changes: [queueAdd] } } });
340
376
  const capability = createCfnDeployCapability(mock.executor);
341
377
  const planned = await capability.plan!({ ...ctx, unresolvedInputs: ["upstream"] }, { stack: "network", template });
@@ -438,3 +474,286 @@ describe("cfn-deploy targets (#4300)", () => {
438
474
  expect(targetRefusal(createCfnDeployCapability(createMockCloudExecutor().executor))).toMatch(/cfn-deploy can't narrow/);
439
475
  });
440
476
  });
477
+
478
+ describe("cfn-deploy previews a member whose inputs are unknown, and checks the real change against it (#4419)", () => {
479
+ const eni = { action: "Modify" as const, logicalResourceId: "Eni", resourceType: "AWS::EC2::NetworkInterface", replacement: false, replacementMode: "False" as const };
480
+ const withDriver = (property: string, causingEntity = "SubnetId") => ({
481
+ ...eni,
482
+ details: [property, "Description"],
483
+ drivers: [{ property, changeSource: "ParameterReference", causingEntity }, { property: "Description", changeSource: "DirectModification" }],
484
+ });
485
+ const input = (): CfnDeployInput => ({ stack: "net", template, inputs: { SubnetId: undefined as never } });
486
+
487
+ beforeEach(() => {
488
+ writeFileSync(
489
+ template,
490
+ JSON.stringify({
491
+ Parameters: { SubnetId: { Type: "AWS::EC2::Subnet::Id" }, Desc: { Type: "String", Default: "x" } },
492
+ Resources: {
493
+ Eni: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { Ref: "SubnetId" }, Description: "d" } },
494
+ Q: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": "q-${SubnetId}" } } },
495
+ },
496
+ }),
497
+ );
498
+ });
499
+
500
+ const q = { action: "Add" as const, logicalResourceId: "Q", resourceType: "AWS::SQS::Queue", replacement: false };
501
+
502
+ it("creates the change set with an id-shaped stand-in, marks the driven properties unknown, and deletes the change set", async () => {
503
+ const mock = createMockCloudExecutor({ stacks: { net: { changes: [withDriver("SubnetId"), q] } } });
504
+ const planned = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, input());
505
+ const created = mock.calls.find((c) => c.method === "createChangeSet")!.args as { parameters: Record<string, string> };
506
+ expect(created.parameters).toEqual({ SubnetId: "subnet-00000000known-after-apply" });
507
+ const entries = planned.changeSet!.entries;
508
+ expect(entries.find((e) => e.address === "Eni")?.attributes).toEqual([
509
+ { path: "Description" },
510
+ { path: "SubnetId", unknown: true },
511
+ ]);
512
+ // A create names no properties; the template says which one reads the parameter.
513
+ expect(entries.find((e) => e.address === "Q")?.attributes).toEqual([{ path: "QueueName", unknown: true }]);
514
+ expect(planned.changeSet!.member.holes).toEqual([]);
515
+ expect(methods(mock)).toEqual(["createChangeSet", "deleteChangeSet"]);
516
+ expect(planned.artifact).toMatchObject({ kind: "cfn-deploy-shape" });
517
+ // The preview reports its shape to core, unknown where a placeholder drives it (#4419).
518
+ expect(planned.shape).toMatchObject({ member: "net", unknownInputs: ["vpc"] });
519
+ expect(planned.shape!.entries.find((e) => e.logicalId === "Eni")).toMatchObject({ action: "update", replacement: "false", properties: ["Description", "SubnetId"], unknown: ["SubnetId"] });
520
+ expect(planned.shape!.entries.find((e) => e.logicalId === "Q")).toMatchObject({ action: "create", unknown: ["QueueName"] });
521
+ });
522
+
523
+ it("a previewed member still says the outputs that do not read an unknown input (#4419)", async () => {
524
+ writeFileSync(
525
+ template,
526
+ JSON.stringify({
527
+ Parameters: { SubnetId: { Type: "AWS::EC2::Subnet::Id" }, Port: { Type: "String" } },
528
+ Resources: { Eni: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { Ref: "SubnetId" } } } },
529
+ Outputs: { Port: { Value: { Ref: "Port" } }, Fixed: { Value: "8080" }, Id: { Value: { Ref: "SubnetId" } } },
530
+ }),
531
+ );
532
+ const mock = createMockCloudExecutor({ stacks: { net: { outputs: { Port: "1", Fixed: "1", Id: "x" }, changes: [{ ...eni, action: "Add" as const, details: [] }] } } });
533
+ const planned = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, { stack: "net", template, inputs: { SubnetId: undefined as never, Port: "8080" } });
534
+ expect(planned.outputs).toEqual({ known: { Fixed: "8080", Port: "8080" }, unknown: ["Id"] });
535
+ });
536
+
537
+ it("a conditional replacement in the real change set is read as conditional", async () => {
538
+ const mock = createMockCloudExecutor({ stacks: { net: { changes: [{ ...eni, replacementMode: "Conditional" as never }] } } });
539
+ const planned = await createCfnDeployCapability(mock.executor).plan!(ctx, { stack: "net", template, inputs: { SubnetId: "subnet-1" } });
540
+ expect(planned.shape!.entries[0]).toMatchObject({ logicalId: "Eni", replacement: "conditional" });
541
+ });
542
+
543
+ it("falls back to a hole when CloudFormation refuses the stand-in", async () => {
544
+ const mock = createMockCloudExecutor({ stacks: { net: { changes: [] } } });
545
+ const real = mock.executor.cloudformation.createChangeSet;
546
+ mock.executor.cloudformation.createChangeSet = async () => {
547
+ throw new Error("Parameter validation failed: parameter value subnet-00000000known-after-apply for parameter name SubnetId does not exist");
548
+ };
549
+ void real;
550
+ const planned = await createCfnDeployCapability(mock.executor).plan!(ctx, input());
551
+ expect(planned.artifact).toBeUndefined();
552
+ expect(planned.changeSet!.member.holes[0].reason).toMatch(/no preview: CloudFormation refused .*does not exist/);
553
+ });
554
+
555
+ async function previewThenRun(previewed: ReturnType<typeof withDriver>[], real: Array<typeof eni | typeof q | ReturnType<typeof withDriver>>) {
556
+ const mock = createMockCloudExecutor({ stacks: { net: { changes: previewed } } });
557
+ const capability = createCfnDeployCapability(mock.executor);
558
+ const planned = await capability.plan!(ctx, input());
559
+ mock.setStack("net", { changes: real });
560
+ const result = await capability.run({ ...ctx, plans: { net: planned.artifact } }, { stack: "net", template, inputs: { SubnetId: "subnet-1" } }).catch((e: unknown) => e);
561
+ return { mock, result };
562
+ }
563
+
564
+ it("executes when the real change matches the preview", async () => {
565
+ const { mock, result } = await previewThenRun([withDriver("SubnetId")], [withDriver("SubnetId")]);
566
+ expect(result).toMatchObject({ stackStatus: expect.any(String) });
567
+ expect(methods(mock)).toContain("executeChangeSet");
568
+ });
569
+
570
+ it("executes when an unknown-driven property drops out because the real value equals the current one", async () => {
571
+ const { mock, result } = await previewThenRun([withDriver("SubnetId")], [{ ...eni, details: ["Description"] }]);
572
+ expect(result).toMatchObject({ stackStatus: expect.any(String) });
573
+ expect(methods(mock)).toContain("executeChangeSet");
574
+ });
575
+
576
+ it("refuses, executing nothing, when the real change adds a resource", async () => {
577
+ const { mock, result } = await previewThenRun([withDriver("SubnetId")], [withDriver("SubnetId"), q]);
578
+ expect(result).toBeInstanceOf(PlanShapeMovedError);
579
+ expect((result as Error).message).toContain("net now plans to create Q, which the first plan did not");
580
+ expect((result as { planMoved?: boolean }).planMoved).toBe(true);
581
+ expect(methods(mock)).not.toContain("executeChangeSet");
582
+ expect(methods(mock).at(-1)).toBe("deleteChangeSet");
583
+ });
584
+
585
+ it("refuses when a property no unknown input drives stops changing", async () => {
586
+ const { mock, result } = await previewThenRun([withDriver("SubnetId")], [{ ...eni, details: ["SubnetId"] }]);
587
+ expect(result).toBeInstanceOf(PlanShapeMovedError);
588
+ expect((result as Error).message).toContain("no longer changes Description on Eni");
589
+ expect(methods(mock)).not.toContain("executeChangeSet");
590
+ });
591
+
592
+ it("refuses when the real change replaces and the preview did not", async () => {
593
+ const { result } = await previewThenRun([withDriver("SubnetId")], [{ ...withDriver("SubnetId"), replacementMode: "Conditional" as never }]);
594
+ expect(result).toBeInstanceOf(PlanShapeMovedError);
595
+ expect((result as Error).message).toContain("may now replace Eni, which the first plan did not show");
596
+ });
597
+ });
598
+
599
+ describe("pr-apply through AWS and core together: a previewed member in a round (#4422, #4423)", () => {
600
+ const queueAdd = { action: "Add" as const, logicalResourceId: "clusterAQueue", resourceType: "AWS::SQS::Queue", replacement: false };
601
+ const extraAdd = { action: "Add" as const, logicalResourceId: "Extra", resourceType: "AWS::SNS::Topic", replacement: false };
602
+ const approvedFirst = { status: "approved" as const, approvedBy: ["github:rev"] };
603
+
604
+ beforeEach(() => {
605
+ writeFileSync(
606
+ template,
607
+ JSON.stringify({
608
+ Parameters: { TopicArn: { Type: "String" } },
609
+ Resources: { Topic: { Type: "AWS::SNS::Topic" }, clusterAQueue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": "q-${TopicArn}" } } } },
610
+ }),
611
+ );
612
+ });
613
+
614
+ const components = (): DriverComponent[] => [
615
+ { name: "network", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "network", template }] }] },
616
+ {
617
+ name: "cluster-a",
618
+ dependsOn: ["network"],
619
+ deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "cluster-a", template, inputs: { TopicArn: { stackOutput: { stack: "network", name: "TopicArn" } } } }] }],
620
+ },
621
+ ];
622
+
623
+ async function planned(stacks: Record<string, FakeStackConfig>) {
624
+ const mock = createMockCloudExecutor({ stacks });
625
+ const registry = new CapabilityRegistry();
626
+ registry.register(createCfnDeployCapability(mock.executor));
627
+ const all = components();
628
+ const plan = planFanOut({ components: all, changed: ["network"] });
629
+ const set = await planPrSet({ components: all, plan, registry, env: "local" });
630
+ return { mock, registry, all, plan, set };
631
+ }
632
+
633
+ const stacks = (): Record<string, FakeStackConfig> => ({
634
+ network: { outputs: { TopicArn: "arn:topic" }, changes: [{ action: "Add", logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false }] },
635
+ "cluster-a": { changes: [queueAdd] },
636
+ });
637
+ const executed = (mock: ReturnType<typeof createMockCloudExecutor>) =>
638
+ mock.calls.filter((c) => c.method === "executeChangeSet").map((c) => (c.args as { stackName: string }).stackName);
639
+
640
+ it("the real change set has the previewed shape: the first approval covers the round, nothing asks again, and both stacks are executed", async () => {
641
+ const { mock, registry, all, plan, set } = await planned(stacks());
642
+ expect(set.shapes?.["cluster-a"]).toMatchObject({ member: "cluster-a" });
643
+ let asked = 0;
644
+ const applied = await applyPrSet({
645
+ components: all,
646
+ plan,
647
+ registry,
648
+ env: "local",
649
+ set,
650
+ approval: approvedFirst,
651
+ decideRound: async () => {
652
+ asked++;
653
+ return { satisfied: false, approval: { status: "pending" } };
654
+ },
655
+ });
656
+ expect(asked).toBe(0);
657
+ expect(applied.status).toBe("applied");
658
+ expect(executed(mock)).toEqual(["network", "cluster-a"]);
659
+ expect(applied.rounds?.[0]).toMatchObject({ round: 2, gate: "pr-apply-round-2", status: "applied", covered: { digest: set.doc.digest, filledInputs: ["network.TopicArn"] } });
660
+ });
661
+
662
+ it("a real change set with a resource the preview did not show is not executed: the member waits at pr-apply-round-2, naming the reason and the new digest", async () => {
663
+ const { mock, registry, all, plan, set } = await planned(stacks());
664
+ mock.setStack("cluster-a", { changes: [queueAdd, extraAdd] });
665
+ const seen: Array<{ gate: string; notCovered?: string; digest: string }> = [];
666
+ const applied = await applyPrSet({
667
+ components: all,
668
+ plan,
669
+ registry,
670
+ env: "local",
671
+ set,
672
+ approval: approvedFirst,
673
+ decideRound: async ({ gate, notCovered, set: round }) => {
674
+ seen.push({ gate, ...(notCovered ? { notCovered } : {}), digest: round.doc.digest });
675
+ return { satisfied: false, approval: { status: "pending" } };
676
+ },
677
+ });
678
+ expect(applied.status).toBe("waiting");
679
+ expect(executed(mock)).toEqual(["network"]);
680
+ expect(seen).toHaveLength(1);
681
+ expect(seen[0]).toMatchObject({ gate: "pr-apply-round-2", notCovered: "cluster-a now plans to create Extra, which the first plan did not" });
682
+ expect(applied.rounds?.[0]).toMatchObject({ round: 2, gate: "pr-apply-round-2", status: "waiting", digest: seen[0]!.digest, notCovered: seen[0]!.notCovered });
683
+ expect(applied.rounds?.[0]?.digest).not.toBe(set.doc.digest);
684
+ expect(applied.members.find((m) => m.member === "cluster-a")).toMatchObject({ status: "waiting", round: 2 });
685
+ });
686
+
687
+ it("a failure part way leaves what applied, applied, and the report says which member failed", async () => {
688
+ const { mock, registry, all, plan, set } = await planned(stacks());
689
+ const run = mock.executor.cloudformation.waitForStack;
690
+ mock.executor.cloudformation.waitForStack = async (name: string, ...rest: never[]) => {
691
+ if (name === "cluster-a") throw new Error("cluster-a: CREATE_FAILED");
692
+ return (run as (n: string) => ReturnType<typeof run>)(name, ...(rest as []));
693
+ };
694
+ const applied = await applyPrSet({ components: all, plan, registry, env: "local", set, approval: approvedFirst, decideRound: async () => ({ satisfied: false, approval: { status: "pending" } }) });
695
+ expect(applied.status).toBe("failed");
696
+ expect(applied.members.map((m) => [m.member, m.status])).toEqual([["cluster-a", "failed"], ["network", "applied"]]);
697
+ });
698
+ });
699
+
700
+ describe("the preview's digest and its cleanup (#4423)", () => {
701
+ const eni = { action: "Modify" as const, logicalResourceId: "Eni", resourceType: "AWS::EC2::NetworkInterface", replacement: false, replacementMode: "False" as const };
702
+ const driven = (property: string) => ({
703
+ ...eni,
704
+ details: [property],
705
+ drivers: [{ property, changeSource: "ParameterReference", causingEntity: "SubnetId" }],
706
+ });
707
+ const input = (): CfnDeployInput => ({ stack: "net", template, inputs: { SubnetId: undefined as never } });
708
+
709
+ beforeEach(() => {
710
+ writeFileSync(
711
+ template,
712
+ JSON.stringify({
713
+ Parameters: { SubnetId: { Type: "AWS::EC2::Subnet::Id" } },
714
+ Resources: { Eni: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { Ref: "SubnetId" }, Description: "d" } } },
715
+ }),
716
+ );
717
+ });
718
+
719
+ const preview = async (config: FakeStackConfig, unresolved = ["vpc"]) => {
720
+ const mock = createMockCloudExecutor({ stacks: { net: config } });
721
+ const out = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: unresolved }, input());
722
+ return { mock, out };
723
+ };
724
+
725
+ it("the same inputs and template give the same digest across two plans; another preview shape gives another", async () => {
726
+ const a = await preview({ changes: [driven("SubnetId")] });
727
+ const b = await preview({ changes: [driven("SubnetId")] });
728
+ expect(a.out.artifact).toMatchObject({ kind: "cfn-deploy-shape" });
729
+ expect(b.out.planDigest).toBe(a.out.planDigest);
730
+ expect(b.out.changeSet!.member.planDigest).toBe(a.out.planDigest);
731
+ // The preview's digest is in it: a preview that shows another change set is another plan.
732
+ const other = await preview({ changes: [{ ...driven("SubnetId"), replacement: true, replacementMode: "True" as never }] });
733
+ expect(other.out.planDigest).not.toBe(a.out.planDigest);
734
+ // A member left a hole has the digest of its inputs alone.
735
+ const hole = await createCfnDeployCapability(createMockCloudExecutor({ stacks: { net: {} } }).executor).plan!({ ...ctx, unresolvedInputs: ["up"] }, { stack: "net", template });
736
+ expect(hole.planDigest).not.toBe(a.out.planDigest);
737
+ });
738
+
739
+ it("a preview that CloudFormation fails after creating the change set deletes the change set and the empty stack, and stays a hole", async () => {
740
+ const mock = createMockCloudExecutor({ stacks: { net: { missing: true, changes: [driven("SubnetId")] } } });
741
+ const create = mock.executor.cloudformation.createChangeSet;
742
+ mock.executor.cloudformation.createChangeSet = async (args) => ({ ...(await create(args)), status: "FAILED", statusReason: "Parameter value for SubnetId is not valid", changes: [] });
743
+ const out = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, input());
744
+ expect(out.artifact).toBeUndefined();
745
+ expect(out.changeSet!.member.holes[0]!.reason).toMatch(/no preview: CloudFormation could not create the change set with stand-in values: Parameter value for SubnetId is not valid/);
746
+ expect(methods(mock)).toEqual(["createChangeSet", "deleteChangeSet", "deleteStackShell"]);
747
+ });
748
+
749
+ it("a preview that throws while reading the change set still deletes the change set and the empty stack", async () => {
750
+ const mock = createMockCloudExecutor({ stacks: { net: { missing: true, changes: [driven("SubnetId")] } } });
751
+ const create = mock.executor.cloudformation.createChangeSet;
752
+ mock.executor.cloudformation.createChangeSet = async (args) => {
753
+ const cs = await create(args);
754
+ return { ...cs, get changes(): never { throw new Error("could not read the change set"); } } as never;
755
+ };
756
+ await expect(createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, input())).rejects.toThrow(/could not read the change set/);
757
+ expect(methods(mock)).toEqual(["createChangeSet", "deleteChangeSet", "deleteStackShell"]);
758
+ });
759
+ });
@@ -54,6 +54,13 @@ export interface CfnChange {
54
54
  replacementMode?: "True" | "False" | "Conditional";
55
55
  /** Resource-specific detail, when the executor's changeset backend provides it (e.g. property/scope). */
56
56
  details?: string[];
57
+ /**
58
+ * What CloudFormation says drives each property change, when it says: the
59
+ * property, `ChangeSource` and `CausingEntity`. A property driven by a
60
+ * parameter reads `ParameterReference` with the parameter's name, which is
61
+ * how a preview marks it known after apply (#4419).
62
+ */
63
+ drivers?: Array<{ property: string; changeSource?: string; causingEntity?: string }>;
57
64
  /**
58
65
  * The logical ids of the nested stacks this change sits in, outermost
59
66
  * first (#3997). Absent for a change to the stack's own resources. Set when
@@ -461,7 +468,7 @@ export interface DescribedCfnChangeSet {
461
468
  LogicalResourceId: string;
462
469
  ResourceType: string;
463
470
  Replacement?: string;
464
- Details?: Array<{ Target?: { Name?: string } }>;
471
+ Details?: Array<{ Target?: { Name?: string }; ChangeSource?: string | null; CausingEntity?: string | null }>;
465
472
  /** Set on an `AWS::CloudFormation::Stack` change when the change set includes nested stacks: the nested change set's ARN. */
466
473
  ChangeSetId?: string | null;
467
474
  };
@@ -498,6 +505,15 @@ export async function readCfnChanges(
498
505
  replacement: rc.Replacement === "True",
499
506
  ...(rc.Replacement === "True" || rc.Replacement === "False" || rc.Replacement === "Conditional" ? { replacementMode: rc.Replacement } : {}),
500
507
  details: rc.Details?.map((d) => d.Target?.Name).filter((x): x is string => !!x),
508
+ ...(rc.Details?.some((d) => d.Target?.Name && (d.ChangeSource || d.CausingEntity))
509
+ ? {
510
+ drivers: rc.Details.filter((d) => d.Target?.Name).map((d) => ({
511
+ property: d.Target!.Name!,
512
+ ...(d.ChangeSource ? { changeSource: d.ChangeSource } : {}),
513
+ ...(d.CausingEntity ? { causingEntity: d.CausingEntity } : {}),
514
+ })),
515
+ }
516
+ : {}),
501
517
  ...(nestedStack.length > 0 ? { nestedStack } : {}),
502
518
  });
503
519
  if (rc.ResourceType === "AWS::CloudFormation::Stack" && rc.ChangeSetId) {