@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.
- package/dist/codegen/generate-lexicon.d.ts +2 -0
- package/dist/codegen/generate-lexicon.d.ts.map +1 -1
- package/dist/codegen/generate-lexicon.js +1 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts +4 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -1
- package/dist/components/__tests__/mock-cloud-executor.js +18 -1
- package/dist/components/apply.d.ts +44 -2
- package/dist/components/apply.d.ts.map +1 -1
- package/dist/components/apply.js +260 -29
- package/dist/components/cfn-adopt.d.ts +99 -0
- package/dist/components/cfn-adopt.d.ts.map +1 -0
- package/dist/components/cfn-adopt.js +278 -0
- package/dist/components/cfn-imports.d.ts +84 -0
- package/dist/components/cfn-imports.d.ts.map +1 -0
- package/dist/components/cfn-imports.js +239 -0
- package/dist/components/cloud-executor.d.ts +31 -2
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/components/cloud-executor.js +64 -2
- package/dist/importable.d.ts +30 -0
- package/dist/importable.d.ts.map +1 -0
- package/dist/importable.js +72 -0
- package/dist/index-identity.d.ts +19 -0
- package/dist/index-identity.d.ts.map +1 -0
- package/dist/index-identity.js +79 -0
- package/dist/index-import.d.ts +17 -0
- package/dist/index-import.d.ts.map +1 -0
- package/dist/index-import.js +115 -0
- package/dist/integrity.json +3 -3
- package/dist/listed-types.d.ts +11 -0
- package/dist/listed-types.d.ts.map +1 -0
- package/dist/listed-types.js +52 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +13206 -1237
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +9 -0
- package/dist/reference-catalog.d.ts.map +1 -1
- package/dist/reference-catalog.js +62 -20
- package/dist/spec/fetch.d.ts +5 -0
- package/dist/spec/fetch.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +8 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/dist/spec/parse.js +3 -0
- package/package.json +2 -2
- package/src/codegen/generate-lexicon.ts +3 -0
- package/src/components/__tests__/mock-cloud-executor.ts +20 -1
- package/src/components/apply.ts +282 -29
- package/src/components/cfn-adopt.test.ts +284 -0
- package/src/components/cfn-adopt.ts +340 -0
- package/src/components/cfn-exports-in-use.test.ts +146 -0
- package/src/components/cfn-imports-preview.test.ts +178 -0
- package/src/components/cfn-imports-three-stacks.test.ts +107 -0
- package/src/components/cfn-imports.test.ts +213 -0
- package/src/components/cfn-imports.ts +246 -0
- package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
- package/src/components/cloud-executor.ts +102 -5
- package/src/components/demo-composed-stacks-floci.e2e.test.ts +235 -0
- package/src/derived-edges.test.ts +142 -0
- package/src/generated/lexicon-aws.json +13206 -1237
- package/src/importable.test.ts +86 -0
- package/src/importable.ts +83 -0
- package/src/index-identity.test.ts +134 -0
- package/src/index-identity.ts +98 -0
- package/src/index-import.test.ts +242 -0
- package/src/index-import.ts +140 -0
- package/src/index-search.test.ts +87 -0
- package/src/listed-types.test.ts +97 -0
- package/src/listed-types.ts +56 -0
- package/src/plugin.ts +10 -0
- package/src/reference-catalog.ts +63 -20
- package/src/spec/fetch.ts +5 -0
- package/src/spec/parse.test.ts +15 -0
- package/src/spec/parse.ts +12 -0
- package/src/testdata/indexed/identity.listing.json +65 -0
- package/src/testdata/indexed/import.listing.json +126 -0
- package/src/testdata/indexed/listing.json +69 -0
- package/src/testdata/indexed/topology.listing.json +350 -0
- package/src/testdata/three-cloud/dns.graph.json +336 -0
- 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
|
-
|
|
598
|
-
|
|
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;
|