@intentius/chant-lexicon-aws 0.124.51 → 0.124.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +76 -3
- package/dist/components/apply.d.ts.map +1 -1
- package/dist/components/apply.js +488 -32
- 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 +44 -2
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/components/cloud-executor.js +73 -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 +546 -33
- 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-floci.e2e.test.ts +53 -0
- package/src/components/cfn-plan-shape-conformance.test.ts +83 -0
- package/src/components/cfn-plan.test.ts +328 -9
- package/src/components/cloud-executor.ts +119 -6
- 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
|
+
}
|
|
@@ -11,6 +11,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
11
11
|
import { tmpdir } from "node:os";
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { CfnPlanMovedError, createCfnDeployCapability } from "./apply";
|
|
14
|
+
import { PlanShapeMovedError } from "@intentius/chant/components/covered-round";
|
|
14
15
|
import { defaultCloudExecutor } from "./cloud-executor";
|
|
15
16
|
|
|
16
17
|
const endpoint = process.env.CHANT_FLOCI_ENDPOINT;
|
|
@@ -74,3 +75,55 @@ describe.skipIf(!endpoint)("cfn-deploy plan against Floci (#3997)", () => {
|
|
|
74
75
|
expect(changeSets()).toEqual([]);
|
|
75
76
|
}, 600_000); // each `aws` call takes seconds, and a plan or a run makes several
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
describe.skipIf(!endpoint)("cfn-deploy preview against Floci (#4423)", () => {
|
|
80
|
+
const stack = `zz-4419-prev-${process.pid}`;
|
|
81
|
+
const ctx = { env: "local", component: stack, unresolvedInputs: ["network"] };
|
|
82
|
+
const saved = { ...process.env };
|
|
83
|
+
let dir: string;
|
|
84
|
+
let template: string;
|
|
85
|
+
const aws = (...args: string[]): string => execFileSync("aws", ["--endpoint-url", endpoint!, ...args], { encoding: "utf8" });
|
|
86
|
+
const open = (): string[] =>
|
|
87
|
+
((JSON.parse(aws("cloudformation", "list-change-sets", "--stack-name", stack)) as { Summaries?: Array<{ ChangeSetName: string; ExecutionStatus?: string }> }).Summaries ?? [])
|
|
88
|
+
.filter((c) => c.ExecutionStatus !== "EXECUTE_COMPLETE")
|
|
89
|
+
.map((c) => c.ChangeSetName);
|
|
90
|
+
const queue = (extra: Record<string, unknown> = {}) => ({
|
|
91
|
+
Parameters: { Prefix: { Type: "String" } },
|
|
92
|
+
Resources: { Queue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": "${Prefix}-q" } } }, ...extra },
|
|
93
|
+
});
|
|
94
|
+
const input = (prefix?: string) => ({ stack, template, inputs: { Prefix: prefix as never } });
|
|
95
|
+
|
|
96
|
+
beforeAll(() => {
|
|
97
|
+
Object.assign(process.env, { AWS_ENDPOINT_URL: endpoint, AWS_ACCESS_KEY_ID: "test", AWS_SECRET_ACCESS_KEY: "test", AWS_REGION: "us-east-1" });
|
|
98
|
+
dir = mkdtempSync(join(tmpdir(), "cfn-preview-floci-"));
|
|
99
|
+
template = join(dir, "stack.template.json");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterAll(() => {
|
|
103
|
+
try {
|
|
104
|
+
aws("cloudformation", "delete-stack", "--stack-name", stack);
|
|
105
|
+
} catch { /* never created */ }
|
|
106
|
+
process.env = saved;
|
|
107
|
+
rmSync(dir, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("previews a stack that does not exist yet, leaves nothing behind, refuses a real change set that adds a resource, and executes one with the previewed shape", async () => {
|
|
111
|
+
const capability = createCfnDeployCapability(defaultCloudExecutor());
|
|
112
|
+
writeFileSync(template, JSON.stringify(queue()));
|
|
113
|
+
const planned = await capability.plan!(ctx, input());
|
|
114
|
+
expect(planned.artifact).toMatchObject({ kind: "cfn-deploy-shape" });
|
|
115
|
+
expect(planned.changeSet!.entries.map((e) => [e.address, e.action])).toEqual([["Queue", "create"]]);
|
|
116
|
+
expect(open()).toEqual([]);
|
|
117
|
+
await expect(capability.outputs!(ctx, { stack, template })).resolves.toEqual({});
|
|
118
|
+
// The same template and inputs plan to the same digest.
|
|
119
|
+
expect((await capability.plan!(ctx, input())).planDigest).toBe(planned.planDigest);
|
|
120
|
+
|
|
121
|
+
writeFileSync(template, JSON.stringify(queue({ Extra: { Type: "AWS::SQS::Queue" } })));
|
|
122
|
+
await expect(capability.run({ ...ctx, plans: { [stack]: planned.artifact } }, input("p"))).rejects.toBeInstanceOf(PlanShapeMovedError);
|
|
123
|
+
expect(open()).toEqual([]);
|
|
124
|
+
|
|
125
|
+
writeFileSync(template, JSON.stringify(queue()));
|
|
126
|
+
const done = await capability.run({ ...ctx, plans: { [stack]: planned.artifact } }, input("p"));
|
|
127
|
+
expect(done.stackStatus).toBe("CREATE_COMPLETE");
|
|
128
|
+
}, 600_000);
|
|
129
|
+
});
|
|
@@ -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
|
+
});
|