@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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An export another stack still imports (#4466c): CloudFormation refuses to
|
|
3
|
+
* change or remove it at apply, so a plan that would is refused at plan
|
|
4
|
+
* time, naming both stacks. A change the plan cannot say about is a hole.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { CapabilityRegistry } from "@intentius/chant/components/capability";
|
|
12
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
13
|
+
import { planFanOut } from "@intentius/chant/components/fan-out";
|
|
14
|
+
import { planPrSet } from "@intentius/chant/components/pr-run";
|
|
15
|
+
import { CfnExportInUseError, createCfnDeployCapability } from "./apply";
|
|
16
|
+
import { createMockCloudExecutor, type FakeStackConfig } from "./__tests__/mock-cloud-executor";
|
|
17
|
+
|
|
18
|
+
let dir: string;
|
|
19
|
+
let template: string;
|
|
20
|
+
const write = (t: unknown) => writeFileSync(template, JSON.stringify(t));
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
dir = mkdtempSync(join(tmpdir(), "cfn-exports-"));
|
|
24
|
+
template = join(dir, "network.json");
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
27
|
+
|
|
28
|
+
const exported = (value: unknown = { Ref: "Vpc" }) => ({
|
|
29
|
+
Resources: { Vpc: { Type: "AWS::EC2::VPC" }, Topic: { Type: "AWS::SNS::Topic" } },
|
|
30
|
+
Outputs: { VpcId: { Value: value, Export: { Name: { "Fn::Sub": "${AWS::StackName}-VpcId" } } } },
|
|
31
|
+
});
|
|
32
|
+
const topicChange = { action: "Modify" as const, logicalResourceId: "Topic", resourceType: "AWS::SNS::Topic", replacement: false, replacementMode: "False" as const };
|
|
33
|
+
const network = (extra: Partial<FakeStackConfig> = {}, importers: string[] = ["app"]): FakeStackConfig => ({
|
|
34
|
+
outputs: { VpcId: "vpc-1" },
|
|
35
|
+
exports: { "network-VpcId": "VpcId" },
|
|
36
|
+
importedBy: { "network-VpcId": importers },
|
|
37
|
+
changes: [topicChange],
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function plan(config: FakeStackConfig, extra: Record<string, unknown> = {}) {
|
|
42
|
+
const mock = createMockCloudExecutor({ stacks: { network: config } });
|
|
43
|
+
const result = await createCfnDeployCapability(mock.executor)
|
|
44
|
+
.plan!({ env: "local", component: "network" }, { stack: "network", template, ...extra })
|
|
45
|
+
.catch((e: unknown) => e);
|
|
46
|
+
return { mock, result };
|
|
47
|
+
}
|
|
48
|
+
const methods = (mock: ReturnType<typeof createMockCloudExecutor>) => mock.calls.map((c) => c.method);
|
|
49
|
+
|
|
50
|
+
describe("a plan that changes or removes an export in use (#4466c)", () => {
|
|
51
|
+
it("removing an export app imports is refused at plan time, naming both stacks, and nothing is executed", async () => {
|
|
52
|
+
write({ Resources: { Vpc: { Type: "AWS::EC2::VPC" }, Topic: { Type: "AWS::SNS::Topic" } } });
|
|
53
|
+
const { mock, result } = await plan(network());
|
|
54
|
+
expect(result).toBeInstanceOf(CfnExportInUseError);
|
|
55
|
+
expect((result as CfnExportInUseError).uses).toEqual([{ exportName: "network-VpcId", change: "removes {export}", importers: ["app"] }]);
|
|
56
|
+
expect((result as Error).message).toBe(
|
|
57
|
+
'cfn-deploy "network": refusing the plan: it removes export network-VpcId, which stack app still imports. ' +
|
|
58
|
+
"CloudFormation refuses to change or remove an export another stack imports. Change app to stop importing it first, then change network.",
|
|
59
|
+
);
|
|
60
|
+
expect(methods(mock)).toContain("deleteChangeSet");
|
|
61
|
+
expect(methods(mock)).not.toContain("executeChangeSet");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("changing an export's value is refused, with the value it has and the one it would get", async () => {
|
|
65
|
+
write(exported("vpc-2"));
|
|
66
|
+
const { result } = await plan(network());
|
|
67
|
+
expect(result).toBeInstanceOf(CfnExportInUseError);
|
|
68
|
+
expect((result as Error).message).toContain("it changes export network-VpcId from vpc-1 to vpc-2, which stack app still imports");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("renaming an export is removing it", async () => {
|
|
72
|
+
write({ ...exported(), Outputs: { VpcId: { Value: { Ref: "Vpc" }, Export: { Name: "renamed-VpcId" } } } });
|
|
73
|
+
const { result } = await plan(network());
|
|
74
|
+
expect((result as CfnExportInUseError).uses?.[0]).toMatchObject({ exportName: "network-VpcId", change: "removes {export}" });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("every importer is named", async () => {
|
|
78
|
+
write({ Resources: { Topic: { Type: "AWS::SNS::Topic" } } });
|
|
79
|
+
const { result } = await plan(network({}, ["edge", "app"]));
|
|
80
|
+
expect((result as Error).message).toContain("which stacks app, edge still import");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("an export whose value stays plans as before, and only that export's importers are asked for", async () => {
|
|
84
|
+
write(exported());
|
|
85
|
+
const { mock, result } = await plan(network());
|
|
86
|
+
expect(result).not.toBeInstanceOf(Error);
|
|
87
|
+
expect((result as { changeSet: { member: { holes: unknown[] } } }).changeSet.member.holes).toEqual([]);
|
|
88
|
+
expect(methods(mock)).not.toContain("listImports");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("an export nobody imports can change", async () => {
|
|
92
|
+
write(exported("vpc-2"));
|
|
93
|
+
const { result } = await plan(network({}, []));
|
|
94
|
+
expect(result).not.toBeInstanceOf(Error);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("an export whose new value is known only after apply is a hole naming its importers, not a refusal", async () => {
|
|
98
|
+
write(exported());
|
|
99
|
+
const { result } = await plan(
|
|
100
|
+
network({ changes: [{ action: "Modify", logicalResourceId: "Vpc", resourceType: "AWS::EC2::VPC", replacement: true, replacementMode: "True" }] }),
|
|
101
|
+
{ onReplace: "allow" },
|
|
102
|
+
);
|
|
103
|
+
expect(result).not.toBeInstanceOf(Error);
|
|
104
|
+
expect((result as { changeSet: { member: { holes: unknown[] } } }).changeSet.member.holes).toEqual([
|
|
105
|
+
{
|
|
106
|
+
address: "Outputs.VpcId",
|
|
107
|
+
reason:
|
|
108
|
+
"the plan may change export network-VpcId: the value of output VpcId is known only after apply, and app still imports it; CloudFormation refuses the apply if it changes",
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("in a pull request's plan (#4466c)", () => {
|
|
115
|
+
it("the exporter's member fails to plan with the refusal, and the set is failed", async () => {
|
|
116
|
+
write({ Resources: { Topic: { Type: "AWS::SNS::Topic" } } });
|
|
117
|
+
const mock = createMockCloudExecutor({ stacks: { network: network() } });
|
|
118
|
+
const registry = new CapabilityRegistry();
|
|
119
|
+
registry.register(createCfnDeployCapability(mock.executor));
|
|
120
|
+
const components: DriverComponent[] = [
|
|
121
|
+
{ name: "network", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "network", template }] }] },
|
|
122
|
+
];
|
|
123
|
+
const set = await planPrSet({ components, plan: planFanOut({ components, changed: ["network"] }), registry, env: "local" });
|
|
124
|
+
expect(set.failed).toBe(true);
|
|
125
|
+
const member = set.members.find((m) => m.member === "network")!;
|
|
126
|
+
expect(member.status).toBe("plan-failed");
|
|
127
|
+
expect(member.error).toContain("it removes export network-VpcId, which stack app still imports");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("a backend that cannot list imports (#4466d)", () => {
|
|
132
|
+
it("is a hole on the export at stake, not a failed plan", async () => {
|
|
133
|
+
write({ Resources: { Topic: { Type: "AWS::SNS::Topic" } } });
|
|
134
|
+
const mock = createMockCloudExecutor({ stacks: { network: network() } });
|
|
135
|
+
mock.executor.cloudformation.listImports = async () => {
|
|
136
|
+
throw new Error("UnknownAction: ListImports");
|
|
137
|
+
};
|
|
138
|
+
const result = await createCfnDeployCapability(mock.executor).plan!({ env: "local", component: "network" }, { stack: "network", template });
|
|
139
|
+
expect(result.changeSet!.member.holes).toEqual([
|
|
140
|
+
{
|
|
141
|
+
address: "Outputs.VpcId",
|
|
142
|
+
reason: "the plan removes export network-VpcId, and the stacks that import it could not be listed: UnknownAction: ListImports",
|
|
143
|
+
},
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The preview of an importer whose export is not known yet (#4466b): its
|
|
3
|
+
* change set is made from a copy of its template in which each such import
|
|
4
|
+
* is a stand-in parameter (`cfnPlaceholder`), with entries and a PlanShape
|
|
5
|
+
* as #4423 gives a member whose parameters are unknown. Once the exporter
|
|
6
|
+
* applies, the real change set is checked with `planShapeDifferences`
|
|
7
|
+
* before it executes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { CapabilityRegistry } from "@intentius/chant/components/capability";
|
|
15
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
16
|
+
import { withDeclaredReads } from "@intentius/chant/components/declared-reads";
|
|
17
|
+
import { planFanOut } from "@intentius/chant/components/fan-out";
|
|
18
|
+
import { applyPrSet, planPrSet } from "@intentius/chant/components/pr-run";
|
|
19
|
+
import { CFN_UNKNOWN_MARK, createCfnDeployCapability } from "./apply";
|
|
20
|
+
import { cfnStandInParameter, cfnTemplateWithStandIns } from "./cfn-imports";
|
|
21
|
+
import { createMockCloudExecutor, type FakeStackConfig } from "./__tests__/mock-cloud-executor";
|
|
22
|
+
|
|
23
|
+
let dir: string;
|
|
24
|
+
const write = (name: string, template: unknown): string => {
|
|
25
|
+
const path = join(dir, name);
|
|
26
|
+
writeFileSync(path, JSON.stringify(template));
|
|
27
|
+
return path;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), "cfn-imports-preview-"));
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
34
|
+
|
|
35
|
+
const NETWORK = {
|
|
36
|
+
Resources: { Vpc: { Type: "AWS::EC2::VPC" } },
|
|
37
|
+
Outputs: { VpcId: { Value: { Ref: "Vpc" }, Export: { Name: { "Fn::Sub": "${AWS::StackName}-VpcId" } } } },
|
|
38
|
+
};
|
|
39
|
+
const APP = {
|
|
40
|
+
Resources: {
|
|
41
|
+
Group: { Type: "AWS::EC2::SecurityGroup", Properties: { GroupDescription: "app", VpcId: { "Fn::ImportValue": "network-VpcId" } } },
|
|
42
|
+
},
|
|
43
|
+
Outputs: { Vpc: { Value: { "Fn::ImportValue": "network-VpcId" } } },
|
|
44
|
+
};
|
|
45
|
+
const STAND_IN = cfnStandInParameter("network-VpcId");
|
|
46
|
+
|
|
47
|
+
describe("the template copy (#4466b)", () => {
|
|
48
|
+
it("each import of a waiting name is a Ref to a String stand-in parameter; other imports stay", () => {
|
|
49
|
+
const template = { ...APP, Resources: { ...APP.Resources, Other: { Type: "AWS::SNS::Topic", Properties: { TopicName: { "Fn::ImportValue": "elsewhere" } } } } };
|
|
50
|
+
const { template: copy, standIns } = cfnTemplateWithStandIns(template, ["network-VpcId"], {});
|
|
51
|
+
expect(standIns).toEqual({ [STAND_IN]: "network-VpcId" });
|
|
52
|
+
expect(copy.Resources).toEqual({
|
|
53
|
+
Group: { Type: "AWS::EC2::SecurityGroup", Properties: { GroupDescription: "app", VpcId: { Ref: STAND_IN } } },
|
|
54
|
+
Other: { Type: "AWS::SNS::Topic", Properties: { TopicName: { "Fn::ImportValue": "elsewhere" } } },
|
|
55
|
+
});
|
|
56
|
+
expect(copy.Outputs).toEqual({ Vpc: { Value: { Ref: STAND_IN } } });
|
|
57
|
+
expect((copy.Parameters as Record<string, { Type: string }>)[STAND_IN]!.Type).toBe("String");
|
|
58
|
+
// The original is untouched.
|
|
59
|
+
expect(template.Resources.Group.Properties.VpcId).toEqual({ "Fn::ImportValue": "network-VpcId" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a stand-in name keeps only letters and digits, and two names never share one", () => {
|
|
63
|
+
expect(cfnStandInParameter("a-b")).toMatch(/^ChantImportab[0-9a-f]{8}$/);
|
|
64
|
+
expect(cfnStandInParameter("a-b")).not.toBe(cfnStandInParameter("a:b"));
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("cfn-deploy previews an importer whose export is not known yet (#4466b)", () => {
|
|
69
|
+
it("creates its change set from the copy with a stand-in value, marks what the import drives, and removes the copy", async () => {
|
|
70
|
+
const template = write("app.json", APP);
|
|
71
|
+
const mock = createMockCloudExecutor({
|
|
72
|
+
stacks: { app: { changes: [{ action: "Add", logicalResourceId: "Group", resourceType: "AWS::EC2::SecurityGroup", replacement: false }] } },
|
|
73
|
+
});
|
|
74
|
+
const planned = await createCfnDeployCapability(mock.executor).plan!(
|
|
75
|
+
{ env: "local", component: "app", unresolvedInputs: ["network"] },
|
|
76
|
+
{ stack: "app", template, imports: { "network-VpcId": undefined } },
|
|
77
|
+
);
|
|
78
|
+
const created = mock.calls.find((c) => c.method === "createChangeSet")!.args as { templatePath: string; parameters: Record<string, string> };
|
|
79
|
+
expect(created.templatePath).not.toBe(template);
|
|
80
|
+
expect(created.templatePath).toMatch(/app\.preview\.template\.json$/);
|
|
81
|
+
expect(created.parameters).toEqual({ [STAND_IN]: CFN_UNKNOWN_MARK });
|
|
82
|
+
expect(existsSync(created.templatePath)).toBe(false);
|
|
83
|
+
expect(planned.artifact).toMatchObject({ kind: "cfn-deploy-shape", member: "app" });
|
|
84
|
+
expect(planned.shape).toMatchObject({ member: "app", unknownInputs: ["network"] });
|
|
85
|
+
expect(planned.shape!.entries).toEqual([
|
|
86
|
+
expect.objectContaining({ logicalId: "Group", action: "create", unknown: ["VpcId"] }),
|
|
87
|
+
]);
|
|
88
|
+
expect(planned.changeSet!.member.holes).toEqual([]);
|
|
89
|
+
expect(planned.changeSet!.entries).toHaveLength(1);
|
|
90
|
+
expect(mock.calls.map((c) => c.method)).not.toContain("executeChangeSet");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("the preview's shape enters the digest, so a different preview is a different plan", async () => {
|
|
94
|
+
const template = write("app.json", APP);
|
|
95
|
+
const plan = async (changes: FakeStackConfig["changes"]) =>
|
|
96
|
+
createCfnDeployCapability(createMockCloudExecutor({ stacks: { app: { changes } } }).executor).plan!(
|
|
97
|
+
{ env: "local", component: "app", unresolvedInputs: ["network"] },
|
|
98
|
+
{ stack: "app", template, imports: { "network-VpcId": undefined } },
|
|
99
|
+
);
|
|
100
|
+
const one = await plan([{ action: "Add", logicalResourceId: "Group", resourceType: "AWS::EC2::SecurityGroup", replacement: false }]);
|
|
101
|
+
const two = await plan([
|
|
102
|
+
{ action: "Add", logicalResourceId: "Group", resourceType: "AWS::EC2::SecurityGroup", replacement: false },
|
|
103
|
+
{ action: "Add", logicalResourceId: "Extra", resourceType: "AWS::SNS::Topic", replacement: false },
|
|
104
|
+
]);
|
|
105
|
+
expect(one.planDigest).not.toBe(two.planDigest);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("pr-apply over two stacks wired only by an import (#4466b)", () => {
|
|
110
|
+
const approvedFirst = { status: "approved" as const, approvedBy: ["github:rev"] };
|
|
111
|
+
const groupAdd = { action: "Add" as const, logicalResourceId: "Group", resourceType: "AWS::EC2::SecurityGroup", replacement: false };
|
|
112
|
+
const extraAdd = { action: "Add" as const, logicalResourceId: "Extra", resourceType: "AWS::SNS::Topic", replacement: false };
|
|
113
|
+
const stacks = (): Record<string, FakeStackConfig> => ({
|
|
114
|
+
network: { outputs: { VpcId: "vpc-1" }, changes: [{ action: "Add", logicalResourceId: "Vpc", resourceType: "AWS::EC2::VPC", replacement: false }] },
|
|
115
|
+
app: { changes: [groupAdd] },
|
|
116
|
+
});
|
|
117
|
+
const executed = (mock: ReturnType<typeof createMockCloudExecutor>) =>
|
|
118
|
+
mock.calls.filter((c) => c.method === "executeChangeSet").map((c) => (c.args as { stackName: string }).stackName);
|
|
119
|
+
|
|
120
|
+
async function planned(config: Record<string, FakeStackConfig>) {
|
|
121
|
+
const declared: DriverComponent[] = [
|
|
122
|
+
{ name: "network", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "network", template: write("network.json", NETWORK) }] }] },
|
|
123
|
+
{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "app", template: write("app.json", APP) }] }] },
|
|
124
|
+
];
|
|
125
|
+
const mock = createMockCloudExecutor({ stacks: config });
|
|
126
|
+
const registry = new CapabilityRegistry();
|
|
127
|
+
registry.register(createCfnDeployCapability(mock.executor));
|
|
128
|
+
const components = await withDeclaredReads(declared, registry, dir);
|
|
129
|
+
const plan = planFanOut({ components, changed: ["network", "app"] });
|
|
130
|
+
const set = await planPrSet({ components, plan, registry, env: "local" });
|
|
131
|
+
return { mock, registry, components, plan, set };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
it("the plan previews app with entries and a shape that names the import as unknown", async () => {
|
|
135
|
+
const { set } = await planned(stacks());
|
|
136
|
+
expect(set.shapes?.app).toMatchObject({ member: "app", unknownInputs: ["network"] });
|
|
137
|
+
expect(set.plans.app?.app).toMatchObject({ kind: "cfn-deploy-shape" });
|
|
138
|
+
expect(set.doc.entries.filter((e) => e.member === "app").map((e) => e.address)).toEqual(["Group"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("the real change set has the previewed shape: one approval covers the round, and both stacks execute in order", async () => {
|
|
142
|
+
const { mock, registry, components, plan, set } = await planned(stacks());
|
|
143
|
+
let asked = 0;
|
|
144
|
+
const applied = await applyPrSet({
|
|
145
|
+
components,
|
|
146
|
+
plan,
|
|
147
|
+
registry,
|
|
148
|
+
env: "local",
|
|
149
|
+
set,
|
|
150
|
+
approval: approvedFirst,
|
|
151
|
+
decideRound: async () => {
|
|
152
|
+
asked++;
|
|
153
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
expect(asked).toBe(0);
|
|
157
|
+
expect(applied.status).toBe("applied");
|
|
158
|
+
expect(executed(mock)).toEqual(["network", "app"]);
|
|
159
|
+
expect(applied.rounds?.[0]).toMatchObject({ round: 2, status: "applied", covered: { digest: set.doc.digest, filledInputs: ["network.VpcId"] } });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("a real change set the preview did not show is not executed: app waits at the next round", async () => {
|
|
163
|
+
const { mock, registry, components, plan, set } = await planned(stacks());
|
|
164
|
+
mock.setStack("app", { changes: [groupAdd, extraAdd] });
|
|
165
|
+
const applied = await applyPrSet({
|
|
166
|
+
components,
|
|
167
|
+
plan,
|
|
168
|
+
registry,
|
|
169
|
+
env: "local",
|
|
170
|
+
set,
|
|
171
|
+
approval: approvedFirst,
|
|
172
|
+
decideRound: async () => ({ satisfied: false, approval: { status: "pending" } }),
|
|
173
|
+
});
|
|
174
|
+
expect(applied.status).toBe("waiting");
|
|
175
|
+
expect(executed(mock)).toEqual(["network"]);
|
|
176
|
+
expect(applied.rounds?.[0]).toMatchObject({ round: 2, status: "waiting", notCovered: "app now plans to create Extra, which the first plan did not" });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three stacks wired only by exports and imports need one approval
|
|
3
|
+
* (#4466d), as #4430 shows for parameters: the shape of
|
|
4
|
+
* `examples/demo-composed-stacks`'s `chant-compose-imports` scenario,
|
|
5
|
+
* against the mock executor. The same scenario runs on Floci in
|
|
6
|
+
* demo-composed-stacks-floci.e2e.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { CapabilityRegistry } from "@intentius/chant/components/capability";
|
|
14
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
15
|
+
import { withDeclaredReads } from "@intentius/chant/components/declared-reads";
|
|
16
|
+
import { planFanOut } from "@intentius/chant/components/fan-out";
|
|
17
|
+
import { applyPrSet, planPrSet } from "@intentius/chant/components/pr-run";
|
|
18
|
+
import { createCfnDeployCapability } from "./apply";
|
|
19
|
+
import { createMockCloudExecutor, type FakeStackConfig } from "./__tests__/mock-cloud-executor";
|
|
20
|
+
|
|
21
|
+
let dir: string;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
dir = mkdtempSync(join(tmpdir(), "cfn-imports-three-"));
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
26
|
+
|
|
27
|
+
const TEMPLATES = {
|
|
28
|
+
network: {
|
|
29
|
+
Resources: { mainVpc: { Type: "AWS::EC2::VPC" }, subnetC: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "mainVpc" }, CidrBlock: "10.0.3.0/24" } } },
|
|
30
|
+
Outputs: { SubnetCId: { Value: { Ref: "subnetC" }, Export: { Name: "chant-compose-subnet-c-id" } } },
|
|
31
|
+
},
|
|
32
|
+
app: {
|
|
33
|
+
Resources: { importsInterface: { Type: "AWS::EC2::NetworkInterface", Properties: { SubnetId: { "Fn::ImportValue": "chant-compose-subnet-c-id" } } } },
|
|
34
|
+
Outputs: { ImportsInterfaceId: { Value: { Ref: "importsInterface" }, Export: { Name: "chant-compose-imports-interface" } } },
|
|
35
|
+
},
|
|
36
|
+
edge: {
|
|
37
|
+
Resources: {
|
|
38
|
+
importsInterfaceRecord: {
|
|
39
|
+
Type: "AWS::SSM::Parameter",
|
|
40
|
+
Properties: { Name: "/chant-compose/imports-interface", Type: "String", Value: { "Fn::ImportValue": "chant-compose-imports-interface" } },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const add = (logicalResourceId: string, resourceType: string) => ({ action: "Add" as const, logicalResourceId, resourceType, replacement: false });
|
|
47
|
+
const stacks = (): Record<string, FakeStackConfig> => ({
|
|
48
|
+
network: { outputs: { SubnetCId: "subnet-c" }, changes: [add("subnetC", "AWS::EC2::Subnet")] },
|
|
49
|
+
app: { outputs: { ImportsInterfaceId: "eni-1" }, changes: [add("importsInterface", "AWS::EC2::NetworkInterface")] },
|
|
50
|
+
edge: { changes: [add("importsInterfaceRecord", "AWS::SSM::Parameter")] },
|
|
51
|
+
});
|
|
52
|
+
const executed = (mock: ReturnType<typeof createMockCloudExecutor>) =>
|
|
53
|
+
mock.calls.filter((c) => c.method === "executeChangeSet").map((c) => (c.args as { stackName: string }).stackName);
|
|
54
|
+
|
|
55
|
+
async function planned() {
|
|
56
|
+
// No dependsOn and no inputs: the imports are the only wiring.
|
|
57
|
+
const declared: DriverComponent[] = (["edge", "app", "network"] as const).map((name) => {
|
|
58
|
+
const template = join(dir, `${name}.template.json`);
|
|
59
|
+
writeFileSync(template, JSON.stringify(TEMPLATES[name]));
|
|
60
|
+
return { name, dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: name, template }] }] };
|
|
61
|
+
});
|
|
62
|
+
const mock = createMockCloudExecutor({ stacks: stacks() });
|
|
63
|
+
const registry = new CapabilityRegistry();
|
|
64
|
+
registry.register(createCfnDeployCapability(mock.executor));
|
|
65
|
+
const components = await withDeclaredReads(declared, registry, dir);
|
|
66
|
+
const plan = planFanOut({ components, changed: ["network", "app", "edge"] });
|
|
67
|
+
const set = await planPrSet({ components, plan, registry, env: "local" });
|
|
68
|
+
return { mock, registry, components, plan, set };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("three stacks wired only by imports (#4466d)", () => {
|
|
72
|
+
it("are ordered by the imports, with each import known after apply and each importer previewed", async () => {
|
|
73
|
+
const { plan, set } = await planned();
|
|
74
|
+
expect(plan.waves).toEqual([["network"], ["app"], ["edge"]]);
|
|
75
|
+
const by = Object.fromEntries(set.members.map((m) => [m.component, m]));
|
|
76
|
+
expect(by.app!.unknownInputs).toEqual(["network.SubnetCId"]);
|
|
77
|
+
expect(by.edge!.unknownInputs).toEqual(["app.ImportsInterfaceId"]);
|
|
78
|
+
expect(set.plans.app?.app).toMatchObject({ kind: "cfn-deploy-shape" });
|
|
79
|
+
expect(set.plans.edge?.edge).toMatchObject({ kind: "cfn-deploy-shape" });
|
|
80
|
+
expect(set.doc.members.every((m) => m.holes.length === 0)).toBe(true);
|
|
81
|
+
expect(set.doc.entries.map((e) => `${e.member}/${e.address}`).sort()).toEqual(["app/importsInterface", "edge/importsInterfaceRecord", "network/subnetC"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("need one approval: both rounds are covered and the three stacks execute in order", async () => {
|
|
85
|
+
const { mock, registry, components, plan, set } = await planned();
|
|
86
|
+
let asked = 0;
|
|
87
|
+
const applied = await applyPrSet({
|
|
88
|
+
components,
|
|
89
|
+
plan,
|
|
90
|
+
registry,
|
|
91
|
+
env: "local",
|
|
92
|
+
set,
|
|
93
|
+
approval: { status: "approved", approvedBy: ["github:rev"] },
|
|
94
|
+
decideRound: async () => {
|
|
95
|
+
asked++;
|
|
96
|
+
return { satisfied: false, approval: { status: "pending" } };
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
expect(asked).toBe(0);
|
|
100
|
+
expect(applied.status).toBe("applied");
|
|
101
|
+
expect(executed(mock)).toEqual(["network", "app", "edge"]);
|
|
102
|
+
const rounds = applied.rounds ?? [];
|
|
103
|
+
expect(rounds.find((r) => r.components.includes("app"))?.covered?.filledInputs).toContain("network.SubnetCId");
|
|
104
|
+
// Round 2 plans app and edge, and edge waits for app; the last round with edge is the one that applies it.
|
|
105
|
+
expect([...rounds].reverse().find((r) => r.components.includes("edge"))?.covered?.filledInputs).toContain("app.ImportsInterfaceId");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Fn::ImportValue` between stacks (#4466a): an import of a name another
|
|
3
|
+
* `cfn-deploy` step's template exports is a read of `<component>.<output>`,
|
|
4
|
+
* which orders the importer after the exporter, lists it under
|
|
5
|
+
* `unknownInputs` and plans it as waiting on that stack. A name the plan
|
|
6
|
+
* cannot resolve is a hole that says why.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { CapabilityRegistry } from "@intentius/chant/components/capability";
|
|
14
|
+
import type { DriverComponent } from "@intentius/chant/components/driver";
|
|
15
|
+
import { withDeclaredReads } from "@intentius/chant/components/declared-reads";
|
|
16
|
+
import { planFanOut } from "@intentius/chant/components/fan-out";
|
|
17
|
+
import { planPrSet } from "@intentius/chant/components/pr-run";
|
|
18
|
+
import { capabilitySteps } from "@intentius/chant/components/wave-plan";
|
|
19
|
+
import { cfnDeclaredReads, cfnPlannedOutputs, createCfnDeployCapability } from "./apply";
|
|
20
|
+
import { cfnExports, cfnImportPointer, cfnImports, cfnKnownValues, cfnPseudoParameters, resolveCfnName } from "./cfn-imports";
|
|
21
|
+
import { createMockCloudExecutor } from "./__tests__/mock-cloud-executor";
|
|
22
|
+
|
|
23
|
+
let dir: string;
|
|
24
|
+
const write = (name: string, template: unknown): string => {
|
|
25
|
+
const path = join(dir, name);
|
|
26
|
+
writeFileSync(path, JSON.stringify(template));
|
|
27
|
+
return path;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
dir = mkdtempSync(join(tmpdir(), "cfn-imports-"));
|
|
32
|
+
});
|
|
33
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
34
|
+
|
|
35
|
+
const NETWORK = {
|
|
36
|
+
Resources: { Vpc: { Type: "AWS::EC2::VPC" } },
|
|
37
|
+
Outputs: {
|
|
38
|
+
VpcId: { Value: { Ref: "Vpc" }, Export: { Name: { "Fn::Sub": "${AWS::StackName}-VpcId" } } },
|
|
39
|
+
Internal: { Value: { Ref: "Vpc" } },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const APP = {
|
|
43
|
+
Parameters: { NetworkStack: { Type: "String", Default: "network" } },
|
|
44
|
+
Resources: {
|
|
45
|
+
Group: { Type: "AWS::EC2::SecurityGroup", Properties: { VpcId: { "Fn::ImportValue": { "Fn::Sub": "${NetworkStack}-VpcId" } } } },
|
|
46
|
+
},
|
|
47
|
+
Outputs: { GroupId: { Value: { Ref: "Group" }, Export: { Name: "app-GroupId" } } },
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("export and import names (#4466a)", () => {
|
|
51
|
+
const known = { NetworkStack: "network", "AWS::StackName": "app" };
|
|
52
|
+
|
|
53
|
+
it("a string is itself; Fn::Sub, Ref and Fn::Join resolve from what is known", () => {
|
|
54
|
+
expect(resolveCfnName("x", known)).toEqual({ name: "x" });
|
|
55
|
+
expect(resolveCfnName({ "Fn::Sub": "${NetworkStack}-VpcId" }, known)).toEqual({ name: "network-VpcId" });
|
|
56
|
+
expect(resolveCfnName({ "Fn::Sub": ["${Prefix}-${AWS::StackName}", { Prefix: { Ref: "NetworkStack" } }] }, known)).toEqual({ name: "network-app" });
|
|
57
|
+
expect(resolveCfnName({ "Fn::Sub": "${!Literal}-x" }, known)).toEqual({ name: "${Literal}-x" });
|
|
58
|
+
expect(resolveCfnName({ Ref: "NetworkStack" }, known)).toEqual({ name: "network" });
|
|
59
|
+
expect(resolveCfnName({ "Fn::Join": ["-", [{ Ref: "NetworkStack" }, "VpcId"]] }, known)).toEqual({ name: "network-VpcId" });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a name that reads what the plan does not know is unresolved, and says what it reads", () => {
|
|
63
|
+
expect(resolveCfnName({ "Fn::Sub": "${Missing}-VpcId" }, known)).toEqual({ unresolved: "reads parameter Missing, which has no value the plan knows" });
|
|
64
|
+
expect(resolveCfnName({ "Fn::Sub": "${AWS::AccountId}-VpcId" }, known)).toEqual({ unresolved: "reads AWS::AccountId, which is known only at apply" });
|
|
65
|
+
expect(resolveCfnName({ "Fn::Sub": "${Vpc.Arn}" }, known)).toEqual({ unresolved: "reads Vpc.Arn, a resource attribute" });
|
|
66
|
+
expect(resolveCfnName({ "Fn::Select": [0, ["a"]] }, known)).toEqual({ unresolved: "uses Fn::Select, which this plan does not follow" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("the pseudo parameters: the stack name always, the region's when the environment names it", () => {
|
|
70
|
+
expect(cfnPseudoParameters("app", {})).toEqual({ "AWS::StackName": "app" });
|
|
71
|
+
expect(cfnPseudoParameters("app", { AWS_REGION: "cn-north-1" })).toEqual({
|
|
72
|
+
"AWS::StackName": "app",
|
|
73
|
+
"AWS::Region": "cn-north-1",
|
|
74
|
+
"AWS::Partition": "aws-cn",
|
|
75
|
+
"AWS::URLSuffix": "amazonaws.com.cn",
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("known values are the defaults, overlaid by plain inputs; a wired input is not known", () => {
|
|
80
|
+
const values = cfnKnownValues(APP, "app", { NetworkStack: "shared", Other: { stackOutput: { stack: "x", name: "y" } } }, {});
|
|
81
|
+
expect(values).toEqual({ NetworkStack: "shared", "AWS::StackName": "app" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lists exports by output and imports by place", () => {
|
|
85
|
+
expect(cfnExports(NETWORK, cfnKnownValues(NETWORK, "network", {}, {}))).toEqual([{ output: "VpcId", name: "network-VpcId" }]);
|
|
86
|
+
expect(cfnImports(APP, cfnKnownValues(APP, "app", {}, {}))).toEqual([{ at: "Resources.Group.Properties.VpcId", logicalId: "Group", name: "network-VpcId" }]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("an export name's pointer escapes / and ~", () => {
|
|
90
|
+
expect(cfnImportPointer("a/b~c")).toBe("/imports/a~1b~0c");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("cfn-deploy's declared reads (#4466a)", () => {
|
|
95
|
+
it("an import of a name another step exports is a read of that component's output", () => {
|
|
96
|
+
const network = write("network.json", NETWORK);
|
|
97
|
+
const app = write("app.json", APP);
|
|
98
|
+
const reads = cfnDeclaredReads(dir, [
|
|
99
|
+
{ component: "network", index: 0, step: { stack: "network", template: network } },
|
|
100
|
+
{ component: "app", index: 0, step: { stack: "app", template: app } },
|
|
101
|
+
]);
|
|
102
|
+
expect(reads).toEqual([{ reader: "app", index: 0, upstream: "network", output: "VpcId", at: "/imports/network-VpcId" }]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("a relative template path is read against the root", () => {
|
|
106
|
+
write("network.json", NETWORK);
|
|
107
|
+
write("app.json", APP);
|
|
108
|
+
const reads = cfnDeclaredReads(dir, [
|
|
109
|
+
{ component: "network", index: 0, step: { template: "network.json" } },
|
|
110
|
+
{ component: "app", index: 0, step: { template: "app.json" } },
|
|
111
|
+
]);
|
|
112
|
+
expect(reads.map((r) => `${r.reader}<-${r.upstream}.${r.output}`)).toEqual(["app<-network.VpcId"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("no read for a name no step exports, a name two steps export, or a name the step cannot resolve", () => {
|
|
116
|
+
const network = write("network.json", NETWORK);
|
|
117
|
+
const twin = write("twin.json", NETWORK);
|
|
118
|
+
const app = write("app.json", APP);
|
|
119
|
+
// network and twin both default AWS::StackName to the same stack name: two exports of one name.
|
|
120
|
+
expect(
|
|
121
|
+
cfnDeclaredReads(dir, [
|
|
122
|
+
{ component: "network", index: 0, step: { stack: "network", template: network } },
|
|
123
|
+
{ component: "twin", index: 0, step: { stack: "network", template: twin } },
|
|
124
|
+
{ component: "app", index: 0, step: { stack: "app", template: app } },
|
|
125
|
+
]),
|
|
126
|
+
).toEqual([]);
|
|
127
|
+
expect(cfnDeclaredReads(dir, [{ component: "app", index: 0, step: { stack: "app", template: app } }])).toEqual([]);
|
|
128
|
+
const wired = write("wired.json", { ...APP, Parameters: { NetworkStack: { Type: "String" } } });
|
|
129
|
+
expect(
|
|
130
|
+
cfnDeclaredReads(dir, [
|
|
131
|
+
{ component: "network", index: 0, step: { stack: "network", template: network } },
|
|
132
|
+
{ component: "app", index: 0, step: { stack: "app", template: wired, inputs: { NetworkStack: { stackOutput: { stack: "x", name: "y" } } } } },
|
|
133
|
+
]),
|
|
134
|
+
).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("an output that passes an import through is known at the import's value", () => {
|
|
138
|
+
const template = { Outputs: { Vpc: { Value: { "Fn::ImportValue": "network-VpcId" } }, Other: { Value: { "Fn::ImportValue": "elsewhere" } } } };
|
|
139
|
+
expect(cfnPlannedOutputs(template, [], { Vpc: "old", Other: "x" }, {}, { "network-VpcId": "vpc-123" })).toEqual({ known: { Vpc: "vpc-123" }, unknown: ["Other"] });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("a pull request's plan over stacks wired only by imports (#4466a)", () => {
|
|
144
|
+
async function planned() {
|
|
145
|
+
const network = write("network.json", NETWORK);
|
|
146
|
+
const app = write("app.json", APP);
|
|
147
|
+
const declared: DriverComponent[] = [
|
|
148
|
+
{ name: "network", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "network", template: network }] }] },
|
|
149
|
+
{ name: "app", dependsOn: [], deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "app", template: app }] }] },
|
|
150
|
+
];
|
|
151
|
+
const mock = createMockCloudExecutor({ stacks: { network: { missing: true }, app: { missing: true } } });
|
|
152
|
+
const registry = new CapabilityRegistry();
|
|
153
|
+
registry.register(createCfnDeployCapability(mock.executor));
|
|
154
|
+
const components = await withDeclaredReads(declared, registry, dir);
|
|
155
|
+
const plan = planFanOut({ components, changed: ["network", "app"] });
|
|
156
|
+
const set = await planPrSet({ components, plan, registry, env: "local" });
|
|
157
|
+
return { components, plan, set, mock };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
it("orders the importer after the exporter and writes the read into its step", async () => {
|
|
161
|
+
const { components, plan } = await planned();
|
|
162
|
+
expect(plan.waves).toEqual([["network"], ["app"]]);
|
|
163
|
+
const app = components.find((c) => c.name === "app")!;
|
|
164
|
+
expect(app.dependsOn).toEqual(["network"]);
|
|
165
|
+
expect(capabilitySteps(app.deploy)[0]).toMatchObject({ imports: { "network-VpcId": { stackOutput: { stack: "network", name: "VpcId" } } } });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("lists the import under unknownInputs and reads, while network's plan cannot say the value", async () => {
|
|
169
|
+
const { set } = await planned();
|
|
170
|
+
const app = set.members.find((m) => m.component === "app")!;
|
|
171
|
+
expect(app.unknownInputs).toEqual(["network.VpcId"]);
|
|
172
|
+
expect(app.reads).toEqual([{ upstream: "network", outputs: ["VpcId"], unknown: ["VpcId"] }]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("an importer whose template cannot be read as JSON plans as waiting on the exporter (no preview, #4466b)", async () => {
|
|
176
|
+
const { set, components } = await planned();
|
|
177
|
+
const app = components.find((c) => c.name === "app")!;
|
|
178
|
+
const step = capabilitySteps(app.deploy)[0]! as Record<string, unknown>;
|
|
179
|
+
writeFileSync(step.template as string, "not json");
|
|
180
|
+
const mock = createMockCloudExecutor({ stacks: { app: { missing: true } } });
|
|
181
|
+
const planned2 = await createCfnDeployCapability(mock.executor).plan!(
|
|
182
|
+
{ env: "local", component: "app", unresolvedInputs: ["network"] },
|
|
183
|
+
{ stack: "app", template: step.template as string, imports: { "network-VpcId": undefined } },
|
|
184
|
+
);
|
|
185
|
+
expect(planned2.changeSet!.member.holes).toEqual([
|
|
186
|
+
{
|
|
187
|
+
address: "app",
|
|
188
|
+
type: "AWS::CloudFormation::Stack",
|
|
189
|
+
reason: "imports network-VpcId, exported by network, which apply first; re-checked on apply; no preview: the template could not be read as JSON to stand in for its imports",
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
expect(set.doc.members.find((m) => m.member === "app")).toBeDefined();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("an import whose name the plan cannot resolve (#4466a)", () => {
|
|
197
|
+
it("is a hole on the importer's member that says what the name reads", async () => {
|
|
198
|
+
const template = write("app.json", {
|
|
199
|
+
Resources: { Group: { Type: "AWS::EC2::SecurityGroup", Properties: { VpcId: { "Fn::ImportValue": { "Fn::Sub": "${AWS::AccountId}-VpcId" } } } } },
|
|
200
|
+
});
|
|
201
|
+
const mock = createMockCloudExecutor({ stacks: { app: { changes: [{ action: "Add", logicalResourceId: "Group", resourceType: "AWS::EC2::SecurityGroup", replacement: false }] } } });
|
|
202
|
+
const planned = await createCfnDeployCapability(mock.executor).plan!({ env: "local", component: "app" }, { stack: "app", template });
|
|
203
|
+
expect(planned.changeSet!.member.holes).toEqual([
|
|
204
|
+
{
|
|
205
|
+
address: "Group",
|
|
206
|
+
type: "AWS::EC2::SecurityGroup",
|
|
207
|
+
reason:
|
|
208
|
+
"the Fn::ImportValue at Resources.Group.Properties.VpcId reads AWS::AccountId, which is known only at apply, so the plan cannot say which export it reads or whether a stack in this change exports it",
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
expect(planned.changeSet!.entries).toHaveLength(1);
|
|
212
|
+
});
|
|
213
|
+
});
|