@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
|
@@ -15,6 +15,7 @@ import { applyPrSet, planPrSet, prReport } from "@intentius/chant/components/pr-
|
|
|
15
15
|
import { composeChangeSet } from "@intentius/chant/change-set";
|
|
16
16
|
import { groupChangeSet, planSummaryHeadline } from "@intentius/chant/plan-summary";
|
|
17
17
|
import { memberCounts, prStatusDescription, renderPrNote } from "@intentius/chant/pr-loop";
|
|
18
|
+
import { PlanShapeMovedError } from "@intentius/chant/components/covered-round";
|
|
18
19
|
import { CfnPlanMovedError, CfnReplacementBlockedError, cfnChangeEntry, createCfnDeployCapability, type CfnDeployInput } from "./apply";
|
|
19
20
|
import { isPlanMovedError } from "@intentius/chant/components/capability";
|
|
20
21
|
import { planMovedIn } from "@intentius/chant/components/driver";
|
|
@@ -122,6 +123,31 @@ describe("cfn-deploy plan (#4084)", () => {
|
|
|
122
123
|
expect(planned.outputs).toEqual({ known: { Region: "us-east-1", TopicArn: "arn:topic" }, unknown: ["EnvName", "Imported", "QueueUrl", "TableArn"] });
|
|
123
124
|
});
|
|
124
125
|
|
|
126
|
+
it("an output that is a plain string, or a Ref to a parameter this plan has a value for, is known at that value, changed or new (#4419)", async () => {
|
|
127
|
+
writeFileSync(
|
|
128
|
+
template,
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
Parameters: { Port: { Type: "String" }, Later: { Type: "String" } },
|
|
131
|
+
Resources: { Setting: { Type: "AWS::SSM::Parameter" } },
|
|
132
|
+
Outputs: {
|
|
133
|
+
Literal: { Value: "8090" },
|
|
134
|
+
NewLiteral: { Value: "9000" },
|
|
135
|
+
Passed: { Value: { Ref: "Port" } },
|
|
136
|
+
NotYet: { Value: { Ref: "Later" } },
|
|
137
|
+
Setting: { Value: { Ref: "Setting" } },
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
const { planned } = await planOne(
|
|
142
|
+
{
|
|
143
|
+
outputs: { Literal: "8080", Passed: "8080", NotYet: "x", Setting: "/setting" },
|
|
144
|
+
changes: [{ action: "Modify", logicalResourceId: "Setting", resourceType: "AWS::SSM::Parameter", replacement: false, replacementMode: "False" }],
|
|
145
|
+
},
|
|
146
|
+
{ inputs: { Port: "8090" } },
|
|
147
|
+
);
|
|
148
|
+
expect(planned.outputs).toEqual({ known: { Literal: "8090", NewLiteral: "9000", Passed: "8090", Setting: "/setting" }, unknown: ["NotYet"] });
|
|
149
|
+
});
|
|
150
|
+
|
|
125
151
|
it("a stack the change set creates says nothing of its outputs (#3999)", async () => {
|
|
126
152
|
const { planned } = await planOne({ missing: true, changes: [{ action: "Add", logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false }] });
|
|
127
153
|
expect(planned.outputs).toBeUndefined();
|
|
@@ -240,7 +266,8 @@ describe("pr-plan over cfn-deploy members (#4084)", () => {
|
|
|
240
266
|
expect(set.plannedOutputs).toEqual({ network: [] });
|
|
241
267
|
expect(set.members.find((m) => m.member === "cluster-a")?.unknownInputs).toEqual(["network.TopicArn"]);
|
|
242
268
|
const note = renderPrNote(report);
|
|
243
|
-
|
|
269
|
+
// The member is previewed against a stand-in, so it counts its changes (#4419).
|
|
270
|
+
expect(note).toContain("| `cluster-a` | cluster-a | planned | 1 | 0 | 0 | 0 |");
|
|
244
271
|
expect(note).toContain("Known only after apply: `cluster-a` reads `network.TopicArn`.");
|
|
245
272
|
});
|
|
246
273
|
|
|
@@ -259,19 +286,28 @@ describe("pr-plan over cfn-deploy members (#4084)", () => {
|
|
|
259
286
|
expect(created.find((c) => c.stackName === "cluster-a")?.parameters).toEqual({ TopicArn: "arn:topic" });
|
|
260
287
|
});
|
|
261
288
|
|
|
262
|
-
it("a member whose inputs come from a stack the run creates first
|
|
289
|
+
it("a member whose inputs come from a stack the run creates first is previewed against a stand-in, and the stand-in is deleted (#4419)", async () => {
|
|
263
290
|
const { mock, set, report } = await planBoth({
|
|
264
291
|
network: { missing: true, changes: [{ action: "Add", logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false }] },
|
|
265
|
-
"cluster-a": { missing: true },
|
|
292
|
+
"cluster-a": { missing: true, changes: [{ action: "Add", logicalResourceId: "clusterAQueue", resourceType: "AWS::SQS::Queue", replacement: false }] },
|
|
266
293
|
});
|
|
267
294
|
const clusterA = set.doc.members.find((m) => m.member === "cluster-a")!;
|
|
268
|
-
expect(clusterA).toMatchObject({ status: "planned", holes: [
|
|
269
|
-
expect(set.doc.entries.filter((e) => e.member === "cluster-a")).
|
|
270
|
-
|
|
295
|
+
expect(clusterA).toMatchObject({ status: "planned", holes: [] });
|
|
296
|
+
expect(set.doc.entries.filter((e) => e.member === "cluster-a")).toHaveLength(1);
|
|
297
|
+
const created = mock.calls.filter((c) => c.method === "createChangeSet").map((c) => c.args as { stackName: string; parameters?: Record<string, string> });
|
|
298
|
+
expect(created.find((c) => c.stackName === "cluster-a")?.parameters).toEqual({ TopicArn: "known-after-apply" });
|
|
299
|
+
const deleted = mock.calls.filter((c) => c.method === "deleteChangeSet").map((c) => (c.args as { stackName: string }).stackName);
|
|
300
|
+
expect(deleted.sort()).toEqual(["cluster-a", "network"]);
|
|
271
301
|
const note = renderPrNote(report);
|
|
272
|
-
expect(note).toContain("| `cluster-a` | cluster-a | planned
|
|
302
|
+
expect(note).toContain("| `cluster-a` | cluster-a | planned | 1 | 0 | 0 | 0 |");
|
|
273
303
|
expect(note).toContain("| `network` | network | planned | 1 | 0 | 0 | 0 |");
|
|
274
|
-
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("a member whose upstream is unresolved but names no unknown input stays a hole", async () => {
|
|
307
|
+
const mock = createMockCloudExecutor({ stacks: { network: {} } });
|
|
308
|
+
const planned = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["up"] }, { stack: "network", template });
|
|
309
|
+
expect(planned.changeSet?.member.holes).toMatchObject([{ reason: "inputs from up; re-checked on apply" }]);
|
|
310
|
+
expect(mock.calls.filter((c) => c.method === "createChangeSet")).toEqual([]);
|
|
275
311
|
});
|
|
276
312
|
|
|
277
313
|
it("planning the same state twice gives the same digest, so the apply's plan matches the approved one", async () => {
|
|
@@ -335,7 +371,7 @@ describe("cfn-deploy applies the change set it planned (#3997)", () => {
|
|
|
335
371
|
expect(methods(mock).slice(3)).toEqual(["createChangeSet", "deleteChangeSet", "deleteStackShell"]);
|
|
336
372
|
});
|
|
337
373
|
|
|
338
|
-
it("a member with
|
|
374
|
+
it("a member with a hole it could not preview hands run no artifact, so run plans it afresh", async () => {
|
|
339
375
|
const mock = createMockCloudExecutor({ stacks: { network: { changes: [queueAdd] } } });
|
|
340
376
|
const capability = createCfnDeployCapability(mock.executor);
|
|
341
377
|
const planned = await capability.plan!({ ...ctx, unresolvedInputs: ["upstream"] }, { stack: "network", template });
|
|
@@ -438,3 +474,286 @@ describe("cfn-deploy targets (#4300)", () => {
|
|
|
438
474
|
expect(targetRefusal(createCfnDeployCapability(createMockCloudExecutor().executor))).toMatch(/cfn-deploy can't narrow/);
|
|
439
475
|
});
|
|
440
476
|
});
|
|
477
|
+
|
|
478
|
+
describe("cfn-deploy previews a member whose inputs are unknown, and checks the real change against it (#4419)", () => {
|
|
479
|
+
const eni = { action: "Modify" as const, logicalResourceId: "Eni", resourceType: "AWS::EC2::NetworkInterface", replacement: false, replacementMode: "False" as const };
|
|
480
|
+
const withDriver = (property: string, causingEntity = "SubnetId") => ({
|
|
481
|
+
...eni,
|
|
482
|
+
details: [property, "Description"],
|
|
483
|
+
drivers: [{ property, changeSource: "ParameterReference", causingEntity }, { property: "Description", changeSource: "DirectModification" }],
|
|
484
|
+
});
|
|
485
|
+
const input = (): CfnDeployInput => ({ stack: "net", template, inputs: { SubnetId: undefined as never } });
|
|
486
|
+
|
|
487
|
+
beforeEach(() => {
|
|
488
|
+
writeFileSync(
|
|
489
|
+
template,
|
|
490
|
+
JSON.stringify({
|
|
491
|
+
Parameters: { SubnetId: { Type: "AWS::EC2::Subnet::Id" }, Desc: { Type: "String", Default: "x" } },
|
|
492
|
+
Resources: {
|
|
493
|
+
Eni: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { Ref: "SubnetId" }, Description: "d" } },
|
|
494
|
+
Q: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": "q-${SubnetId}" } } },
|
|
495
|
+
},
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
const q = { action: "Add" as const, logicalResourceId: "Q", resourceType: "AWS::SQS::Queue", replacement: false };
|
|
501
|
+
|
|
502
|
+
it("creates the change set with an id-shaped stand-in, marks the driven properties unknown, and deletes the change set", async () => {
|
|
503
|
+
const mock = createMockCloudExecutor({ stacks: { net: { changes: [withDriver("SubnetId"), q] } } });
|
|
504
|
+
const planned = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, input());
|
|
505
|
+
const created = mock.calls.find((c) => c.method === "createChangeSet")!.args as { parameters: Record<string, string> };
|
|
506
|
+
expect(created.parameters).toEqual({ SubnetId: "subnet-00000000known-after-apply" });
|
|
507
|
+
const entries = planned.changeSet!.entries;
|
|
508
|
+
expect(entries.find((e) => e.address === "Eni")?.attributes).toEqual([
|
|
509
|
+
{ path: "Description" },
|
|
510
|
+
{ path: "SubnetId", unknown: true },
|
|
511
|
+
]);
|
|
512
|
+
// A create names no properties; the template says which one reads the parameter.
|
|
513
|
+
expect(entries.find((e) => e.address === "Q")?.attributes).toEqual([{ path: "QueueName", unknown: true }]);
|
|
514
|
+
expect(planned.changeSet!.member.holes).toEqual([]);
|
|
515
|
+
expect(methods(mock)).toEqual(["createChangeSet", "deleteChangeSet"]);
|
|
516
|
+
expect(planned.artifact).toMatchObject({ kind: "cfn-deploy-shape" });
|
|
517
|
+
// The preview reports its shape to core, unknown where a placeholder drives it (#4419).
|
|
518
|
+
expect(planned.shape).toMatchObject({ member: "net", unknownInputs: ["vpc"] });
|
|
519
|
+
expect(planned.shape!.entries.find((e) => e.logicalId === "Eni")).toMatchObject({ action: "update", replacement: "false", properties: ["Description", "SubnetId"], unknown: ["SubnetId"] });
|
|
520
|
+
expect(planned.shape!.entries.find((e) => e.logicalId === "Q")).toMatchObject({ action: "create", unknown: ["QueueName"] });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("a previewed member still says the outputs that do not read an unknown input (#4419)", async () => {
|
|
524
|
+
writeFileSync(
|
|
525
|
+
template,
|
|
526
|
+
JSON.stringify({
|
|
527
|
+
Parameters: { SubnetId: { Type: "AWS::EC2::Subnet::Id" }, Port: { Type: "String" } },
|
|
528
|
+
Resources: { Eni: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { Ref: "SubnetId" } } } },
|
|
529
|
+
Outputs: { Port: { Value: { Ref: "Port" } }, Fixed: { Value: "8080" }, Id: { Value: { Ref: "SubnetId" } } },
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
const mock = createMockCloudExecutor({ stacks: { net: { outputs: { Port: "1", Fixed: "1", Id: "x" }, changes: [{ ...eni, action: "Add" as const, details: [] }] } } });
|
|
533
|
+
const planned = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, { stack: "net", template, inputs: { SubnetId: undefined as never, Port: "8080" } });
|
|
534
|
+
expect(planned.outputs).toEqual({ known: { Fixed: "8080", Port: "8080" }, unknown: ["Id"] });
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("a conditional replacement in the real change set is read as conditional", async () => {
|
|
538
|
+
const mock = createMockCloudExecutor({ stacks: { net: { changes: [{ ...eni, replacementMode: "Conditional" as never }] } } });
|
|
539
|
+
const planned = await createCfnDeployCapability(mock.executor).plan!(ctx, { stack: "net", template, inputs: { SubnetId: "subnet-1" } });
|
|
540
|
+
expect(planned.shape!.entries[0]).toMatchObject({ logicalId: "Eni", replacement: "conditional" });
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("falls back to a hole when CloudFormation refuses the stand-in", async () => {
|
|
544
|
+
const mock = createMockCloudExecutor({ stacks: { net: { changes: [] } } });
|
|
545
|
+
const real = mock.executor.cloudformation.createChangeSet;
|
|
546
|
+
mock.executor.cloudformation.createChangeSet = async () => {
|
|
547
|
+
throw new Error("Parameter validation failed: parameter value subnet-00000000known-after-apply for parameter name SubnetId does not exist");
|
|
548
|
+
};
|
|
549
|
+
void real;
|
|
550
|
+
const planned = await createCfnDeployCapability(mock.executor).plan!(ctx, input());
|
|
551
|
+
expect(planned.artifact).toBeUndefined();
|
|
552
|
+
expect(planned.changeSet!.member.holes[0].reason).toMatch(/no preview: CloudFormation refused .*does not exist/);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
async function previewThenRun(previewed: ReturnType<typeof withDriver>[], real: Array<typeof eni | typeof q | ReturnType<typeof withDriver>>) {
|
|
556
|
+
const mock = createMockCloudExecutor({ stacks: { net: { changes: previewed } } });
|
|
557
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
558
|
+
const planned = await capability.plan!(ctx, input());
|
|
559
|
+
mock.setStack("net", { changes: real });
|
|
560
|
+
const result = await capability.run({ ...ctx, plans: { net: planned.artifact } }, { stack: "net", template, inputs: { SubnetId: "subnet-1" } }).catch((e: unknown) => e);
|
|
561
|
+
return { mock, result };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
it("executes when the real change matches the preview", async () => {
|
|
565
|
+
const { mock, result } = await previewThenRun([withDriver("SubnetId")], [withDriver("SubnetId")]);
|
|
566
|
+
expect(result).toMatchObject({ stackStatus: expect.any(String) });
|
|
567
|
+
expect(methods(mock)).toContain("executeChangeSet");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("executes when an unknown-driven property drops out because the real value equals the current one", async () => {
|
|
571
|
+
const { mock, result } = await previewThenRun([withDriver("SubnetId")], [{ ...eni, details: ["Description"] }]);
|
|
572
|
+
expect(result).toMatchObject({ stackStatus: expect.any(String) });
|
|
573
|
+
expect(methods(mock)).toContain("executeChangeSet");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("refuses, executing nothing, when the real change adds a resource", async () => {
|
|
577
|
+
const { mock, result } = await previewThenRun([withDriver("SubnetId")], [withDriver("SubnetId"), q]);
|
|
578
|
+
expect(result).toBeInstanceOf(PlanShapeMovedError);
|
|
579
|
+
expect((result as Error).message).toContain("net now plans to create Q, which the first plan did not");
|
|
580
|
+
expect((result as { planMoved?: boolean }).planMoved).toBe(true);
|
|
581
|
+
expect(methods(mock)).not.toContain("executeChangeSet");
|
|
582
|
+
expect(methods(mock).at(-1)).toBe("deleteChangeSet");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("refuses when a property no unknown input drives stops changing", async () => {
|
|
586
|
+
const { mock, result } = await previewThenRun([withDriver("SubnetId")], [{ ...eni, details: ["SubnetId"] }]);
|
|
587
|
+
expect(result).toBeInstanceOf(PlanShapeMovedError);
|
|
588
|
+
expect((result as Error).message).toContain("no longer changes Description on Eni");
|
|
589
|
+
expect(methods(mock)).not.toContain("executeChangeSet");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("refuses when the real change replaces and the preview did not", async () => {
|
|
593
|
+
const { result } = await previewThenRun([withDriver("SubnetId")], [{ ...withDriver("SubnetId"), replacementMode: "Conditional" as never }]);
|
|
594
|
+
expect(result).toBeInstanceOf(PlanShapeMovedError);
|
|
595
|
+
expect((result as Error).message).toContain("may now replace Eni, which the first plan did not show");
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe("pr-apply through AWS and core together: a previewed member in a round (#4422, #4423)", () => {
|
|
600
|
+
const queueAdd = { action: "Add" as const, logicalResourceId: "clusterAQueue", resourceType: "AWS::SQS::Queue", replacement: false };
|
|
601
|
+
const extraAdd = { action: "Add" as const, logicalResourceId: "Extra", resourceType: "AWS::SNS::Topic", replacement: false };
|
|
602
|
+
const approvedFirst = { status: "approved" as const, approvedBy: ["github:rev"] };
|
|
603
|
+
|
|
604
|
+
beforeEach(() => {
|
|
605
|
+
writeFileSync(
|
|
606
|
+
template,
|
|
607
|
+
JSON.stringify({
|
|
608
|
+
Parameters: { TopicArn: { Type: "String" } },
|
|
609
|
+
Resources: { Topic: { Type: "AWS::SNS::Topic" }, clusterAQueue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": "q-${TopicArn}" } } } },
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
const components = (): DriverComponent[] => [
|
|
615
|
+
{ name: "network", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "network", template }] }] },
|
|
616
|
+
{
|
|
617
|
+
name: "cluster-a",
|
|
618
|
+
dependsOn: ["network"],
|
|
619
|
+
deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "cluster-a", template, inputs: { TopicArn: { stackOutput: { stack: "network", name: "TopicArn" } } } }] }],
|
|
620
|
+
},
|
|
621
|
+
];
|
|
622
|
+
|
|
623
|
+
async function planned(stacks: Record<string, FakeStackConfig>) {
|
|
624
|
+
const mock = createMockCloudExecutor({ stacks });
|
|
625
|
+
const registry = new CapabilityRegistry();
|
|
626
|
+
registry.register(createCfnDeployCapability(mock.executor));
|
|
627
|
+
const all = components();
|
|
628
|
+
const plan = planFanOut({ components: all, changed: ["network"] });
|
|
629
|
+
const set = await planPrSet({ components: all, plan, registry, env: "local" });
|
|
630
|
+
return { mock, registry, all, plan, set };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const stacks = (): Record<string, FakeStackConfig> => ({
|
|
634
|
+
network: { outputs: { TopicArn: "arn:topic" }, changes: [{ action: "Add", logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false }] },
|
|
635
|
+
"cluster-a": { changes: [queueAdd] },
|
|
636
|
+
});
|
|
637
|
+
const executed = (mock: ReturnType<typeof createMockCloudExecutor>) =>
|
|
638
|
+
mock.calls.filter((c) => c.method === "executeChangeSet").map((c) => (c.args as { stackName: string }).stackName);
|
|
639
|
+
|
|
640
|
+
it("the real change set has the previewed shape: the first approval covers the round, nothing asks again, and both stacks are executed", async () => {
|
|
641
|
+
const { mock, registry, all, plan, set } = await planned(stacks());
|
|
642
|
+
expect(set.shapes?.["cluster-a"]).toMatchObject({ member: "cluster-a" });
|
|
643
|
+
let asked = 0;
|
|
644
|
+
const applied = await applyPrSet({
|
|
645
|
+
components: all,
|
|
646
|
+
plan,
|
|
647
|
+
registry,
|
|
648
|
+
env: "local",
|
|
649
|
+
set,
|
|
650
|
+
approval: approvedFirst,
|
|
651
|
+
decideRound: async () => {
|
|
652
|
+
asked++;
|
|
653
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
expect(asked).toBe(0);
|
|
657
|
+
expect(applied.status).toBe("applied");
|
|
658
|
+
expect(executed(mock)).toEqual(["network", "cluster-a"]);
|
|
659
|
+
expect(applied.rounds?.[0]).toMatchObject({ round: 2, gate: "pr-apply-round-2", status: "applied", covered: { digest: set.doc.digest, filledInputs: ["network.TopicArn"] } });
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("a real change set with a resource the preview did not show is not executed: the member waits at pr-apply-round-2, naming the reason and the new digest", async () => {
|
|
663
|
+
const { mock, registry, all, plan, set } = await planned(stacks());
|
|
664
|
+
mock.setStack("cluster-a", { changes: [queueAdd, extraAdd] });
|
|
665
|
+
const seen: Array<{ gate: string; notCovered?: string; digest: string }> = [];
|
|
666
|
+
const applied = await applyPrSet({
|
|
667
|
+
components: all,
|
|
668
|
+
plan,
|
|
669
|
+
registry,
|
|
670
|
+
env: "local",
|
|
671
|
+
set,
|
|
672
|
+
approval: approvedFirst,
|
|
673
|
+
decideRound: async ({ gate, notCovered, set: round }) => {
|
|
674
|
+
seen.push({ gate, ...(notCovered ? { notCovered } : {}), digest: round.doc.digest });
|
|
675
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
expect(applied.status).toBe("waiting");
|
|
679
|
+
expect(executed(mock)).toEqual(["network"]);
|
|
680
|
+
expect(seen).toHaveLength(1);
|
|
681
|
+
expect(seen[0]).toMatchObject({ gate: "pr-apply-round-2", notCovered: "cluster-a now plans to create Extra, which the first plan did not" });
|
|
682
|
+
expect(applied.rounds?.[0]).toMatchObject({ round: 2, gate: "pr-apply-round-2", status: "waiting", digest: seen[0]!.digest, notCovered: seen[0]!.notCovered });
|
|
683
|
+
expect(applied.rounds?.[0]?.digest).not.toBe(set.doc.digest);
|
|
684
|
+
expect(applied.members.find((m) => m.member === "cluster-a")).toMatchObject({ status: "waiting", round: 2 });
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("a failure part way leaves what applied, applied, and the report says which member failed", async () => {
|
|
688
|
+
const { mock, registry, all, plan, set } = await planned(stacks());
|
|
689
|
+
const run = mock.executor.cloudformation.waitForStack;
|
|
690
|
+
mock.executor.cloudformation.waitForStack = async (name: string, ...rest: never[]) => {
|
|
691
|
+
if (name === "cluster-a") throw new Error("cluster-a: CREATE_FAILED");
|
|
692
|
+
return (run as (n: string) => ReturnType<typeof run>)(name, ...(rest as []));
|
|
693
|
+
};
|
|
694
|
+
const applied = await applyPrSet({ components: all, plan, registry, env: "local", set, approval: approvedFirst, decideRound: async () => ({ satisfied: false, approval: { status: "pending" } }) });
|
|
695
|
+
expect(applied.status).toBe("failed");
|
|
696
|
+
expect(applied.members.map((m) => [m.member, m.status])).toEqual([["cluster-a", "failed"], ["network", "applied"]]);
|
|
697
|
+
});
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
describe("the preview's digest and its cleanup (#4423)", () => {
|
|
701
|
+
const eni = { action: "Modify" as const, logicalResourceId: "Eni", resourceType: "AWS::EC2::NetworkInterface", replacement: false, replacementMode: "False" as const };
|
|
702
|
+
const driven = (property: string) => ({
|
|
703
|
+
...eni,
|
|
704
|
+
details: [property],
|
|
705
|
+
drivers: [{ property, changeSource: "ParameterReference", causingEntity: "SubnetId" }],
|
|
706
|
+
});
|
|
707
|
+
const input = (): CfnDeployInput => ({ stack: "net", template, inputs: { SubnetId: undefined as never } });
|
|
708
|
+
|
|
709
|
+
beforeEach(() => {
|
|
710
|
+
writeFileSync(
|
|
711
|
+
template,
|
|
712
|
+
JSON.stringify({
|
|
713
|
+
Parameters: { SubnetId: { Type: "AWS::EC2::Subnet::Id" } },
|
|
714
|
+
Resources: { Eni: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { Ref: "SubnetId" }, Description: "d" } } },
|
|
715
|
+
}),
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const preview = async (config: FakeStackConfig, unresolved = ["vpc"]) => {
|
|
720
|
+
const mock = createMockCloudExecutor({ stacks: { net: config } });
|
|
721
|
+
const out = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: unresolved }, input());
|
|
722
|
+
return { mock, out };
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
it("the same inputs and template give the same digest across two plans; another preview shape gives another", async () => {
|
|
726
|
+
const a = await preview({ changes: [driven("SubnetId")] });
|
|
727
|
+
const b = await preview({ changes: [driven("SubnetId")] });
|
|
728
|
+
expect(a.out.artifact).toMatchObject({ kind: "cfn-deploy-shape" });
|
|
729
|
+
expect(b.out.planDigest).toBe(a.out.planDigest);
|
|
730
|
+
expect(b.out.changeSet!.member.planDigest).toBe(a.out.planDigest);
|
|
731
|
+
// The preview's digest is in it: a preview that shows another change set is another plan.
|
|
732
|
+
const other = await preview({ changes: [{ ...driven("SubnetId"), replacement: true, replacementMode: "True" as never }] });
|
|
733
|
+
expect(other.out.planDigest).not.toBe(a.out.planDigest);
|
|
734
|
+
// A member left a hole has the digest of its inputs alone.
|
|
735
|
+
const hole = await createCfnDeployCapability(createMockCloudExecutor({ stacks: { net: {} } }).executor).plan!({ ...ctx, unresolvedInputs: ["up"] }, { stack: "net", template });
|
|
736
|
+
expect(hole.planDigest).not.toBe(a.out.planDigest);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("a preview that CloudFormation fails after creating the change set deletes the change set and the empty stack, and stays a hole", async () => {
|
|
740
|
+
const mock = createMockCloudExecutor({ stacks: { net: { missing: true, changes: [driven("SubnetId")] } } });
|
|
741
|
+
const create = mock.executor.cloudformation.createChangeSet;
|
|
742
|
+
mock.executor.cloudformation.createChangeSet = async (args) => ({ ...(await create(args)), status: "FAILED", statusReason: "Parameter value for SubnetId is not valid", changes: [] });
|
|
743
|
+
const out = await createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, input());
|
|
744
|
+
expect(out.artifact).toBeUndefined();
|
|
745
|
+
expect(out.changeSet!.member.holes[0]!.reason).toMatch(/no preview: CloudFormation could not create the change set with stand-in values: Parameter value for SubnetId is not valid/);
|
|
746
|
+
expect(methods(mock)).toEqual(["createChangeSet", "deleteChangeSet", "deleteStackShell"]);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("a preview that throws while reading the change set still deletes the change set and the empty stack", async () => {
|
|
750
|
+
const mock = createMockCloudExecutor({ stacks: { net: { missing: true, changes: [driven("SubnetId")] } } });
|
|
751
|
+
const create = mock.executor.cloudformation.createChangeSet;
|
|
752
|
+
mock.executor.cloudformation.createChangeSet = async (args) => {
|
|
753
|
+
const cs = await create(args);
|
|
754
|
+
return { ...cs, get changes(): never { throw new Error("could not read the change set"); } } as never;
|
|
755
|
+
};
|
|
756
|
+
await expect(createCfnDeployCapability(mock.executor).plan!({ ...ctx, unresolvedInputs: ["vpc"] }, input())).rejects.toThrow(/could not read the change set/);
|
|
757
|
+
expect(methods(mock)).toEqual(["createChangeSet", "deleteChangeSet", "deleteStackShell"]);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
@@ -54,6 +54,13 @@ export interface CfnChange {
|
|
|
54
54
|
replacementMode?: "True" | "False" | "Conditional";
|
|
55
55
|
/** Resource-specific detail, when the executor's changeset backend provides it (e.g. property/scope). */
|
|
56
56
|
details?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* What CloudFormation says drives each property change, when it says: the
|
|
59
|
+
* property, `ChangeSource` and `CausingEntity`. A property driven by a
|
|
60
|
+
* parameter reads `ParameterReference` with the parameter's name, which is
|
|
61
|
+
* how a preview marks it known after apply (#4419).
|
|
62
|
+
*/
|
|
63
|
+
drivers?: Array<{ property: string; changeSource?: string; causingEntity?: string }>;
|
|
57
64
|
/**
|
|
58
65
|
* The logical ids of the nested stacks this change sits in, outermost
|
|
59
66
|
* first (#3997). Absent for a change to the stack's own resources. Set when
|
|
@@ -74,8 +81,8 @@ export interface CfnChangeSet {
|
|
|
74
81
|
statusReason?: string;
|
|
75
82
|
/** True the first time this stack is created (no prior stack exists). */
|
|
76
83
|
isCreate: boolean;
|
|
77
|
-
/** The change set's type: CREATE for a stack that does not exist yet or was never executed, else UPDATE. */
|
|
78
|
-
changeSetType?: "CREATE" | "UPDATE";
|
|
84
|
+
/** The change set's type: CREATE for a stack that does not exist yet or was never executed, else UPDATE; IMPORT for an adoption (#4536). */
|
|
85
|
+
changeSetType?: "CREATE" | "UPDATE" | "IMPORT";
|
|
79
86
|
/**
|
|
80
87
|
* True when no stack existed before this change set, so CloudFormation made
|
|
81
88
|
* an empty `REVIEW_IN_PROGRESS` stack to hold it. A plan that deletes the
|
|
@@ -92,6 +99,18 @@ export interface CfnCreateChangeSetArgs {
|
|
|
92
99
|
parameters?: Record<string, string>;
|
|
93
100
|
}
|
|
94
101
|
|
|
102
|
+
/** One existing resource an IMPORT change set brings under a stack (#4536). */
|
|
103
|
+
export interface CfnResourceToImport {
|
|
104
|
+
resourceType: string;
|
|
105
|
+
logicalResourceId: string;
|
|
106
|
+
/** The type's primary identifier properties and their values (`{ BucketName: "logs" }`). */
|
|
107
|
+
resourceIdentifier: Record<string, string>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface CfnCreateImportChangeSetArgs extends CfnCreateChangeSetArgs {
|
|
111
|
+
resourcesToImport: CfnResourceToImport[];
|
|
112
|
+
}
|
|
113
|
+
|
|
95
114
|
export interface CfnExecuteChangeSetArgs {
|
|
96
115
|
stackName: string;
|
|
97
116
|
changeSetName: string;
|
|
@@ -100,11 +119,21 @@ export interface CfnExecuteChangeSetArgs {
|
|
|
100
119
|
export interface CfnStackStatus {
|
|
101
120
|
stackStatus: string;
|
|
102
121
|
outputs: Record<string, string>;
|
|
122
|
+
/** The stack's exports by export name (#4466c): the output each is and its value. Absent when it exports nothing or the backend does not say. */
|
|
123
|
+
exports?: Record<string, { output: string; value: string }>;
|
|
103
124
|
}
|
|
104
125
|
|
|
105
126
|
export interface CloudFormationClient {
|
|
106
127
|
/** Create (but do not execute) a changeset, returning its proposed changes for preview/safety checks. */
|
|
107
128
|
createChangeSet(args: CfnCreateChangeSetArgs): Promise<CfnChangeSet>;
|
|
129
|
+
/**
|
|
130
|
+
* Create (but do not execute) an IMPORT change set that brings existing
|
|
131
|
+
* resources under the stack (#4536, chant's CloudFormation adoption). A
|
|
132
|
+
* stack that does not exist yet is created by it. No stack tags are sent:
|
|
133
|
+
* chant's markers are written by the UPDATE that follows. Optional: a
|
|
134
|
+
* client without it cannot adopt.
|
|
135
|
+
*/
|
|
136
|
+
createImportChangeSet?(args: CfnCreateImportChangeSetArgs): Promise<CfnChangeSet>;
|
|
108
137
|
/** Execute a previously created changeset. */
|
|
109
138
|
executeChangeSet(args: CfnExecuteChangeSetArgs): Promise<void>;
|
|
110
139
|
/** Delete a changeset without executing it (used when a safety policy blocks the apply). */
|
|
@@ -122,6 +151,12 @@ export interface CloudFormationClient {
|
|
|
122
151
|
* empty stack, and the next CREATE change set reuses it.
|
|
123
152
|
*/
|
|
124
153
|
deleteStackShell?(stackName: string): Promise<boolean>;
|
|
154
|
+
/**
|
|
155
|
+
* The stacks that import `exportName` now (`ListImports`, #4466c), sorted;
|
|
156
|
+
* empty when none does. Optional: without it, a plan cannot say whether a
|
|
157
|
+
* change to an export is in use, and refuses nothing.
|
|
158
|
+
*/
|
|
159
|
+
listImports?(exportName: string): Promise<string[]>;
|
|
125
160
|
}
|
|
126
161
|
|
|
127
162
|
// ── ECS ──────────────────────────────────────────────────────────────────��─
|
|
@@ -461,7 +496,7 @@ export interface DescribedCfnChangeSet {
|
|
|
461
496
|
LogicalResourceId: string;
|
|
462
497
|
ResourceType: string;
|
|
463
498
|
Replacement?: string;
|
|
464
|
-
Details?: Array<{ Target?: { Name?: string } }>;
|
|
499
|
+
Details?: Array<{ Target?: { Name?: string }; ChangeSource?: string | null; CausingEntity?: string | null }>;
|
|
465
500
|
/** Set on an `AWS::CloudFormation::Stack` change when the change set includes nested stacks: the nested change set's ARN. */
|
|
466
501
|
ChangeSetId?: string | null;
|
|
467
502
|
};
|
|
@@ -498,6 +533,15 @@ export async function readCfnChanges(
|
|
|
498
533
|
replacement: rc.Replacement === "True",
|
|
499
534
|
...(rc.Replacement === "True" || rc.Replacement === "False" || rc.Replacement === "Conditional" ? { replacementMode: rc.Replacement } : {}),
|
|
500
535
|
details: rc.Details?.map((d) => d.Target?.Name).filter((x): x is string => !!x),
|
|
536
|
+
...(rc.Details?.some((d) => d.Target?.Name && (d.ChangeSource || d.CausingEntity))
|
|
537
|
+
? {
|
|
538
|
+
drivers: rc.Details.filter((d) => d.Target?.Name).map((d) => ({
|
|
539
|
+
property: d.Target!.Name!,
|
|
540
|
+
...(d.ChangeSource ? { changeSource: d.ChangeSource } : {}),
|
|
541
|
+
...(d.CausingEntity ? { causingEntity: d.CausingEntity } : {}),
|
|
542
|
+
})),
|
|
543
|
+
}
|
|
544
|
+
: {}),
|
|
501
545
|
...(nestedStack.length > 0 ? { nestedStack } : {}),
|
|
502
546
|
});
|
|
503
547
|
if (rc.ResourceType === "AWS::CloudFormation::Stack" && rc.ChangeSetId) {
|
|
@@ -561,6 +605,53 @@ const realCloudFormation: CloudFormationClient = {
|
|
|
561
605
|
changes: await readCfnChanges(described, describeNested),
|
|
562
606
|
};
|
|
563
607
|
},
|
|
608
|
+
async createImportChangeSet(args) {
|
|
609
|
+
const changeSetName = `cs-import-${Date.now()}`;
|
|
610
|
+
const params = Object.entries(args.parameters ?? {})
|
|
611
|
+
.map(([k, v]) => `ParameterKey=${k},ParameterValue=${v}`)
|
|
612
|
+
.join(" ");
|
|
613
|
+
const paramFlag = params ? ` --parameters ${params}` : "";
|
|
614
|
+
let body = "";
|
|
615
|
+
try {
|
|
616
|
+
body = readFileSync(args.templatePath, "utf8");
|
|
617
|
+
} catch { /* unreadable template — create-change-set reports it */ }
|
|
618
|
+
const capabilities = awsDeployCapabilitiesForBody(body).join(" ");
|
|
619
|
+
const { existed } = await cfnChangeSetType(args.stackName);
|
|
620
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-cfn-import-"));
|
|
621
|
+
try {
|
|
622
|
+
const resourcesFile = join(dir, "resources-to-import.json");
|
|
623
|
+
writeFileSync(
|
|
624
|
+
resourcesFile,
|
|
625
|
+
JSON.stringify(
|
|
626
|
+
args.resourcesToImport.map((r) => ({ ResourceType: r.resourceType, LogicalResourceId: r.logicalResourceId, ResourceIdentifier: r.resourceIdentifier })),
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
await run(
|
|
630
|
+
`aws cloudformation create-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)} ` +
|
|
631
|
+
`--change-set-type IMPORT --resources-to-import file://${resourcesFile} ` +
|
|
632
|
+
`--template-body file://${args.templatePath} --capabilities ${capabilities}${paramFlag}`,
|
|
633
|
+
);
|
|
634
|
+
} finally {
|
|
635
|
+
rmSync(dir, { recursive: true, force: true });
|
|
636
|
+
}
|
|
637
|
+
await run(
|
|
638
|
+
`aws cloudformation wait change-set-create-complete --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)}`,
|
|
639
|
+
).catch(() => undefined);
|
|
640
|
+
const { stdout } = await run(
|
|
641
|
+
`aws cloudformation describe-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)}`,
|
|
642
|
+
);
|
|
643
|
+
const described = JSON.parse(stdout) as DescribedCfnChangeSet;
|
|
644
|
+
return {
|
|
645
|
+
changeSetName,
|
|
646
|
+
stackName: args.stackName,
|
|
647
|
+
status: described.Status,
|
|
648
|
+
statusReason: described.StatusReason ?? undefined,
|
|
649
|
+
isCreate: false,
|
|
650
|
+
changeSetType: "IMPORT",
|
|
651
|
+
stackCreated: !existed,
|
|
652
|
+
changes: await readCfnChanges(described, async () => ({ Status: "CREATE_COMPLETE" })),
|
|
653
|
+
};
|
|
654
|
+
},
|
|
564
655
|
async executeChangeSet(args) {
|
|
565
656
|
await run(
|
|
566
657
|
`aws cloudformation execute-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(args.changeSetName)}`,
|
|
@@ -574,12 +665,34 @@ const realCloudFormation: CloudFormationClient = {
|
|
|
574
665
|
async describeStack(stackName) {
|
|
575
666
|
const { stdout } = await run(`aws cloudformation describe-stacks --stack-name ${q(stackName)}`);
|
|
576
667
|
const described = JSON.parse(stdout) as {
|
|
577
|
-
Stacks: Array<{ StackStatus: string; Outputs?: Array<{ OutputKey: string; OutputValue: string }> }>;
|
|
668
|
+
Stacks: Array<{ StackStatus: string; Outputs?: Array<{ OutputKey: string; OutputValue: string; ExportName?: string }> }>;
|
|
578
669
|
};
|
|
579
670
|
const stack = described.Stacks[0];
|
|
580
671
|
const outputs: Record<string, string> = {};
|
|
581
|
-
|
|
582
|
-
|
|
672
|
+
const exports: Record<string, { output: string; value: string }> = {};
|
|
673
|
+
for (const o of stack?.Outputs ?? []) {
|
|
674
|
+
outputs[o.OutputKey] = o.OutputValue;
|
|
675
|
+
if (o.ExportName) exports[o.ExportName] = { output: o.OutputKey, value: o.OutputValue };
|
|
676
|
+
}
|
|
677
|
+
return { stackStatus: stack?.StackStatus ?? "UNKNOWN", outputs, ...(Object.keys(exports).length > 0 ? { exports } : {}) };
|
|
678
|
+
},
|
|
679
|
+
async listImports(exportName) {
|
|
680
|
+
const stacks: string[] = [];
|
|
681
|
+
let token: string | undefined;
|
|
682
|
+
do {
|
|
683
|
+
let stdout: string;
|
|
684
|
+
try {
|
|
685
|
+
({ stdout } = await run(`aws cloudformation list-imports --export-name ${q(exportName)}${token ? ` --next-token ${q(token)}` : ""}`));
|
|
686
|
+
} catch (err) {
|
|
687
|
+
// CloudFormation answers an export nobody imports with an error, not an empty list.
|
|
688
|
+
if (/is not imported by any stack/i.test(err instanceof Error ? err.message : String(err))) return [];
|
|
689
|
+
throw err;
|
|
690
|
+
}
|
|
691
|
+
const page = JSON.parse(stdout) as { Imports?: string[]; NextToken?: string };
|
|
692
|
+
stacks.push(...(page.Imports ?? []));
|
|
693
|
+
token = page.NextToken;
|
|
694
|
+
} while (token);
|
|
695
|
+
return [...new Set(stacks)].sort();
|
|
583
696
|
},
|
|
584
697
|
async waitForStack(stackName, opts) {
|
|
585
698
|
const intervalMs = opts?.intervalMs ?? 10_000;
|