@intentius/chant-lexicon-aws 0.124.52 → 0.124.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) 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 +44 -2
  8. package/dist/components/apply.d.ts.map +1 -1
  9. package/dist/components/apply.js +260 -29
  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 +31 -2
  17. package/dist/components/cloud-executor.d.ts.map +1 -1
  18. package/dist/components/cloud-executor.js +64 -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 +282 -29
  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-shape-conformance.test.ts +83 -0
  55. package/src/components/cloud-executor.ts +102 -5
  56. package/src/components/demo-composed-stacks-floci.e2e.test.ts +235 -0
  57. package/src/derived-edges.test.ts +142 -0
  58. package/src/generated/lexicon-aws.json +13206 -1237
  59. package/src/importable.test.ts +86 -0
  60. package/src/importable.ts +83 -0
  61. package/src/index-identity.test.ts +134 -0
  62. package/src/index-identity.ts +98 -0
  63. package/src/index-import.test.ts +242 -0
  64. package/src/index-import.ts +140 -0
  65. package/src/index-search.test.ts +87 -0
  66. package/src/listed-types.test.ts +97 -0
  67. package/src/listed-types.ts +56 -0
  68. package/src/plugin.ts +10 -0
  69. package/src/reference-catalog.ts +63 -20
  70. package/src/spec/fetch.ts +5 -0
  71. package/src/spec/parse.test.ts +15 -0
  72. package/src/spec/parse.ts +12 -0
  73. package/src/testdata/indexed/identity.listing.json +65 -0
  74. package/src/testdata/indexed/import.listing.json +126 -0
  75. package/src/testdata/indexed/listing.json +69 -0
  76. package/src/testdata/indexed/topology.listing.json +350 -0
  77. package/src/testdata/three-cloud/dns.graph.json +336 -0
  78. package/src/three-cloud-parity.test.ts +49 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * #4536: CloudFormation adoption over a fake CloudFormation client. The
3
+ * IMPORT change set carries no markers and no stack tags, the UPDATE adds
4
+ * them and must be tags only, each change set goes through the review, and
5
+ * the plan refuses create-only tags, a type CloudFormation cannot import, a
6
+ * resource with no DeletionPolicy, an extra resource and a stale adoption
7
+ * file. Nothing reaches a cloud; the floci run is a maintainer run.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { describe, expect, test } from "vitest";
11
+ import { indexDocumentDigest, indexScopeKey, indexVersionName, type CloudIndexScope, type IndexDocument } from "@intentius/chant/lifecycle/index-document";
12
+ import type { AdoptionFile } from "@intentius/chant/lifecycle/adoption-file";
13
+ import type { ImportabilityManifestEntry } from "../importable";
14
+ import {
15
+ adoptIntoStack,
16
+ cfnMarkerReview,
17
+ cfnResourceIdentifier,
18
+ markerChangeSetProblems,
19
+ planCfnAdoption,
20
+ type CfnAdoptionReview,
21
+ type CfnTemplate,
22
+ } from "./cfn-adopt";
23
+ import type { CfnChange, CfnChangeSet, CfnCreateChangeSetArgs, CfnCreateImportChangeSetArgs, CloudFormationClient } from "./cloud-executor";
24
+
25
+ const SCOPE: CloudIndexScope = { kind: "cloud", substrate: "aws", scopeId: "000000000000", locations: ["us-east-1"], types: "*" };
26
+ const BUCKET = "aws/000000000000/us-east-1/AWS::S3::Bucket/web-logs";
27
+ const KEYPAIR = "aws/000000000000/us-east-1/AWS::EC2::KeyPair/deploy";
28
+ const ROUTE = "aws/000000000000/us-east-1/AWS::EC2::Route/rtb-1|0.0.0.0/0";
29
+ const graph = {
30
+ version: 1 as const,
31
+ nodes: [
32
+ { id: BUCKET, kind: "AWS::S3::Bucket", lexicon: "aws", attrs: { BucketName: "web-logs" }, listedType: "AWS::S3::Bucket", location: "us-east-1", nativeId: "web-logs" },
33
+ { id: KEYPAIR, kind: "AWS::EC2::KeyPair", lexicon: "aws", attrs: { KeyName: "deploy" }, listedType: "AWS::EC2::KeyPair", location: "us-east-1", nativeId: "deploy" },
34
+ { id: ROUTE, kind: "AWS::EC2::Route", lexicon: "aws", attrs: {}, listedType: "AWS::EC2::Route", location: "us-east-1", nativeId: "rtb-1|0.0.0.0/0" },
35
+ ],
36
+ edges: [],
37
+ groups: {},
38
+ };
39
+ const INDEX: IndexDocument = {
40
+ index: "v1",
41
+ stamp: { source: { kind: "cloud", name: "behold-lister" }, scope: SCOPE, scopeKey: indexScopeKey(SCOPE), observedAt: "2026-10-26T12:00:00Z", digest: indexDocumentDigest({ graph, coverage: [] }) },
42
+ coverage: [],
43
+ graph,
44
+ };
45
+ const ALL = ["create", "delete", "list", "read", "update"];
46
+ const MANIFEST = new Map<string, ImportabilityManifestEntry>([
47
+ ["AWS::S3::Bucket", { resourceType: "AWS::S3::Bucket", kind: "resource", handlers: ALL, primaryIdentifier: ["BucketName"], tagging: { taggable: true, tagUpdatable: true, tagProperty: "Tags", tagShape: "list" } }],
48
+ ["AWS::EC2::KeyPair", { resourceType: "AWS::EC2::KeyPair", kind: "resource", handlers: ALL, primaryIdentifier: ["KeyName"], tagging: { taggable: true, tagUpdatable: false, tagProperty: "Tags", tagShape: "list" } }],
49
+ ["AWS::EC2::Route", { resourceType: "AWS::EC2::Route", kind: "resource", handlers: ALL, primaryIdentifier: ["RouteTableId", "DestinationCidrBlock"] }],
50
+ ["AWS::IAM::Policy", { resourceType: "AWS::IAM::Policy", kind: "resource", handlers: ["create", "delete", "update"], primaryIdentifier: ["Id"] }],
51
+ ]);
52
+ const MARKERS = [
53
+ { Key: "chant:managed-by", Value: "chant" },
54
+ { Key: "chant:stack", Value: "web" },
55
+ { Key: "chant:env", Value: "prod" },
56
+ ];
57
+
58
+ const adoption = (select: AdoptionFile["select"]): AdoptionFile => ({
59
+ adopt: "v1",
60
+ index: { scopeKey: INDEX.stamp.scopeKey, version: indexVersionName(INDEX.stamp.observedAt, INDEX.stamp.digest), digest: INDEX.stamp.digest },
61
+ select,
62
+ });
63
+ const template = (): CfnTemplate => ({
64
+ AWSTemplateFormatVersion: "2010-09-09",
65
+ Metadata: { "chant:ownership": { "chant:managed-by": "chant", "chant:stack": "web", "chant:env": "prod" } },
66
+ Resources: {
67
+ BucketWebLogs: {
68
+ Type: "AWS::S3::Bucket",
69
+ DeletionPolicy: "Retain",
70
+ UpdateReplacePolicy: "Retain",
71
+ Properties: { BucketName: "web-logs", Tags: [{ Key: "team", Value: "shop" }, ...MARKERS] },
72
+ },
73
+ },
74
+ });
75
+ const bucketOnly = () => adoption([{ node: BUCKET, as: "BucketWebLogs", type: "AWS::S3::Bucket" }]);
76
+
77
+ interface Fake {
78
+ cfn: CloudFormationClient;
79
+ calls: string[];
80
+ templates: Record<string, unknown>;
81
+ imports: CfnCreateImportChangeSetArgs[];
82
+ }
83
+
84
+ function fakeCfn(opts: { importChanges?: CfnChange[]; markerChanges?: CfnChange[]; markerNoop?: boolean; noImport?: boolean } = {}): Fake {
85
+ const calls: string[] = [];
86
+ const templates: Record<string, unknown> = {};
87
+ const imports: CfnCreateImportChangeSetArgs[] = [];
88
+ let status = "REVIEW_IN_PROGRESS";
89
+ let pending: "IMPORT" | "UPDATE" | undefined;
90
+ const cs = (name: string, changes: CfnChange[], extra: Partial<CfnChangeSet> = {}): CfnChangeSet => ({ changeSetName: name, stackName: "web", status: "CREATE_COMPLETE", isCreate: false, changes, ...extra });
91
+ const cfn: CloudFormationClient = {
92
+ async createImportChangeSet(args) {
93
+ calls.push("createImportChangeSet");
94
+ imports.push(args);
95
+ templates.import = JSON.parse(readFileSync(args.templatePath, "utf8"));
96
+ pending = "IMPORT";
97
+ const changes = opts.importChanges ?? args.resourcesToImport.map((r) => ({ action: "Import" as const, logicalResourceId: r.logicalResourceId, resourceType: r.resourceType, replacement: false }));
98
+ return cs("cs-import", changes, { changeSetType: "IMPORT" });
99
+ },
100
+ async createChangeSet(args: CfnCreateChangeSetArgs) {
101
+ calls.push("createChangeSet");
102
+ templates.markers = JSON.parse(readFileSync(args.templatePath, "utf8"));
103
+ pending = "UPDATE";
104
+ if (opts.markerNoop) return cs("cs-markers", [], { status: "FAILED", statusReason: "The submitted information didn't contain changes." });
105
+ return cs("cs-markers", opts.markerChanges ?? [{ action: "Modify", logicalResourceId: "BucketWebLogs", resourceType: "AWS::S3::Bucket", replacement: false, replacementMode: "False", details: ["Tags"] }]);
106
+ },
107
+ async executeChangeSet(args) {
108
+ calls.push(`execute:${args.changeSetName}`);
109
+ status = pending === "IMPORT" ? "IMPORT_COMPLETE" : "UPDATE_COMPLETE";
110
+ },
111
+ async deleteChangeSet(args) {
112
+ calls.push(`delete:${args.changeSetName}`);
113
+ },
114
+ async waitForStack() {
115
+ calls.push("waitForStack");
116
+ return { stackStatus: status, outputs: {} };
117
+ },
118
+ async describeStack() {
119
+ return { stackStatus: status, outputs: {} };
120
+ },
121
+ async rollbackStack() {},
122
+ };
123
+ if (opts.noImport) delete cfn.createImportChangeSet;
124
+ return { cfn, calls, templates, imports };
125
+ }
126
+
127
+ const approveAll = (seen: Array<{ step: string; lines: string[] }> = []): CfnAdoptionReview => async (step, _cs, lines) => {
128
+ seen.push({ step, lines });
129
+ return true;
130
+ };
131
+
132
+ describe("planCfnAdoption (#4536)", () => {
133
+ test("the IMPORT template drops the markers and the stack tags; the marker template keeps them", () => {
134
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
135
+ expect(plan.refusals).toEqual([]);
136
+ expect(plan.resources).toEqual([
137
+ {
138
+ node: BUCKET,
139
+ logicalId: "BucketWebLogs",
140
+ type: "AWS::S3::Bucket",
141
+ identifier: { BucketName: "web-logs" },
142
+ deletionPolicy: "Retain",
143
+ tagProperty: "Tags",
144
+ markers: { "chant:managed-by": "chant", "chant:stack": "web", "chant:env": "prod" },
145
+ },
146
+ ]);
147
+ expect(plan.importTemplate.Resources.BucketWebLogs!.Properties!.Tags).toEqual([{ Key: "team", Value: "shop" }]);
148
+ expect(plan.importTemplate.Metadata).toBeUndefined();
149
+ expect(plan.importTemplate.Resources.BucketWebLogs!.DeletionPolicy).toBe("Retain");
150
+ expect(plan.markerTemplate).toEqual(template());
151
+ });
152
+
153
+ test("a tag list of markers only is dropped from the IMPORT template", () => {
154
+ const t = template();
155
+ t.Resources.BucketWebLogs!.Properties!.Tags = [...MARKERS];
156
+ const plan = planCfnAdoption({ template: t, adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
157
+ expect(plan.importTemplate.Resources.BucketWebLogs!.Properties!.Tags).toBeUndefined();
158
+ });
159
+
160
+ test("create-only tags are refused by name, never replaced to mark them", () => {
161
+ const t = template();
162
+ t.Resources.KeyPairDeploy = { Type: "AWS::EC2::KeyPair", DeletionPolicy: "Retain", Properties: { KeyName: "deploy" } };
163
+ const plan = planCfnAdoption({
164
+ template: t,
165
+ adoption: adoption([{ node: BUCKET, as: "BucketWebLogs" }, { node: KEYPAIR, as: "KeyPairDeploy" }]),
166
+ index: INDEX,
167
+ manifest: MANIFEST,
168
+ });
169
+ expect(plan.refusals).toEqual(["KeyPairDeploy: AWS::EC2::KeyPair's tags can only be set at create, so writing chant's markers would replace it"]);
170
+ });
171
+
172
+ test("a resource with no DeletionPolicy, one already in the stack, and an extra resource are refused", () => {
173
+ const t = template();
174
+ delete t.Resources.BucketWebLogs!.DeletionPolicy;
175
+ t.Resources.NewQueue = { Type: "AWS::SQS::Queue", Properties: {} };
176
+ const plan = planCfnAdoption({ template: t, adoption: bucketOnly(), index: INDEX, manifest: MANIFEST, existing: new Set(["BucketWebLogs"]) });
177
+ expect(plan.refusals.join("\n")).toMatch(/BucketWebLogs declares no DeletionPolicy/);
178
+ expect(plan.refusals.join("\n")).toMatch(/BucketWebLogs is already in the stack/);
179
+ expect(plan.refusals.join("\n")).toMatch(/the template also creates NewQueue/);
180
+ });
181
+
182
+ test("a stale adoption file, a missing logical id and a type mismatch are refused", () => {
183
+ const stale = bucketOnly();
184
+ stale.index = { ...stale.index, digest: "0".repeat(64), version: indexVersionName(INDEX.stamp.observedAt, "0".repeat(64)) };
185
+ expect(planCfnAdoption({ template: template(), adoption: stale, index: INDEX, manifest: MANIFEST }).refusals.join("\n")).toMatch(/digest does not match/);
186
+ const missing = adoption([{ node: BUCKET, as: "Elsewhere" }]);
187
+ expect(planCfnAdoption({ template: template(), adoption: missing, index: INDEX, manifest: MANIFEST }).refusals.join("\n")).toMatch(/declares no resource Elsewhere/);
188
+ const wrongType = adoption([{ node: BUCKET, as: "BucketWebLogs", type: "aws_s3_bucket" }]);
189
+ expect(planCfnAdoption({ template: template(), adoption: wrongType, index: INDEX, manifest: MANIFEST }).refusals.join("\n")).toMatch(/names type aws_s3_bucket/);
190
+ });
191
+
192
+ test("a composite identifier splits on |", () => {
193
+ expect(cfnResourceIdentifier(["RouteTableId", "DestinationCidrBlock"], "rtb-1|0.0.0.0/0")).toEqual({ RouteTableId: "rtb-1", DestinationCidrBlock: "0.0.0.0/0" });
194
+ expect(cfnResourceIdentifier(["A", "B"], "only-one")).toBeUndefined();
195
+ });
196
+
197
+ test("the marker review lists each marker write as tags only", () => {
198
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
199
+ expect(cfnMarkerReview(plan)).toEqual(["marker write, tags only: BucketWebLogs (AWS::S3::Bucket) Tags += chant:env=prod, chant:managed-by=chant, chant:stack=web"]);
200
+ });
201
+ });
202
+
203
+ describe("adoptIntoStack (#4536)", () => {
204
+ test("IMPORT then the marker UPDATE, each reviewed before it executes", async () => {
205
+ const fake = fakeCfn();
206
+ const seen: Array<{ step: string; lines: string[] }> = [];
207
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
208
+ const out = await adoptIntoStack({ cfn: fake.cfn, stackName: "web", plan, review: approveAll(seen) });
209
+ expect(out).toMatchObject({ status: "adopted", importChangeSet: "cs-import", markerChangeSet: "cs-markers" });
210
+ expect(fake.calls).toEqual(["createImportChangeSet", "execute:cs-import", "waitForStack", "createChangeSet", "execute:cs-markers", "waitForStack"]);
211
+ expect(fake.imports[0]!.resourcesToImport).toEqual([{ resourceType: "AWS::S3::Bucket", logicalResourceId: "BucketWebLogs", resourceIdentifier: { BucketName: "web-logs" } }]);
212
+ expect(JSON.stringify(fake.templates.import)).not.toContain("chant:");
213
+ expect(JSON.stringify(fake.templates.markers)).toContain("chant:managed-by");
214
+ expect(seen.map((s) => s.step)).toEqual(["import", "markers"]);
215
+ expect(seen[0]!.lines).toEqual(["import BucketWebLogs (AWS::S3::Bucket) BucketName=web-logs, DeletionPolicy Retain"]);
216
+ expect(seen[1]!.lines[0]).toMatch(/^marker write, tags only: BucketWebLogs/);
217
+ });
218
+
219
+ test("a plan with refusals makes no change set", async () => {
220
+ const fake = fakeCfn();
221
+ const t = template();
222
+ t.Resources.KeyPairDeploy = { Type: "AWS::EC2::KeyPair", DeletionPolicy: "Retain", Properties: { KeyName: "deploy" } };
223
+ const plan = planCfnAdoption({ template: t, adoption: adoption([{ node: BUCKET, as: "BucketWebLogs" }, { node: KEYPAIR, as: "KeyPairDeploy" }]), index: INDEX, manifest: MANIFEST });
224
+ const out = await adoptIntoStack({ cfn: fake.cfn, stackName: "web", plan, review: approveAll() });
225
+ expect(out.status).toBe("refused");
226
+ expect(fake.calls).toEqual([]);
227
+ });
228
+
229
+ test("a declined import review deletes the change set and executes nothing", async () => {
230
+ const fake = fakeCfn();
231
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
232
+ const out = await adoptIntoStack({ cfn: fake.cfn, stackName: "web", plan, review: async () => false });
233
+ expect(out).toEqual({ status: "declined", step: "import" });
234
+ expect(fake.calls).toEqual(["createImportChangeSet", "delete:cs-import"]);
235
+ });
236
+
237
+ test("an IMPORT change set that would touch another resource is refused and deleted", async () => {
238
+ const fake = fakeCfn({
239
+ importChanges: [
240
+ { action: "Import", logicalResourceId: "BucketWebLogs", resourceType: "AWS::S3::Bucket", replacement: false },
241
+ { action: "Add", logicalResourceId: "Other", resourceType: "AWS::SQS::Queue", replacement: false },
242
+ ],
243
+ });
244
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
245
+ const out = await adoptIntoStack({ cfn: fake.cfn, stackName: "web", plan, review: approveAll() });
246
+ expect(out).toMatchObject({ status: "refused", step: "import" });
247
+ expect(fake.calls).toEqual(["createImportChangeSet", "delete:cs-import"]);
248
+ });
249
+
250
+ test("a marker UPDATE that changes more than tags, or replaces, is refused before it executes", async () => {
251
+ for (const change of [
252
+ { action: "Modify" as const, logicalResourceId: "BucketWebLogs", resourceType: "AWS::S3::Bucket", replacement: false, details: ["Tags", "VersioningConfiguration"] },
253
+ { action: "Modify" as const, logicalResourceId: "BucketWebLogs", resourceType: "AWS::S3::Bucket", replacement: true, replacementMode: "True" as const, details: ["Tags"] },
254
+ { action: "Add" as const, logicalResourceId: "Other", resourceType: "AWS::SQS::Queue", replacement: false },
255
+ ]) {
256
+ const fake = fakeCfn({ markerChanges: [change] });
257
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
258
+ const out = await adoptIntoStack({ cfn: fake.cfn, stackName: "web", plan, review: approveAll() });
259
+ expect(out).toMatchObject({ status: "refused", step: "markers" });
260
+ expect(fake.calls.slice(-2)).toEqual(["createChangeSet", "delete:cs-markers"]);
261
+ expect(fake.calls).not.toContain("execute:cs-markers");
262
+ }
263
+ });
264
+
265
+ test("a marker UPDATE with nothing to change ends the adoption without executing it", async () => {
266
+ const fake = fakeCfn({ markerNoop: true });
267
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
268
+ const out = await adoptIntoStack({ cfn: fake.cfn, stackName: "web", plan, review: approveAll() });
269
+ expect(out).toMatchObject({ status: "adopted", importChangeSet: "cs-import" });
270
+ expect(fake.calls).toEqual(["createImportChangeSet", "execute:cs-import", "waitForStack", "createChangeSet", "delete:cs-markers"]);
271
+ });
272
+
273
+ test("a client that cannot create an IMPORT change set is refused", async () => {
274
+ const fake = fakeCfn({ noImport: true });
275
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
276
+ expect(await adoptIntoStack({ cfn: fake.cfn, stackName: "web", plan, review: approveAll() })).toMatchObject({ status: "refused", step: "plan" });
277
+ });
278
+
279
+ test("markerChangeSetProblems accepts a tags-only Modify on an imported resource", () => {
280
+ const plan = planCfnAdoption({ template: template(), adoption: bucketOnly(), index: INDEX, manifest: MANIFEST });
281
+ const cs: CfnChangeSet = { changeSetName: "x", stackName: "web", status: "CREATE_COMPLETE", isCreate: false, changes: [{ action: "Modify", logicalResourceId: "BucketWebLogs", resourceType: "AWS::S3::Bucket", replacement: false, details: ["Tags"] }] };
282
+ expect(markerChangeSetProblems(cs, plan)).toEqual([]);
283
+ });
284
+ });
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Adopting indexed resources into a CloudFormation stack (#4536; design and
3
+ * ruling on INTENTIUS/choudoufu#2284, 2026-10-26).
4
+ *
5
+ * The aws lexicon deploys CREATE and UPDATE change sets. A CREATE of a
6
+ * template that declares an existing bucket fails, so adoption takes two
7
+ * change sets:
8
+ *
9
+ * 1. IMPORT, with the template's markers left out. CloudFormation import
10
+ * brings a resource under the stack without applying the template's
11
+ * property values, so markers in the import template would never be
12
+ * written, and the next UPDATE would then see no change and not write them
13
+ * either. The import template also carries no stack tags.
14
+ * 2. UPDATE, with the template as built: it adds chant's marker tags to each
15
+ * imported resource and the stack's own tags. It must be tags only: a
16
+ * change to anything but the tag property, a replace, an add or a remove
17
+ * refuses the run before it executes.
18
+ *
19
+ * The gate is chant's change-set review (Q3): each change set is shown before
20
+ * it is executed, through the caller's `review`, and the marker review lists
21
+ * each marker write as tags only. Nothing here asks a question of its own.
22
+ *
23
+ * Refused before any change set is made ({@link planCfnAdoption}): an
24
+ * adoption file that does not match its index version, a node the template
25
+ * does not declare under the file's `as`, a type CloudFormation cannot import
26
+ * or whose tags can only be set at create (../importable.ts), a resource with
27
+ * no DeletionPolicy (import needs one; `chant import --index` writes Retain,
28
+ * Q4), a resource already in the stack, and a template that would also create
29
+ * a resource. The index proposes; the live read decides: CloudFormation reads
30
+ * each object again when it imports it, by the identifier given here.
31
+ */
32
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
35
+ import { adoptionFileProblemsAgainst, type AdoptionFile } from "@intentius/chant/lifecycle/adoption-file";
36
+ import type { IndexDocument } from "@intentius/chant/lifecycle/index-document";
37
+ import { adoptionRefusal, importabilityManifest, type ImportabilityManifestEntry } from "../importable";
38
+ import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "../ownership";
39
+ import { isNoopChangeSet } from "./apply";
40
+ import type { CfnChange, CfnChangeSet, CfnResourceToImport, CloudFormationClient } from "./cloud-executor";
41
+
42
+ /** A CloudFormation template as chant builds it. */
43
+ export interface CfnTemplate {
44
+ Metadata?: Record<string, unknown>;
45
+ Resources: Record<string, { Type: string; Properties?: Record<string, unknown>; DeletionPolicy?: string; UpdateReplacePolicy?: string; [key: string]: unknown }>;
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ /** One resource the adoption brings under the stack. */
50
+ export interface CfnAdoptedResource {
51
+ node: string;
52
+ logicalId: string;
53
+ type: string;
54
+ identifier: Record<string, string>;
55
+ deletionPolicy: string;
56
+ /** The property the markers are written to, when the type has one chant can stamp. */
57
+ tagProperty?: string;
58
+ /** The marker tags the UPDATE writes. Empty for a type chant cannot tag. */
59
+ markers: Record<string, string>;
60
+ }
61
+
62
+ export interface CfnAdoptionPlan {
63
+ /** Why the adoption cannot run, each by name. Empty when it can. */
64
+ refusals: string[];
65
+ resources: CfnAdoptedResource[];
66
+ /** The IMPORT change set's template: the built one without markers or stack tags. */
67
+ importTemplate: CfnTemplate;
68
+ /** The UPDATE change set's template: the built one. */
69
+ markerTemplate: CfnTemplate;
70
+ }
71
+
72
+ export interface PlanCfnAdoptionInput {
73
+ /** The built template of the stack the resources join, from the source `chant import --index` wrote. */
74
+ template: CfnTemplate;
75
+ adoption: AdoptionFile;
76
+ /** The index version the adoption file names. */
77
+ index: IndexDocument;
78
+ /** Logical ids already in the stack (empty for a new stack). */
79
+ existing?: ReadonlySet<string>;
80
+ manifest?: ReadonlyMap<string, ImportabilityManifestEntry>;
81
+ }
82
+
83
+ const MARKER_KEYS: ReadonlySet<string> = new Set(Object.values(AWS_TAG_OWNERSHIP_KEYS));
84
+
85
+ /** The marker tags in a tag property's value, and the value without them. */
86
+ function splitMarkers(tags: unknown, shape: "list" | "map" | undefined): { markers: Record<string, string>; rest: unknown } {
87
+ const markers: Record<string, string> = {};
88
+ if (Array.isArray(tags) && shape !== "map") {
89
+ const rest = tags.filter((t) => {
90
+ const key = (t as { Key?: unknown })?.Key;
91
+ const value = (t as { Value?: unknown })?.Value;
92
+ if (typeof key === "string" && MARKER_KEYS.has(key)) {
93
+ if (typeof value === "string") markers[key] = value;
94
+ return false;
95
+ }
96
+ return true;
97
+ });
98
+ return { markers, rest };
99
+ }
100
+ if (tags && typeof tags === "object" && !Array.isArray(tags)) {
101
+ const rest: Record<string, unknown> = {};
102
+ for (const [k, v] of Object.entries(tags as Record<string, unknown>)) {
103
+ if (MARKER_KEYS.has(k)) {
104
+ if (typeof v === "string") markers[k] = v;
105
+ } else rest[k] = v;
106
+ }
107
+ return { markers, rest };
108
+ }
109
+ return { markers, rest: tags };
110
+ }
111
+
112
+ const isEmpty = (v: unknown): boolean =>
113
+ v === undefined || (Array.isArray(v) && v.length === 0) || (typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0);
114
+
115
+ /** The import identifier: the type's primary identifier properties zipped with the native id split on `|`. */
116
+ export function cfnResourceIdentifier(primaryIdentifier: readonly string[], nativeId: string): Record<string, string> | undefined {
117
+ const parts = nativeId.split("|");
118
+ if (primaryIdentifier.length === 0 || parts.length !== primaryIdentifier.length) return undefined;
119
+ return Object.fromEntries(primaryIdentifier.map((p, i) => [p, parts[i]!]));
120
+ }
121
+
122
+ /** Plan an adoption. Pure: reads nothing from the cloud. */
123
+ export function planCfnAdoption(input: PlanCfnAdoptionInput): CfnAdoptionPlan {
124
+ const manifest = input.manifest ?? importabilityManifest();
125
+ const existing = input.existing ?? new Set<string>();
126
+ const refusals = adoptionFileProblemsAgainst(input.adoption, input.index);
127
+ const nodes = new Map(input.index.graph.nodes.map((n) => [n.id, n]));
128
+ const importTemplate = JSON.parse(JSON.stringify(input.template)) as CfnTemplate;
129
+ const markerTemplate = JSON.parse(JSON.stringify(input.template)) as CfnTemplate;
130
+ const resources: CfnAdoptedResource[] = [];
131
+ const adopted = new Set<string>();
132
+
133
+ for (const sel of input.adoption.select ?? []) {
134
+ const node = nodes.get(sel.node);
135
+ if (!node) continue; // adoptionFileProblemsAgainst names it
136
+ const logicalId = sel.as;
137
+ adopted.add(logicalId);
138
+ const resource = input.template.Resources?.[logicalId];
139
+ if (!resource) {
140
+ refusals.push(`${sel.node}: the template declares no resource ${logicalId}`);
141
+ continue;
142
+ }
143
+ const type = resource.Type;
144
+ if (type !== node.kind) refusals.push(`${logicalId}: the template declares ${type}, but ${sel.node} is ${node.kind}`);
145
+ if (sel.type !== undefined && sel.type !== type) refusals.push(`${logicalId}: the adoption file names type ${sel.type}, but the template declares ${type}`);
146
+ const refusal = adoptionRefusal(type, manifest);
147
+ if (refusal) refusals.push(`${logicalId}: ${refusal}`);
148
+ if (existing.has(logicalId)) refusals.push(`${logicalId} is already in the stack; nothing to import`);
149
+ const deletionPolicy = resource.DeletionPolicy;
150
+ if (typeof deletionPolicy !== "string") {
151
+ refusals.push(`${logicalId} declares no DeletionPolicy; CloudFormation import needs one (chant import --index writes Retain)`);
152
+ }
153
+ const entry = manifest.get(type);
154
+ const identifier = cfnResourceIdentifier(entry?.primaryIdentifier ?? [], node.nativeId ?? "");
155
+ if (!identifier) {
156
+ refusals.push(
157
+ `${logicalId}: native id "${node.nativeId ?? ""}" does not split into ${type}'s primary identifier (${(entry?.primaryIdentifier ?? []).join(", ") || "none"})`,
158
+ );
159
+ }
160
+ const tagProperty = entry?.tagging?.taggable ? entry.tagging.tagProperty : undefined;
161
+ let markers: Record<string, string> = {};
162
+ if (tagProperty) {
163
+ const props = importTemplate.Resources[logicalId]!.Properties ?? {};
164
+ const split = splitMarkers(props[tagProperty], entry?.tagging?.tagShape);
165
+ markers = split.markers;
166
+ if (isEmpty(split.rest)) delete props[tagProperty];
167
+ else props[tagProperty] = split.rest;
168
+ importTemplate.Resources[logicalId]!.Properties = props;
169
+ }
170
+ resources.push({
171
+ node: sel.node,
172
+ logicalId,
173
+ type,
174
+ identifier: identifier ?? {},
175
+ deletionPolicy: typeof deletionPolicy === "string" ? deletionPolicy : "",
176
+ ...(tagProperty ? { tagProperty } : {}),
177
+ markers,
178
+ });
179
+ }
180
+
181
+ for (const id of Object.keys(input.template.Resources ?? {}).sort()) {
182
+ if (!adopted.has(id) && !existing.has(id)) {
183
+ refusals.push(`the template also creates ${id}; adopt first, then add it in a later deploy (the marker UPDATE must be tags only)`);
184
+ }
185
+ }
186
+
187
+ // No stack tags on the IMPORT: the UPDATE writes them with the resource markers.
188
+ if (importTemplate.Metadata && OWNERSHIP_METADATA_KEY in importTemplate.Metadata) {
189
+ delete importTemplate.Metadata[OWNERSHIP_METADATA_KEY];
190
+ if (Object.keys(importTemplate.Metadata).length === 0) delete importTemplate.Metadata;
191
+ }
192
+
193
+ resources.sort((a, b) => (a.logicalId < b.logicalId ? -1 : a.logicalId > b.logicalId ? 1 : 0));
194
+ return { refusals, resources, importTemplate, markerTemplate };
195
+ }
196
+
197
+ /** What the IMPORT change set's review shows, one line per resource. */
198
+ export function cfnImportReview(plan: CfnAdoptionPlan): string[] {
199
+ return plan.resources.map(
200
+ (r) =>
201
+ `import ${r.logicalId} (${r.type}) ${Object.entries(r.identifier).map(([k, v]) => `${k}=${v}`).join(" ")}, DeletionPolicy ${r.deletionPolicy}`,
202
+ );
203
+ }
204
+
205
+ /** What the marker UPDATE's review shows: each marker write, as tags only. */
206
+ export function cfnMarkerReview(plan: CfnAdoptionPlan): string[] {
207
+ return plan.resources.map((r) =>
208
+ Object.keys(r.markers).length > 0 && r.tagProperty
209
+ ? `marker write, tags only: ${r.logicalId} (${r.type}) ${r.tagProperty} += ${Object.entries(r.markers)
210
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
211
+ .map(([k, v]) => `${k}=${v}`)
212
+ .join(", ")}`
213
+ : r.tagProperty
214
+ ? `no marker: ${r.logicalId} (${r.type}) carries no chant marker in the template; the stack owns it on its template alone`
215
+ : `no marker: ${r.logicalId} (${r.type}) has no tags chant can stamp; the stack owns it on its template alone`,
216
+ );
217
+ }
218
+
219
+ /** Why an IMPORT change set is not the one planned, or empty. */
220
+ export function importChangeSetProblems(changeSet: CfnChangeSet, plan: CfnAdoptionPlan): string[] {
221
+ const problems: string[] = [];
222
+ if (changeSet.status === "FAILED") problems.push(`the IMPORT change set failed: ${changeSet.statusReason ?? "no reason given"}`);
223
+ const want = new Set(plan.resources.map((r) => r.logicalId));
224
+ const seen = new Set<string>();
225
+ for (const c of changeSet.changes) {
226
+ if (c.action !== "Import" || !want.has(c.logicalResourceId)) {
227
+ problems.push(`the IMPORT change set would ${c.action.toLowerCase()} ${c.logicalResourceId} (${c.resourceType}), which the adoption does not import`);
228
+ }
229
+ seen.add(c.logicalResourceId);
230
+ }
231
+ for (const id of want) if (!seen.has(id) && changeSet.status !== "FAILED") problems.push(`the IMPORT change set does not import ${id}`);
232
+ return problems;
233
+ }
234
+
235
+ function changeReplaces(c: CfnChange): boolean {
236
+ return c.replacement || c.replacementMode === "True" || c.replacementMode === "Conditional";
237
+ }
238
+
239
+ /** Why the marker UPDATE is not tags only on the imported resources, or empty. */
240
+ export function markerChangeSetProblems(changeSet: CfnChangeSet, plan: CfnAdoptionPlan): string[] {
241
+ const problems: string[] = [];
242
+ if (changeSet.status === "FAILED" && !isNoopChangeSet(changeSet)) {
243
+ problems.push(`the marker change set failed: ${changeSet.statusReason ?? "no reason given"}`);
244
+ }
245
+ const byId = new Map(plan.resources.map((r) => [r.logicalId, r]));
246
+ for (const c of changeSet.changes) {
247
+ const r = byId.get(c.logicalResourceId);
248
+ if (c.action !== "Modify" || !r) {
249
+ problems.push(`the marker change set would ${c.action.toLowerCase()} ${c.logicalResourceId} (${c.resourceType}); it may only add tags to the imported resources`);
250
+ continue;
251
+ }
252
+ if (changeReplaces(c)) problems.push(`the marker change set would replace ${c.logicalResourceId} (${c.resourceType})`);
253
+ const other = (c.details ?? []).filter((d) => d !== r.tagProperty);
254
+ if (other.length > 0) problems.push(`the marker change set changes ${c.logicalResourceId}'s ${other.join(", ")}, not only its tags`);
255
+ }
256
+ return problems;
257
+ }
258
+
259
+ /** One change-set review: the step, the change set and what it writes. Approving executes it. */
260
+ export type CfnAdoptionReview = (step: "import" | "markers", changeSet: CfnChangeSet, lines: string[]) => Promise<boolean>;
261
+
262
+ export type CfnAdoptionOutcome =
263
+ | { status: "adopted"; resources: CfnAdoptedResource[]; importChangeSet: string; markerChangeSet?: string }
264
+ | { status: "refused"; step: "plan" | "import" | "markers"; refusals: string[] }
265
+ | { status: "declined"; step: "import" | "markers" }
266
+ | { status: "failed"; step: "import" | "markers"; stackStatus: string };
267
+
268
+ export interface AdoptIntoStackOptions {
269
+ cfn: CloudFormationClient;
270
+ stackName: string;
271
+ plan: CfnAdoptionPlan;
272
+ review: CfnAdoptionReview;
273
+ parameters?: Record<string, string>;
274
+ waitOptions?: { intervalMs?: number; timeoutMs?: number };
275
+ }
276
+
277
+ /**
278
+ * Run a planned adoption: the IMPORT change set, its review and execution,
279
+ * then the marker UPDATE, its tags-only check, review and execution. A
280
+ * refusal or a declined review deletes the change set it made and stops. A
281
+ * marker step that stops leaves the resources imported with Retain and
282
+ * without markers; running the adoption's UPDATE again (a plain deploy of
283
+ * the same template) writes them.
284
+ */
285
+ export async function adoptIntoStack(opts: AdoptIntoStackOptions): Promise<CfnAdoptionOutcome> {
286
+ const { cfn, stackName, plan, review } = opts;
287
+ if (plan.refusals.length > 0) return { status: "refused", step: "plan", refusals: plan.refusals };
288
+ if (plan.resources.length === 0) return { status: "refused", step: "plan", refusals: ["the adoption names no resource"] };
289
+ if (!cfn.createImportChangeSet) {
290
+ return { status: "refused", step: "plan", refusals: ["this CloudFormation client cannot create an IMPORT change set"] };
291
+ }
292
+ const dir = mkdtempSync(join(tmpdir(), "chant-cfn-adopt-"));
293
+ try {
294
+ const importPath = join(dir, "import.template.json");
295
+ writeFileSync(importPath, JSON.stringify(plan.importTemplate, null, 2));
296
+ const resourcesToImport: CfnResourceToImport[] = plan.resources.map((r) => ({
297
+ resourceType: r.type,
298
+ logicalResourceId: r.logicalId,
299
+ resourceIdentifier: r.identifier,
300
+ }));
301
+ const imported = await cfn.createImportChangeSet({ stackName, templatePath: importPath, resourcesToImport, ...(opts.parameters ? { parameters: opts.parameters } : {}) });
302
+ const ref = { stackName, changeSetName: imported.changeSetName };
303
+ const importProblems = importChangeSetProblems(imported, plan);
304
+ if (importProblems.length > 0) {
305
+ await cfn.deleteChangeSet(ref);
306
+ return { status: "refused", step: "import", refusals: importProblems };
307
+ }
308
+ if (!(await review("import", imported, cfnImportReview(plan)))) {
309
+ await cfn.deleteChangeSet(ref);
310
+ return { status: "declined", step: "import" };
311
+ }
312
+ await cfn.executeChangeSet(ref);
313
+ const afterImport = await cfn.waitForStack(stackName, opts.waitOptions);
314
+ if (afterImport.stackStatus !== "IMPORT_COMPLETE") return { status: "failed", step: "import", stackStatus: afterImport.stackStatus };
315
+
316
+ const markerPath = join(dir, "markers.template.json");
317
+ writeFileSync(markerPath, JSON.stringify(plan.markerTemplate, null, 2));
318
+ const marked = await cfn.createChangeSet({ stackName, templatePath: markerPath, ...(opts.parameters ? { parameters: opts.parameters } : {}) });
319
+ const markerRef = { stackName, changeSetName: marked.changeSetName };
320
+ if (isNoopChangeSet(marked)) {
321
+ await cfn.deleteChangeSet(markerRef);
322
+ return { status: "adopted", resources: plan.resources, importChangeSet: imported.changeSetName };
323
+ }
324
+ const markerProblems = markerChangeSetProblems(marked, plan);
325
+ if (markerProblems.length > 0) {
326
+ await cfn.deleteChangeSet(markerRef);
327
+ return { status: "refused", step: "markers", refusals: markerProblems };
328
+ }
329
+ if (!(await review("markers", marked, cfnMarkerReview(plan)))) {
330
+ await cfn.deleteChangeSet(markerRef);
331
+ return { status: "declined", step: "markers" };
332
+ }
333
+ await cfn.executeChangeSet(markerRef);
334
+ const afterMarkers = await cfn.waitForStack(stackName, opts.waitOptions);
335
+ if (afterMarkers.stackStatus !== "UPDATE_COMPLETE") return { status: "failed", step: "markers", stackStatus: afterMarkers.stackStatus };
336
+ return { status: "adopted", resources: plan.resources, importChangeSet: imported.changeSetName, markerChangeSet: marked.changeSetName };
337
+ } finally {
338
+ rmSync(dir, { recursive: true, force: true });
339
+ }
340
+ }