@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,246 @@
1
+ /**
2
+ * `Fn::ImportValue` between stacks (#4466): which exports a template
3
+ * declares, which it imports, and the names they go by.
4
+ *
5
+ * A stack that imports another stack's export reads that stack, the way a
6
+ * CDK app wires its stacks. The read sits inside the template, so core does
7
+ * not see it in the step. `cfn-deploy`'s `declaredReads` finds it here: an
8
+ * import of a name another `cfn-deploy` step's template exports is a read of
9
+ * `<component>.<output>`, which core writes into the importer's step under
10
+ * `imports` (`CfnDeployInput.imports`) and orders, holds and lists like any
11
+ * other read.
12
+ *
13
+ * A name is a string, or an `Fn::Sub` (or `Fn::Join`, or `Ref`) over
14
+ * parameters and pseudo parameters. It resolves where the values it reads
15
+ * are known: a parameter with a plain value in the step's `inputs` or a
16
+ * `Default` in the template, `AWS::StackName`, and the region's pseudo
17
+ * parameters when `AWS_REGION` or `AWS_DEFAULT_REGION` names the region.
18
+ * Anything else (`AWS::AccountId`, a resource attribute, a parameter wired
19
+ * from another stack, an intrinsic this does not follow) leaves the name
20
+ * unresolved, and the plan says so in a hole.
21
+ */
22
+
23
+ import { createHash } from "node:crypto";
24
+ import { readFileSync } from "node:fs";
25
+ import { isAbsolute, resolve } from "node:path";
26
+
27
+ /** A name an export or import goes by: resolved, or why not. */
28
+ export type CfnName = { name: string } | { unresolved: string };
29
+
30
+ /** One export a template declares: the output it is, and its name. */
31
+ export type CfnExport = { output: string } & CfnName;
32
+
33
+ /** One import in a template: where it sits, as a dotted path, the logical id it sits under, and its name. */
34
+ export type CfnImport = { at: string; logicalId: string } & CfnName;
35
+
36
+ /** The template at `path`, read against `root` when relative, as JSON; `undefined` for one that cannot be read or parsed, or an archive reference. */
37
+ export function readCfnTemplate(path: unknown, root?: string): Record<string, unknown> | undefined {
38
+ if (typeof path !== "string" || path.startsWith("archive:")) return undefined;
39
+ try {
40
+ const parsed = JSON.parse(readFileSync(root && !isAbsolute(path) ? resolve(root, path) : path, "utf8")) as unknown;
41
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ /** The region the plan runs against, when the environment names it. */
48
+ function regionOf(env: NodeJS.ProcessEnv): string | undefined {
49
+ return env.AWS_REGION || env.AWS_DEFAULT_REGION || undefined;
50
+ }
51
+
52
+ /**
53
+ * The pseudo parameters this plan knows for `stack`: `AWS::StackName`, and
54
+ * `AWS::Region`, `AWS::Partition` and `AWS::URLSuffix` when the region is
55
+ * named. `AWS::AccountId` and `AWS::StackId` are not known before apply.
56
+ */
57
+ export function cfnPseudoParameters(stack: string, env: NodeJS.ProcessEnv = process.env): Record<string, string> {
58
+ const out: Record<string, string> = { "AWS::StackName": stack };
59
+ const region = regionOf(env);
60
+ if (region) {
61
+ out["AWS::Region"] = region;
62
+ out["AWS::Partition"] = region.startsWith("cn-") ? "aws-cn" : region.startsWith("us-gov-") ? "aws-us-gov" : "aws";
63
+ out["AWS::URLSuffix"] = region.startsWith("cn-") ? "amazonaws.com.cn" : "amazonaws.com";
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * The values a name in `template` can read for `stack`: each parameter's
70
+ * `Default`, overlaid by the step's plain inputs (a string, a number or a
71
+ * boolean; a wired reference or `undefined` is not known), and the pseudo
72
+ * parameters.
73
+ */
74
+ export function cfnKnownValues(
75
+ template: Record<string, unknown> | undefined,
76
+ stack: string,
77
+ inputs: Record<string, unknown> | undefined,
78
+ env: NodeJS.ProcessEnv = process.env,
79
+ ): Record<string, string> {
80
+ const out: Record<string, string> = {};
81
+ const plain = (v: unknown): string | undefined =>
82
+ typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : undefined;
83
+ const params = (template?.Parameters ?? {}) as Record<string, { Default?: unknown } | undefined>;
84
+ for (const [k, p] of Object.entries(params)) {
85
+ const d = plain(p?.Default);
86
+ if (d !== undefined) out[k] = d;
87
+ }
88
+ for (const [k, v] of Object.entries(inputs ?? {})) {
89
+ const s = plain(v);
90
+ if (s !== undefined) out[k] = s;
91
+ }
92
+ return { ...out, ...cfnPseudoParameters(stack, env) };
93
+ }
94
+
95
+ /** Why a variable a name reads has no value here. */
96
+ function unknownVariable(v: string, known: Record<string, string>): string | undefined {
97
+ if (v in known) return undefined;
98
+ if (v.includes(".")) return `reads ${v}, a resource attribute`;
99
+ if (v.startsWith("AWS::")) return `reads ${v}, which is known only at apply`;
100
+ return `reads parameter ${v}, which has no value the plan knows`;
101
+ }
102
+
103
+ /**
104
+ * The name `value` resolves to against `known`, or why it does not. A
105
+ * string is itself; `Fn::Sub` substitutes `${X}` from `known` and its own
106
+ * variables (`${!X}` is the literal `${X}`); `Ref` reads `known`;
107
+ * `Fn::Join` joins what it resolves. Any other shape is unresolved.
108
+ */
109
+ export function resolveCfnName(value: unknown, known: Record<string, string>): CfnName {
110
+ if (typeof value === "string") return { name: value };
111
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return { unresolved: "is not a string or an intrinsic" };
112
+ const entries = Object.entries(value as Record<string, unknown>);
113
+ if (entries.length !== 1) return { unresolved: "is not a string or an intrinsic" };
114
+ const [key, arg] = entries[0]!;
115
+ if (key === "Ref") {
116
+ if (typeof arg !== "string") return { unresolved: "is a Ref without a name" };
117
+ const why = unknownVariable(arg, known);
118
+ return why ? { unresolved: why } : { name: known[arg]! };
119
+ }
120
+ if (key === "Fn::Sub") {
121
+ const text = typeof arg === "string" ? arg : Array.isArray(arg) && typeof arg[0] === "string" ? arg[0] : undefined;
122
+ if (text === undefined) return { unresolved: "is an Fn::Sub this plan cannot read" };
123
+ const vars: Record<string, string> = { ...known };
124
+ if (Array.isArray(arg) && arg[1] && typeof arg[1] === "object") {
125
+ for (const [k, v] of Object.entries(arg[1] as Record<string, unknown>)) {
126
+ const r = resolveCfnName(v, known);
127
+ if ("unresolved" in r) return { unresolved: `its Fn::Sub variable ${k} ${r.unresolved}` };
128
+ vars[k] = r.name;
129
+ }
130
+ }
131
+ let why: string | undefined;
132
+ const name = text.replace(/\$\{(!?)([^}]*)\}/g, (whole, bang: string, v: string) => {
133
+ if (bang) return "${" + v + "}";
134
+ const w = unknownVariable(v, vars);
135
+ if (w) {
136
+ why ??= w;
137
+ return whole;
138
+ }
139
+ return vars[v]!;
140
+ });
141
+ return why ? { unresolved: why } : { name };
142
+ }
143
+ if (key === "Fn::Join") {
144
+ if (!Array.isArray(arg) || typeof arg[0] !== "string" || !Array.isArray(arg[1])) return { unresolved: "is an Fn::Join this plan cannot read" };
145
+ const parts: string[] = [];
146
+ for (const part of arg[1] as unknown[]) {
147
+ const r = resolveCfnName(part, known);
148
+ if ("unresolved" in r) return r;
149
+ parts.push(r.name);
150
+ }
151
+ return { name: parts.join(arg[0]) };
152
+ }
153
+ return { unresolved: `uses ${key}, which this plan does not follow` };
154
+ }
155
+
156
+ /** Every export `template` declares (`Outputs.<name>.Export.Name`), by output, sorted. */
157
+ export function cfnExports(template: Record<string, unknown> | undefined, known: Record<string, string>): CfnExport[] {
158
+ const outputs = (template?.Outputs ?? {}) as Record<string, { Export?: { Name?: unknown } } | undefined>;
159
+ const out: CfnExport[] = [];
160
+ for (const [output, o] of Object.entries(outputs).sort(([a], [b]) => a.localeCompare(b))) {
161
+ if (o?.Export === undefined) continue;
162
+ out.push({ output, ...resolveCfnName(o.Export.Name, known) });
163
+ }
164
+ return out;
165
+ }
166
+
167
+ /**
168
+ * Every `Fn::ImportValue` in `template`'s resources, outputs and conditions,
169
+ * with where it sits and the name it imports, in template order. An import
170
+ * whose argument is itself an import is listed once, unresolved.
171
+ */
172
+ export function cfnImports(template: Record<string, unknown> | undefined, known: Record<string, string>): CfnImport[] {
173
+ const out: CfnImport[] = [];
174
+ const walk = (v: unknown, path: string, logicalId: string): void => {
175
+ if (Array.isArray(v)) return v.forEach((x, i) => walk(x, `${path}.${i}`, logicalId));
176
+ if (v === null || typeof v !== "object") return;
177
+ for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
178
+ if (k === "Fn::ImportValue") {
179
+ out.push({ at: path, logicalId, ...resolveCfnName(x, known) });
180
+ continue;
181
+ }
182
+ walk(x, `${path}.${k}`, logicalId);
183
+ }
184
+ };
185
+ for (const section of ["Resources", "Outputs", "Conditions"] as const) {
186
+ const s = template?.[section];
187
+ if (s === null || typeof s !== "object") continue;
188
+ for (const [id, body] of Object.entries(s as Record<string, unknown>)) walk(body, `${section}.${id}`, id);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** The JSON Pointer `CfnDeployInput.imports[name]` sits at in a `cfn-deploy` step. */
194
+ export function cfnImportPointer(name: string): string {
195
+ return "/imports/" + name.replace(/~/g, "~0").replace(/\//g, "~1");
196
+ }
197
+
198
+ /** The stand-in parameter that takes an import's place in a preview: `ChantImport`, the name's letters and digits, and a short digest so two names never share one. */
199
+ export function cfnStandInParameter(name: string): string {
200
+ return `ChantImport${name.replace(/[^A-Za-z0-9]/g, "").slice(0, 200)}${createHash("sha256").update(name).digest("hex").slice(0, 8)}`;
201
+ }
202
+
203
+ /**
204
+ * A copy of `template` for a preview (#4466b): each `Fn::ImportValue` of a
205
+ * name in `names` is a `Ref` to a stand-in `String` parameter, which the
206
+ * preview fills with `cfnPlaceholder`. CloudFormation refuses a change set
207
+ * that imports an export that does not exist yet, so the importer of a stack
208
+ * the same change creates is planned against this copy. `standIns` maps each
209
+ * stand-in parameter to the export it stands for; a name the template never
210
+ * imports gets none.
211
+ */
212
+ export function cfnTemplateWithStandIns(
213
+ template: Record<string, unknown>,
214
+ names: readonly string[],
215
+ known: Record<string, string>,
216
+ ): { template: Record<string, unknown>; standIns: Record<string, string> } {
217
+ const wanted = new Set(names);
218
+ const standIns: Record<string, string> = {};
219
+ const swap = (v: unknown): unknown => {
220
+ if (Array.isArray(v)) return v.map(swap);
221
+ if (v === null || typeof v !== "object") return v;
222
+ const entries = Object.entries(v as Record<string, unknown>);
223
+ if (entries.length === 1 && entries[0]![0] === "Fn::ImportValue") {
224
+ const r = resolveCfnName(entries[0]![1], known);
225
+ if ("name" in r && wanted.has(r.name)) {
226
+ const param = cfnStandInParameter(r.name);
227
+ standIns[param] = r.name;
228
+ return { Ref: param };
229
+ }
230
+ return v;
231
+ }
232
+ return Object.fromEntries(entries.map(([k, x]) => [k, swap(x)]));
233
+ };
234
+ const out: Record<string, unknown> = { ...template };
235
+ for (const section of ["Resources", "Outputs", "Conditions"] as const) {
236
+ if (template[section] !== undefined) out[section] = swap(template[section]);
237
+ }
238
+ if (Object.keys(standIns).length > 0) {
239
+ const params = { ...((template.Parameters ?? {}) as Record<string, unknown>) };
240
+ for (const [param, name] of Object.entries(standIns).sort(([a], [b]) => a.localeCompare(b))) {
241
+ params[param] = { Type: "String", Description: `chant preview: stands in for Fn::ImportValue ${name}, known after its stack applies` };
242
+ }
243
+ out.Parameters = params;
244
+ }
245
+ return { template: out, standIns };
246
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `cfn-deploy`'s plan shapes pass core's plan shape conformance (#4425):
3
+ * the preview with a stand-in parameter, the real change set planned after
4
+ * the upstream applied, and real change sets that add, drop or replace a
5
+ * resource, all made by `plan` against the mock executor.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
9
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { checkPlanShapeConformance } from "@intentius/chant/components";
13
+ import type { CfnChange } from "./cloud-executor";
14
+ import { createCfnDeployCapability } from "./apply";
15
+ import { createMockCloudExecutor } from "./__tests__/mock-cloud-executor";
16
+
17
+ let dir: string;
18
+ let template: string;
19
+
20
+ beforeEach(() => {
21
+ dir = mkdtempSync(join(tmpdir(), "cfn-shape-conformance-"));
22
+ template = join(dir, "stack.template.json");
23
+ writeFileSync(
24
+ template,
25
+ JSON.stringify({
26
+ Parameters: { SubnetId: { Type: "AWS::EC2::Subnet::Id" } },
27
+ Resources: {
28
+ Eni: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { Ref: "SubnetId" }, Description: "d" } },
29
+ Q: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": "q-${SubnetId}" } } },
30
+ },
31
+ }),
32
+ );
33
+ });
34
+
35
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
36
+
37
+ const ctx = { env: "local", component: "network" };
38
+
39
+ const eni: CfnChange = {
40
+ action: "Modify",
41
+ logicalResourceId: "Eni",
42
+ resourceType: "AWS::EC2::NetworkInterface",
43
+ replacement: false,
44
+ replacementMode: "False",
45
+ details: ["SubnetId", "Description"],
46
+ drivers: [
47
+ { property: "SubnetId", changeSource: "ParameterReference", causingEntity: "SubnetId" },
48
+ { property: "Description", changeSource: "DirectModification" },
49
+ ],
50
+ };
51
+ const q: CfnChange = { action: "Add", logicalResourceId: "Q", resourceType: "AWS::SQS::Queue", replacement: false };
52
+ const topic: CfnChange = { action: "Add", logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false };
53
+
54
+ /** The member previewed: `SubnetId` holds nothing yet, so the plan uses a stand-in. */
55
+ async function preview() {
56
+ const mock = createMockCloudExecutor({ stacks: { net: { changes: [eni, q] } } });
57
+ const planned = await createCfnDeployCapability(mock.executor).plan!(
58
+ { ...ctx, unresolvedInputs: ["vpc.SubnetId"] },
59
+ { stack: "net", template, inputs: { SubnetId: undefined as never } },
60
+ );
61
+ return planned.shape;
62
+ }
63
+
64
+ /** The member planned again once the upstream applied, with the real change set `changes`. */
65
+ async function real(changes: CfnChange[]) {
66
+ const mock = createMockCloudExecutor({ stacks: { net: { changes } } });
67
+ const planned = await createCfnDeployCapability(mock.executor).plan!(ctx, { stack: "net", template, inputs: { SubnetId: "subnet-1" }, onReplace: "allow" });
68
+ return planned.shape;
69
+ }
70
+
71
+ describe("cfn-deploy plan shape conformance (#4425)", () => {
72
+ it("passes", async () => {
73
+ const problems = await checkPlanShapeConformance({
74
+ lexicon: "aws",
75
+ preview,
76
+ fresh: () => real([eni, q]),
77
+ added: () => real([eni, q, topic]),
78
+ missing: () => real([q]),
79
+ replaced: () => real([{ ...eni, replacement: true, replacementMode: "True" }, q]),
80
+ });
81
+ expect(problems).toEqual([]);
82
+ });
83
+ });
@@ -81,8 +81,8 @@ export interface CfnChangeSet {
81
81
  statusReason?: string;
82
82
  /** True the first time this stack is created (no prior stack exists). */
83
83
  isCreate: boolean;
84
- /** The change set's type: CREATE for a stack that does not exist yet or was never executed, else UPDATE. */
85
- 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";
86
86
  /**
87
87
  * True when no stack existed before this change set, so CloudFormation made
88
88
  * an empty `REVIEW_IN_PROGRESS` stack to hold it. A plan that deletes the
@@ -99,6 +99,18 @@ export interface CfnCreateChangeSetArgs {
99
99
  parameters?: Record<string, string>;
100
100
  }
101
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
+
102
114
  export interface CfnExecuteChangeSetArgs {
103
115
  stackName: string;
104
116
  changeSetName: string;
@@ -107,11 +119,21 @@ export interface CfnExecuteChangeSetArgs {
107
119
  export interface CfnStackStatus {
108
120
  stackStatus: string;
109
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 }>;
110
124
  }
111
125
 
112
126
  export interface CloudFormationClient {
113
127
  /** Create (but do not execute) a changeset, returning its proposed changes for preview/safety checks. */
114
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>;
115
137
  /** Execute a previously created changeset. */
116
138
  executeChangeSet(args: CfnExecuteChangeSetArgs): Promise<void>;
117
139
  /** Delete a changeset without executing it (used when a safety policy blocks the apply). */
@@ -129,6 +151,12 @@ export interface CloudFormationClient {
129
151
  * empty stack, and the next CREATE change set reuses it.
130
152
  */
131
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[]>;
132
160
  }
133
161
 
134
162
  // ── ECS ──────────────────────────────────────────────────────────────────��─
@@ -577,6 +605,53 @@ const realCloudFormation: CloudFormationClient = {
577
605
  changes: await readCfnChanges(described, describeNested),
578
606
  };
579
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
+ },
580
655
  async executeChangeSet(args) {
581
656
  await run(
582
657
  `aws cloudformation execute-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(args.changeSetName)}`,
@@ -590,12 +665,34 @@ const realCloudFormation: CloudFormationClient = {
590
665
  async describeStack(stackName) {
591
666
  const { stdout } = await run(`aws cloudformation describe-stacks --stack-name ${q(stackName)}`);
592
667
  const described = JSON.parse(stdout) as {
593
- Stacks: Array<{ StackStatus: string; Outputs?: Array<{ OutputKey: string; OutputValue: string }> }>;
668
+ Stacks: Array<{ StackStatus: string; Outputs?: Array<{ OutputKey: string; OutputValue: string; ExportName?: string }> }>;
594
669
  };
595
670
  const stack = described.Stacks[0];
596
671
  const outputs: Record<string, string> = {};
597
- for (const o of stack?.Outputs ?? []) outputs[o.OutputKey] = o.OutputValue;
598
- 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();
599
696
  },
600
697
  async waitForStack(stackName, opts) {
601
698
  const intervalMs = opts?.intervalMs ?? 10_000;