@intentius/chant-lexicon-aws 0.124.51 → 0.124.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/codegen/generate-lexicon.d.ts +2 -0
  2. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  3. package/dist/codegen/generate-lexicon.js +1 -0
  4. package/dist/components/__tests__/mock-cloud-executor.d.ts +4 -0
  5. package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -1
  6. package/dist/components/__tests__/mock-cloud-executor.js +18 -1
  7. package/dist/components/apply.d.ts +76 -3
  8. package/dist/components/apply.d.ts.map +1 -1
  9. package/dist/components/apply.js +488 -32
  10. package/dist/components/cfn-adopt.d.ts +99 -0
  11. package/dist/components/cfn-adopt.d.ts.map +1 -0
  12. package/dist/components/cfn-adopt.js +278 -0
  13. package/dist/components/cfn-imports.d.ts +84 -0
  14. package/dist/components/cfn-imports.d.ts.map +1 -0
  15. package/dist/components/cfn-imports.js +239 -0
  16. package/dist/components/cloud-executor.d.ts +44 -2
  17. package/dist/components/cloud-executor.d.ts.map +1 -1
  18. package/dist/components/cloud-executor.js +73 -2
  19. package/dist/importable.d.ts +30 -0
  20. package/dist/importable.d.ts.map +1 -0
  21. package/dist/importable.js +72 -0
  22. package/dist/index-identity.d.ts +19 -0
  23. package/dist/index-identity.d.ts.map +1 -0
  24. package/dist/index-identity.js +79 -0
  25. package/dist/index-import.d.ts +17 -0
  26. package/dist/index-import.d.ts.map +1 -0
  27. package/dist/index-import.js +115 -0
  28. package/dist/integrity.json +3 -3
  29. package/dist/listed-types.d.ts +11 -0
  30. package/dist/listed-types.d.ts.map +1 -0
  31. package/dist/listed-types.js +52 -0
  32. package/dist/manifest.json +1 -1
  33. package/dist/meta.json +13206 -1237
  34. package/dist/plugin.d.ts.map +1 -1
  35. package/dist/plugin.js +9 -0
  36. package/dist/reference-catalog.d.ts.map +1 -1
  37. package/dist/reference-catalog.js +62 -20
  38. package/dist/spec/fetch.d.ts +5 -0
  39. package/dist/spec/fetch.d.ts.map +1 -1
  40. package/dist/spec/parse.d.ts +8 -0
  41. package/dist/spec/parse.d.ts.map +1 -1
  42. package/dist/spec/parse.js +3 -0
  43. package/package.json +2 -2
  44. package/src/codegen/generate-lexicon.ts +3 -0
  45. package/src/components/__tests__/mock-cloud-executor.ts +20 -1
  46. package/src/components/apply.ts +546 -33
  47. package/src/components/cfn-adopt.test.ts +284 -0
  48. package/src/components/cfn-adopt.ts +340 -0
  49. package/src/components/cfn-exports-in-use.test.ts +146 -0
  50. package/src/components/cfn-imports-preview.test.ts +178 -0
  51. package/src/components/cfn-imports-three-stacks.test.ts +107 -0
  52. package/src/components/cfn-imports.test.ts +213 -0
  53. package/src/components/cfn-imports.ts +246 -0
  54. package/src/components/cfn-plan-floci.e2e.test.ts +53 -0
  55. package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
  56. package/src/components/cfn-plan.test.ts +328 -9
  57. package/src/components/cloud-executor.ts +119 -6
  58. package/src/components/demo-composed-stacks-floci.e2e.test.ts +235 -0
  59. package/src/derived-edges.test.ts +142 -0
  60. package/src/generated/lexicon-aws.json +13206 -1237
  61. package/src/importable.test.ts +86 -0
  62. package/src/importable.ts +83 -0
  63. package/src/index-identity.test.ts +134 -0
  64. package/src/index-identity.ts +98 -0
  65. package/src/index-import.test.ts +242 -0
  66. package/src/index-import.ts +140 -0
  67. package/src/index-search.test.ts +87 -0
  68. package/src/listed-types.test.ts +97 -0
  69. package/src/listed-types.ts +56 -0
  70. package/src/plugin.ts +10 -0
  71. package/src/reference-catalog.ts +63 -20
  72. package/src/spec/fetch.ts +5 -0
  73. package/src/spec/parse.test.ts +15 -0
  74. package/src/spec/parse.ts +12 -0
  75. package/src/testdata/indexed/identity.listing.json +65 -0
  76. package/src/testdata/indexed/import.listing.json +126 -0
  77. package/src/testdata/indexed/listing.json +69 -0
  78. package/src/testdata/indexed/topology.listing.json +350 -0
  79. package/src/testdata/three-cloud/dns.graph.json +336 -0
  80. package/src/three-cloud-parity.test.ts +49 -0
@@ -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
@@ -74,8 +81,8 @@ export interface CfnChangeSet {
74
81
  statusReason?: string;
75
82
  /** True the first time this stack is created (no prior stack exists). */
76
83
  isCreate: boolean;
77
- /** The change set's type: CREATE for a stack that does not exist yet or was never executed, else UPDATE. */
78
- changeSetType?: "CREATE" | "UPDATE";
84
+ /** The change set's type: CREATE for a stack that does not exist yet or was never executed, else UPDATE; IMPORT for an adoption (#4536). */
85
+ changeSetType?: "CREATE" | "UPDATE" | "IMPORT";
79
86
  /**
80
87
  * True when no stack existed before this change set, so CloudFormation made
81
88
  * an empty `REVIEW_IN_PROGRESS` stack to hold it. A plan that deletes the
@@ -92,6 +99,18 @@ export interface CfnCreateChangeSetArgs {
92
99
  parameters?: Record<string, string>;
93
100
  }
94
101
 
102
+ /** One existing resource an IMPORT change set brings under a stack (#4536). */
103
+ export interface CfnResourceToImport {
104
+ resourceType: string;
105
+ logicalResourceId: string;
106
+ /** The type's primary identifier properties and their values (`{ BucketName: "logs" }`). */
107
+ resourceIdentifier: Record<string, string>;
108
+ }
109
+
110
+ export interface CfnCreateImportChangeSetArgs extends CfnCreateChangeSetArgs {
111
+ resourcesToImport: CfnResourceToImport[];
112
+ }
113
+
95
114
  export interface CfnExecuteChangeSetArgs {
96
115
  stackName: string;
97
116
  changeSetName: string;
@@ -100,11 +119,21 @@ export interface CfnExecuteChangeSetArgs {
100
119
  export interface CfnStackStatus {
101
120
  stackStatus: string;
102
121
  outputs: Record<string, string>;
122
+ /** The stack's exports by export name (#4466c): the output each is and its value. Absent when it exports nothing or the backend does not say. */
123
+ exports?: Record<string, { output: string; value: string }>;
103
124
  }
104
125
 
105
126
  export interface CloudFormationClient {
106
127
  /** Create (but do not execute) a changeset, returning its proposed changes for preview/safety checks. */
107
128
  createChangeSet(args: CfnCreateChangeSetArgs): Promise<CfnChangeSet>;
129
+ /**
130
+ * Create (but do not execute) an IMPORT change set that brings existing
131
+ * resources under the stack (#4536, chant's CloudFormation adoption). A
132
+ * stack that does not exist yet is created by it. No stack tags are sent:
133
+ * chant's markers are written by the UPDATE that follows. Optional: a
134
+ * client without it cannot adopt.
135
+ */
136
+ createImportChangeSet?(args: CfnCreateImportChangeSetArgs): Promise<CfnChangeSet>;
108
137
  /** Execute a previously created changeset. */
109
138
  executeChangeSet(args: CfnExecuteChangeSetArgs): Promise<void>;
110
139
  /** Delete a changeset without executing it (used when a safety policy blocks the apply). */
@@ -122,6 +151,12 @@ export interface CloudFormationClient {
122
151
  * empty stack, and the next CREATE change set reuses it.
123
152
  */
124
153
  deleteStackShell?(stackName: string): Promise<boolean>;
154
+ /**
155
+ * The stacks that import `exportName` now (`ListImports`, #4466c), sorted;
156
+ * empty when none does. Optional: without it, a plan cannot say whether a
157
+ * change to an export is in use, and refuses nothing.
158
+ */
159
+ listImports?(exportName: string): Promise<string[]>;
125
160
  }
126
161
 
127
162
  // ── ECS ──────────────────────────────────────────────────────────────────��─
@@ -461,7 +496,7 @@ export interface DescribedCfnChangeSet {
461
496
  LogicalResourceId: string;
462
497
  ResourceType: string;
463
498
  Replacement?: string;
464
- Details?: Array<{ Target?: { Name?: string } }>;
499
+ Details?: Array<{ Target?: { Name?: string }; ChangeSource?: string | null; CausingEntity?: string | null }>;
465
500
  /** Set on an `AWS::CloudFormation::Stack` change when the change set includes nested stacks: the nested change set's ARN. */
466
501
  ChangeSetId?: string | null;
467
502
  };
@@ -498,6 +533,15 @@ export async function readCfnChanges(
498
533
  replacement: rc.Replacement === "True",
499
534
  ...(rc.Replacement === "True" || rc.Replacement === "False" || rc.Replacement === "Conditional" ? { replacementMode: rc.Replacement } : {}),
500
535
  details: rc.Details?.map((d) => d.Target?.Name).filter((x): x is string => !!x),
536
+ ...(rc.Details?.some((d) => d.Target?.Name && (d.ChangeSource || d.CausingEntity))
537
+ ? {
538
+ drivers: rc.Details.filter((d) => d.Target?.Name).map((d) => ({
539
+ property: d.Target!.Name!,
540
+ ...(d.ChangeSource ? { changeSource: d.ChangeSource } : {}),
541
+ ...(d.CausingEntity ? { causingEntity: d.CausingEntity } : {}),
542
+ })),
543
+ }
544
+ : {}),
501
545
  ...(nestedStack.length > 0 ? { nestedStack } : {}),
502
546
  });
503
547
  if (rc.ResourceType === "AWS::CloudFormation::Stack" && rc.ChangeSetId) {
@@ -561,6 +605,53 @@ const realCloudFormation: CloudFormationClient = {
561
605
  changes: await readCfnChanges(described, describeNested),
562
606
  };
563
607
  },
608
+ async createImportChangeSet(args) {
609
+ const changeSetName = `cs-import-${Date.now()}`;
610
+ const params = Object.entries(args.parameters ?? {})
611
+ .map(([k, v]) => `ParameterKey=${k},ParameterValue=${v}`)
612
+ .join(" ");
613
+ const paramFlag = params ? ` --parameters ${params}` : "";
614
+ let body = "";
615
+ try {
616
+ body = readFileSync(args.templatePath, "utf8");
617
+ } catch { /* unreadable template — create-change-set reports it */ }
618
+ const capabilities = awsDeployCapabilitiesForBody(body).join(" ");
619
+ const { existed } = await cfnChangeSetType(args.stackName);
620
+ const dir = mkdtempSync(join(tmpdir(), "chant-cfn-import-"));
621
+ try {
622
+ const resourcesFile = join(dir, "resources-to-import.json");
623
+ writeFileSync(
624
+ resourcesFile,
625
+ JSON.stringify(
626
+ args.resourcesToImport.map((r) => ({ ResourceType: r.resourceType, LogicalResourceId: r.logicalResourceId, ResourceIdentifier: r.resourceIdentifier })),
627
+ ),
628
+ );
629
+ await run(
630
+ `aws cloudformation create-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)} ` +
631
+ `--change-set-type IMPORT --resources-to-import file://${resourcesFile} ` +
632
+ `--template-body file://${args.templatePath} --capabilities ${capabilities}${paramFlag}`,
633
+ );
634
+ } finally {
635
+ rmSync(dir, { recursive: true, force: true });
636
+ }
637
+ await run(
638
+ `aws cloudformation wait change-set-create-complete --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)}`,
639
+ ).catch(() => undefined);
640
+ const { stdout } = await run(
641
+ `aws cloudformation describe-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)}`,
642
+ );
643
+ const described = JSON.parse(stdout) as DescribedCfnChangeSet;
644
+ return {
645
+ changeSetName,
646
+ stackName: args.stackName,
647
+ status: described.Status,
648
+ statusReason: described.StatusReason ?? undefined,
649
+ isCreate: false,
650
+ changeSetType: "IMPORT",
651
+ stackCreated: !existed,
652
+ changes: await readCfnChanges(described, async () => ({ Status: "CREATE_COMPLETE" })),
653
+ };
654
+ },
564
655
  async executeChangeSet(args) {
565
656
  await run(
566
657
  `aws cloudformation execute-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(args.changeSetName)}`,
@@ -574,12 +665,34 @@ const realCloudFormation: CloudFormationClient = {
574
665
  async describeStack(stackName) {
575
666
  const { stdout } = await run(`aws cloudformation describe-stacks --stack-name ${q(stackName)}`);
576
667
  const described = JSON.parse(stdout) as {
577
- Stacks: Array<{ StackStatus: string; Outputs?: Array<{ OutputKey: string; OutputValue: string }> }>;
668
+ Stacks: Array<{ StackStatus: string; Outputs?: Array<{ OutputKey: string; OutputValue: string; ExportName?: string }> }>;
578
669
  };
579
670
  const stack = described.Stacks[0];
580
671
  const outputs: Record<string, string> = {};
581
- for (const o of stack?.Outputs ?? []) outputs[o.OutputKey] = o.OutputValue;
582
- return { stackStatus: stack?.StackStatus ?? "UNKNOWN", outputs };
672
+ const exports: Record<string, { output: string; value: string }> = {};
673
+ for (const o of stack?.Outputs ?? []) {
674
+ outputs[o.OutputKey] = o.OutputValue;
675
+ if (o.ExportName) exports[o.ExportName] = { output: o.OutputKey, value: o.OutputValue };
676
+ }
677
+ return { stackStatus: stack?.StackStatus ?? "UNKNOWN", outputs, ...(Object.keys(exports).length > 0 ? { exports } : {}) };
678
+ },
679
+ async listImports(exportName) {
680
+ const stacks: string[] = [];
681
+ let token: string | undefined;
682
+ do {
683
+ let stdout: string;
684
+ try {
685
+ ({ stdout } = await run(`aws cloudformation list-imports --export-name ${q(exportName)}${token ? ` --next-token ${q(token)}` : ""}`));
686
+ } catch (err) {
687
+ // CloudFormation answers an export nobody imports with an error, not an empty list.
688
+ if (/is not imported by any stack/i.test(err instanceof Error ? err.message : String(err))) return [];
689
+ throw err;
690
+ }
691
+ const page = JSON.parse(stdout) as { Imports?: string[]; NextToken?: string };
692
+ stacks.push(...(page.Imports ?? []));
693
+ token = page.NextToken;
694
+ } while (token);
695
+ return [...new Set(stacks)].sort();
583
696
  },
584
697
  async waitForStack(stackName, opts) {
585
698
  const intervalMs = opts?.intervalMs ?? 10_000;