@intentius/chant-lexicon-aws 0.124.50 → 0.124.52
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/components/apply.d.ts +33 -2
- package/dist/components/apply.d.ts.map +1 -1
- package/dist/components/apply.js +241 -16
- package/dist/components/cloud-executor.d.ts +13 -0
- package/dist/components/cloud-executor.d.ts.map +1 -1
- package/dist/components/cloud-executor.js +9 -0
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -2
- package/src/components/apply.ts +276 -16
- package/src/components/cfn-plan-floci.e2e.test.ts +53 -0
- package/src/components/cfn-plan.test.ts +328 -9
- package/src/components/cloud-executor.ts +17 -1
|
@@ -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
|
|
@@ -461,7 +468,7 @@ export interface DescribedCfnChangeSet {
|
|
|
461
468
|
LogicalResourceId: string;
|
|
462
469
|
ResourceType: string;
|
|
463
470
|
Replacement?: string;
|
|
464
|
-
Details?: Array<{ Target?: { Name?: string } }>;
|
|
471
|
+
Details?: Array<{ Target?: { Name?: string }; ChangeSource?: string | null; CausingEntity?: string | null }>;
|
|
465
472
|
/** Set on an `AWS::CloudFormation::Stack` change when the change set includes nested stacks: the nested change set's ARN. */
|
|
466
473
|
ChangeSetId?: string | null;
|
|
467
474
|
};
|
|
@@ -498,6 +505,15 @@ export async function readCfnChanges(
|
|
|
498
505
|
replacement: rc.Replacement === "True",
|
|
499
506
|
...(rc.Replacement === "True" || rc.Replacement === "False" || rc.Replacement === "Conditional" ? { replacementMode: rc.Replacement } : {}),
|
|
500
507
|
details: rc.Details?.map((d) => d.Target?.Name).filter((x): x is string => !!x),
|
|
508
|
+
...(rc.Details?.some((d) => d.Target?.Name && (d.ChangeSource || d.CausingEntity))
|
|
509
|
+
? {
|
|
510
|
+
drivers: rc.Details.filter((d) => d.Target?.Name).map((d) => ({
|
|
511
|
+
property: d.Target!.Name!,
|
|
512
|
+
...(d.ChangeSource ? { changeSource: d.ChangeSource } : {}),
|
|
513
|
+
...(d.CausingEntity ? { causingEntity: d.CausingEntity } : {}),
|
|
514
|
+
})),
|
|
515
|
+
}
|
|
516
|
+
: {}),
|
|
501
517
|
...(nestedStack.length > 0 ? { nestedStack } : {}),
|
|
502
518
|
});
|
|
503
519
|
if (rc.ResourceType === "AWS::CloudFormation::Stack" && rc.ChangeSetId) {
|