@intentius/chant-lexicon-aws 0.14.0 → 0.15.0
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/__tests__/mock-cloud-executor.d.ts +106 -0
- package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -0
- package/dist/components/apply.d.ts +242 -0
- package/dist/components/apply.d.ts.map +1 -0
- package/dist/components/builders.d.ts +37 -0
- package/dist/components/builders.d.ts.map +1 -0
- package/dist/components/capability-plugin.d.ts +23 -0
- package/dist/components/capability-plugin.d.ts.map +1 -0
- package/dist/components/cloud-executor.d.ts +350 -0
- package/dist/components/cloud-executor.d.ts.map +1 -0
- package/dist/components/config-bom.d.ts +135 -0
- package/dist/components/config-bom.d.ts.map +1 -0
- package/dist/components/host-delivery.d.ts +110 -0
- package/dist/components/host-delivery.d.ts.map +1 -0
- package/dist/components/index.d.ts +19 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/job-submission.d.ts +89 -0
- package/dist/components/job-submission.d.ts.map +1 -0
- package/dist/components/publish.d.ts +194 -0
- package/dist/components/publish.d.ts.map +1 -0
- package/dist/components/safety.d.ts +49 -0
- package/dist/components/safety.d.ts.map +1 -0
- package/dist/components/wait-aws.d.ts +65 -0
- package/dist/components/wait-aws.d.ts.map +1 -0
- package/dist/generated/index.d.ts +7 -4
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/import/live-export.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/lint/audit-catalog.d.ts +10 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +141 -30
- package/dist/op/activities/floci.d.ts +54 -0
- package/dist/op/activities/floci.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +8 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/ownership.d.ts +10 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/types/index.d.ts +145 -38
- package/package.json +12 -2
- package/src/components/__fixtures__/schemas/cyclonedx-1.5.schema.json +3800 -0
- package/src/components/__fixtures__/schemas/cyclonedx-spdx-license.schema.json +621 -0
- package/src/components/__fixtures__/schemas/jsf-0.82.schema.json +240 -0
- package/src/components/__fixtures__/schemas/spdx-2.3.schema.json +740 -0
- package/src/components/__fixtures__/synthesized-template.json +53 -0
- package/src/components/__tests__/mock-cloud-executor.ts +425 -0
- package/src/components/apply.test.ts +382 -0
- package/src/components/apply.ts +499 -0
- package/src/components/builders.test.ts +49 -0
- package/src/components/builders.ts +59 -0
- package/src/components/capability-plugin.ts +73 -0
- package/src/components/cloud-executor.test.ts +25 -0
- package/src/components/cloud-executor.ts +814 -0
- package/src/components/config-bom.test.ts +273 -0
- package/src/components/config-bom.ts +310 -0
- package/src/components/host-delivery.test.ts +121 -0
- package/src/components/host-delivery.ts +193 -0
- package/src/components/index.ts +19 -0
- package/src/components/job-submission.test.ts +97 -0
- package/src/components/job-submission.ts +138 -0
- package/src/components/pilots-e2e.test.ts +460 -0
- package/src/components/presets-e2e.test.ts +166 -0
- package/src/components/publish.test.ts +318 -0
- package/src/components/publish.ts +356 -0
- package/src/components/safety.test.ts +47 -0
- package/src/components/safety.ts +78 -0
- package/src/components/wait-aws.test.ts +79 -0
- package/src/components/wait-aws.ts +132 -0
- package/src/generated/index.d.ts +145 -38
- package/src/generated/index.ts +12 -9
- package/src/generated/lexicon-aws.json +141 -30
- package/src/import/live-export.ts +2 -1
- package/src/index.ts +5 -0
- package/src/lint/audit-catalog.ts +45 -0
- package/src/op/activities/floci.test.ts +67 -0
- package/src/op/activities/floci.ts +149 -0
- package/src/op/activities/index.ts +16 -0
- package/src/ownership.test.ts +18 -0
- package/src/ownership.ts +15 -0
- package/src/plugin.ts +3 -0
- package/src/serializer.ts +2 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createCfnDeployCapability,
|
|
4
|
+
createEcsUpdateServiceCapability,
|
|
5
|
+
createLambdaDeployCapability,
|
|
6
|
+
createS3SyncCapability,
|
|
7
|
+
createCdnInvalidateCapability,
|
|
8
|
+
createRunMigrationCapability,
|
|
9
|
+
CfnReplacementBlockedError,
|
|
10
|
+
} from "./apply";
|
|
11
|
+
import { createMockCloudExecutor } from "./__tests__/mock-cloud-executor";
|
|
12
|
+
|
|
13
|
+
const ctx = { env: "dev", component: "orders-table" };
|
|
14
|
+
|
|
15
|
+
describe("cfn-deploy (#557)", () => {
|
|
16
|
+
it("creates a changeset, executes it, and waits for the stack to reach a terminal status", async () => {
|
|
17
|
+
const mock = createMockCloudExecutor({
|
|
18
|
+
stacks: { "orders-table": { outputs: { TableArn: "arn:aws:dynamodb:table/orders" } } },
|
|
19
|
+
});
|
|
20
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
21
|
+
|
|
22
|
+
const output = await capability.run(ctx, { stack: "orders-table", template: "archive:orders-table.template.json" });
|
|
23
|
+
|
|
24
|
+
expect(output.stackStatus).toBe("UPDATE_COMPLETE");
|
|
25
|
+
expect(output.outputs).toEqual({ TableArn: "arn:aws:dynamodb:table/orders" });
|
|
26
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["createChangeSet", "executeChangeSet", "waitForStack"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("throws when the stack rolls back — a failed deploy must not report success", async () => {
|
|
30
|
+
const mock = createMockCloudExecutor({
|
|
31
|
+
stacks: { "search-service": { terminalStatus: "ROLLBACK_COMPLETE" } },
|
|
32
|
+
});
|
|
33
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
34
|
+
|
|
35
|
+
await expect(
|
|
36
|
+
capability.run(ctx, { stack: "search-service", template: "archive:search.template.json" }),
|
|
37
|
+
).rejects.toThrow(/ROLLBACK_COMPLETE/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("throws on a hard failure terminal status (*_FAILED)", async () => {
|
|
41
|
+
const mock = createMockCloudExecutor({
|
|
42
|
+
stacks: { "search-service": { terminalStatus: "CREATE_FAILED" } },
|
|
43
|
+
});
|
|
44
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
45
|
+
|
|
46
|
+
await expect(
|
|
47
|
+
capability.run(ctx, { stack: "search-service", template: "archive:search.template.json" }),
|
|
48
|
+
).rejects.toThrow(/CREATE_FAILED/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("passes wired inputs and imageRef through as CloudFormation parameters", async () => {
|
|
52
|
+
const mock = createMockCloudExecutor({ stacks: { "search-service": {} } });
|
|
53
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
54
|
+
|
|
55
|
+
await capability.run(
|
|
56
|
+
{ env: "dev", component: "search-service" },
|
|
57
|
+
{
|
|
58
|
+
stack: "search-service",
|
|
59
|
+
template: "archive:search.template.json",
|
|
60
|
+
imageRef: "sha256:abcdef",
|
|
61
|
+
inputs: { listenerArn: "arn:aws:elb:listener/abc" },
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const createCall = mock.calls.find((c) => c.method === "createChangeSet")!;
|
|
66
|
+
expect(createCall.args).toMatchObject({
|
|
67
|
+
parameters: { listenerArn: "arn:aws:elb:listener/abc", ImageRef: "sha256:abcdef" },
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("onReplace safety policy — the data-loss guard the epic requires", () => {
|
|
72
|
+
const replacingChange = {
|
|
73
|
+
action: "Modify" as const,
|
|
74
|
+
logicalResourceId: "OrdersTable",
|
|
75
|
+
resourceType: "AWS::DynamoDB::Table",
|
|
76
|
+
replacement: true,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it('onReplace: "block" (default) refuses a changeset that would replace a resource', async () => {
|
|
80
|
+
const mock = createMockCloudExecutor({
|
|
81
|
+
stacks: { "orders-table": { changes: [replacingChange] } },
|
|
82
|
+
});
|
|
83
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
capability.run(ctx, { stack: "orders-table", template: "archive:orders-table.template.json" }),
|
|
87
|
+
).rejects.toBeInstanceOf(CfnReplacementBlockedError);
|
|
88
|
+
|
|
89
|
+
// The changeset must be deleted, never executed — a blocked replacement applies nothing.
|
|
90
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["createChangeSet", "deleteChangeSet"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('onReplace: "block" is the default when the option is omitted entirely', async () => {
|
|
94
|
+
const mock = createMockCloudExecutor({ stacks: { "orders-table": { changes: [replacingChange] } } });
|
|
95
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
96
|
+
await expect(
|
|
97
|
+
capability.run(ctx, {
|
|
98
|
+
stack: "orders-table",
|
|
99
|
+
template: "archive:orders-table.template.json",
|
|
100
|
+
// onReplace omitted
|
|
101
|
+
}),
|
|
102
|
+
).rejects.toBeInstanceOf(CfnReplacementBlockedError);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("the blocked error names the specific resources CloudFormation proposed replacing", async () => {
|
|
106
|
+
const mock = createMockCloudExecutor({ stacks: { "orders-table": { changes: [replacingChange] } } });
|
|
107
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
108
|
+
try {
|
|
109
|
+
await capability.run(ctx, { stack: "orders-table", template: "t.json", onReplace: "block" });
|
|
110
|
+
expect.unreachable();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
expect(err).toBeInstanceOf(CfnReplacementBlockedError);
|
|
113
|
+
const blocked = err as CfnReplacementBlockedError;
|
|
114
|
+
expect(blocked.replacements).toEqual([replacingChange]);
|
|
115
|
+
expect(blocked.message).toContain("OrdersTable");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('onReplace: "allow" executes the replacing changeset', async () => {
|
|
120
|
+
const mock = createMockCloudExecutor({ stacks: { "orders-table": { changes: [replacingChange] } } });
|
|
121
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
122
|
+
const output = await capability.run(ctx, {
|
|
123
|
+
stack: "orders-table",
|
|
124
|
+
template: "t.json",
|
|
125
|
+
onReplace: "allow",
|
|
126
|
+
});
|
|
127
|
+
expect(output.stackStatus).toBe("UPDATE_COMPLETE");
|
|
128
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["createChangeSet", "executeChangeSet", "waitForStack"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('onReplace: "snapshot-first" records a snapshot marker before executing the replacement', async () => {
|
|
132
|
+
const mock = createMockCloudExecutor({ stacks: { "orders-table": { changes: [replacingChange] } } });
|
|
133
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
134
|
+
const output = await capability.run(ctx, {
|
|
135
|
+
stack: "orders-table",
|
|
136
|
+
template: "t.json",
|
|
137
|
+
onReplace: "snapshot-first",
|
|
138
|
+
});
|
|
139
|
+
expect(output.snapshotId).toBeDefined();
|
|
140
|
+
expect(output.snapshotId).toContain("OrdersTable");
|
|
141
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["createChangeSet", "executeChangeSet", "waitForStack"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("a non-replacing changeset executes normally under every onReplace policy", async () => {
|
|
145
|
+
for (const onReplace of ["block", "allow", "snapshot-first"] as const) {
|
|
146
|
+
const mock = createMockCloudExecutor({ stacks: { "orders-table": { changes: [] } } });
|
|
147
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
148
|
+
const output = await capability.run(ctx, { stack: "orders-table", template: "t.json", onReplace });
|
|
149
|
+
expect(output.stackStatus).toBe("UPDATE_COMPLETE");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("rollback triggers CloudFormation's native rollback-stack", async () => {
|
|
155
|
+
const mock = createMockCloudExecutor({ stacks: { "orders-table": {} } });
|
|
156
|
+
const capability = createCfnDeployCapability(mock.executor);
|
|
157
|
+
await capability.rollback!(ctx, { stack: "orders-table", template: "t.json" });
|
|
158
|
+
expect(mock.calls).toEqual([{ client: "cloudformation", method: "rollbackStack", args: "orders-table" }]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("ecs-update-service (#557)", () => {
|
|
163
|
+
const svcCtx = { env: "dev", component: "search-service" };
|
|
164
|
+
|
|
165
|
+
it("updates the service via the executor and returns the deployment id", async () => {
|
|
166
|
+
const mock = createMockCloudExecutor();
|
|
167
|
+
const capability = createEcsUpdateServiceCapability(mock.executor);
|
|
168
|
+
|
|
169
|
+
const output = await capability.run(svcCtx, { cluster: "prod", service: "search", imageRef: "sha256:abc" });
|
|
170
|
+
|
|
171
|
+
expect(output.deploymentId).toBe("mock-deployment-prod/search");
|
|
172
|
+
const updateCall = mock.calls.find((c) => c.method === "updateService")!;
|
|
173
|
+
expect(updateCall.args).toMatchObject({ cluster: "prod", service: "search", taskDefinition: "sha256:abc" });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("declares a capability-level rollback that re-invokes updateService (best-effort)", async () => {
|
|
177
|
+
const mock = createMockCloudExecutor();
|
|
178
|
+
const capability = createEcsUpdateServiceCapability(mock.executor);
|
|
179
|
+
expect(typeof capability.rollback).toBe("function");
|
|
180
|
+
await capability.rollback!(svcCtx, { cluster: "prod", service: "search" });
|
|
181
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["rollbackService"]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("lambda-deploy (#558) — the one new capability the fourth validation component needed", () => {
|
|
186
|
+
const lambdaCtx = { env: "dev", component: "image-processor-lambda" };
|
|
187
|
+
|
|
188
|
+
it("updates the function's code, waits for the update, publishes a version, and repoints the alias", async () => {
|
|
189
|
+
const mock = createMockCloudExecutor();
|
|
190
|
+
const capability = createLambdaDeployCapability(mock.executor);
|
|
191
|
+
|
|
192
|
+
const output = await capability.run(lambdaCtx, {
|
|
193
|
+
functionName: "image-processor",
|
|
194
|
+
codeRef: "123.dkr.ecr.us-east-1.amazonaws.com/image-processor@sha256:abc",
|
|
195
|
+
alias: "live",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(output.version).toBe("1");
|
|
199
|
+
expect(output.functionArn).toContain("image-processor");
|
|
200
|
+
expect(mock.calls.map((c) => c.method)).toEqual([
|
|
201
|
+
"getAliasVersion",
|
|
202
|
+
"updateFunctionCode",
|
|
203
|
+
"waitForUpdate",
|
|
204
|
+
"publishVersion",
|
|
205
|
+
"updateAlias",
|
|
206
|
+
]);
|
|
207
|
+
const updateCall = mock.calls.find((c) => c.method === "updateFunctionCode")!;
|
|
208
|
+
expect(updateCall.args).toMatchObject({
|
|
209
|
+
functionName: "image-processor",
|
|
210
|
+
imageUri: "123.dkr.ecr.us-east-1.amazonaws.com/image-processor@sha256:abc",
|
|
211
|
+
});
|
|
212
|
+
const aliasCall = mock.calls.find((c) => c.method === "updateAlias")!;
|
|
213
|
+
expect(aliasCall.args).toMatchObject({ functionName: "image-processor", alias: "live", version: "1" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('defaults the alias to "live" when omitted', async () => {
|
|
217
|
+
const mock = createMockCloudExecutor();
|
|
218
|
+
const capability = createLambdaDeployCapability(mock.executor);
|
|
219
|
+
await capability.run(lambdaCtx, { functionName: "image-processor", codeRef: "sha256:abc" });
|
|
220
|
+
const aliasCall = mock.calls.find((c) => c.method === "updateAlias")!;
|
|
221
|
+
expect(aliasCall.args).toMatchObject({ alias: "live" });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("skips publish/alias when publish: false, returning $LATEST", async () => {
|
|
225
|
+
const mock = createMockCloudExecutor();
|
|
226
|
+
const capability = createLambdaDeployCapability(mock.executor);
|
|
227
|
+
const output = await capability.run(lambdaCtx, {
|
|
228
|
+
functionName: "image-processor",
|
|
229
|
+
codeRef: "sha256:abc",
|
|
230
|
+
publish: false,
|
|
231
|
+
});
|
|
232
|
+
expect(output.version).toBe("$LATEST");
|
|
233
|
+
expect(mock.calls.some((c) => c.method === "publishVersion")).toBe(false);
|
|
234
|
+
expect(mock.calls.some((c) => c.method === "updateAlias")).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("throws when the code update itself fails", async () => {
|
|
238
|
+
const mock = createMockCloudExecutor({ lambdas: { "image-processor": { failUpdate: true } } });
|
|
239
|
+
const capability = createLambdaDeployCapability(mock.executor);
|
|
240
|
+
await expect(
|
|
241
|
+
capability.run(lambdaCtx, { functionName: "image-processor", codeRef: "sha256:abc" }),
|
|
242
|
+
).rejects.toThrow(/code update ended "Failed"/);
|
|
243
|
+
expect(mock.calls.some((c) => c.method === "publishVersion")).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("rollback restores whatever version the alias pointed at before this step ran", async () => {
|
|
247
|
+
const mock = createMockCloudExecutor({ lambdas: { "image-processor": { aliasVersions: { live: "7" } } } });
|
|
248
|
+
const capability = createLambdaDeployCapability(mock.executor);
|
|
249
|
+
|
|
250
|
+
await capability.run(lambdaCtx, { functionName: "image-processor", codeRef: "sha256:new", alias: "live" });
|
|
251
|
+
await capability.rollback!(lambdaCtx, { functionName: "image-processor", codeRef: "sha256:new", alias: "live" });
|
|
252
|
+
|
|
253
|
+
const aliasCalls = mock.calls.filter((c) => c.method === "updateAlias");
|
|
254
|
+
expect(aliasCalls).toHaveLength(2);
|
|
255
|
+
expect(aliasCalls[1]!.args).toMatchObject({ version: "7" }); // restored to the pre-deploy version.
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("rollback is a no-op when no prior alias version was recorded (first deploy)", async () => {
|
|
259
|
+
const mock = createMockCloudExecutor();
|
|
260
|
+
const capability = createLambdaDeployCapability(mock.executor);
|
|
261
|
+
|
|
262
|
+
await capability.run(lambdaCtx, { functionName: "image-processor", codeRef: "sha256:new", alias: "live" });
|
|
263
|
+
await capability.rollback!(lambdaCtx, { functionName: "image-processor", codeRef: "sha256:new", alias: "live" });
|
|
264
|
+
|
|
265
|
+
const aliasCalls = mock.calls.filter((c) => c.method === "updateAlias");
|
|
266
|
+
expect(aliasCalls).toHaveLength(1); // only the run()'s own updateAlias — rollback found nothing to restore.
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("s3-sync (#557)", () => {
|
|
271
|
+
it("syncs from → to and returns the uploaded/deleted counts", async () => {
|
|
272
|
+
const mock = createMockCloudExecutor({ s3Sync: { uploaded: 3, deleted: 1 } });
|
|
273
|
+
const out = await createS3SyncCapability(mock.executor).run(ctx, { from: "dist/", to: "s3://assets/site", delete: true });
|
|
274
|
+
expect(out).toEqual({ uploaded: 3, deleted: 1 });
|
|
275
|
+
expect(mock.calls).toEqual([{ client: "s3", method: "sync", args: { from: "dist/", to: "s3://assets/site", delete: true } }]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("reports 0 deleted when delete is not requested", async () => {
|
|
279
|
+
const mock = createMockCloudExecutor({ s3Sync: { uploaded: 2, deleted: 5 } });
|
|
280
|
+
const out = await createS3SyncCapability(mock.executor).run(ctx, { from: "dist/", to: "s3://assets" });
|
|
281
|
+
expect(out).toEqual({ uploaded: 2, deleted: 0 });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("declares no rollback — a content sync owns its own re-sync", () => {
|
|
285
|
+
expect(createS3SyncCapability(createMockCloudExecutor().executor).rollback).toBeUndefined();
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("cdn-invalidate (#557)", () => {
|
|
290
|
+
it("invalidates the given paths and returns the batch id", async () => {
|
|
291
|
+
const mock = createMockCloudExecutor();
|
|
292
|
+
const out = await createCdnInvalidateCapability(mock.executor).run(ctx, { distributionId: "E123", paths: ["/index.html"] });
|
|
293
|
+
expect(out).toEqual({ invalidationId: "I-MOCK0001" });
|
|
294
|
+
expect(mock.calls).toEqual([{ client: "cloudfront", method: "createInvalidation", args: { distributionId: "E123", paths: ["/index.html"] } }]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("defaults paths to /* when omitted", async () => {
|
|
298
|
+
const mock = createMockCloudExecutor();
|
|
299
|
+
await createCdnInvalidateCapability(mock.executor).run(ctx, { distributionId: "E123" });
|
|
300
|
+
expect(mock.calls[0]!.args).toEqual({ distributionId: "E123", paths: ["/*"] });
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("run-migration", () => {
|
|
305
|
+
it("ecs-task: runs a one-off task, waits for it to stop, and reports applied on a clean exit", async () => {
|
|
306
|
+
const mock = createMockCloudExecutor({ ecsTask: { lastStatus: "STOPPED", exitCode: 0 } });
|
|
307
|
+
const out = await createRunMigrationCapability(mock.executor).run(ctx, {
|
|
308
|
+
tool: "flyway",
|
|
309
|
+
target: {
|
|
310
|
+
via: "ecs-task",
|
|
311
|
+
cluster: "prod",
|
|
312
|
+
taskDefinition: "orders-migrate",
|
|
313
|
+
command: ["flyway", "migrate"],
|
|
314
|
+
subnets: ["subnet-1"],
|
|
315
|
+
assignPublicIp: true,
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
expect(out).toEqual({ applied: true, version: "" });
|
|
319
|
+
expect(mock.calls.map((c) => `${c.client}.${c.method}`)).toEqual(["ecs.runTask", "ecs.waitForTask"]);
|
|
320
|
+
expect((mock.calls[0]!.args as { command: string[] }).command).toEqual(["flyway", "migrate"]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("ecs-task: throws with the stop reason on a non-zero exit", async () => {
|
|
324
|
+
const mock = createMockCloudExecutor({ ecsTask: { lastStatus: "STOPPED", exitCode: 1, stoppedReason: "Essential container exited" } });
|
|
325
|
+
await expect(
|
|
326
|
+
createRunMigrationCapability(mock.executor).run(ctx, {
|
|
327
|
+
tool: "flyway",
|
|
328
|
+
target: { via: "ecs-task", cluster: "prod", taskDefinition: "orders-migrate" },
|
|
329
|
+
}),
|
|
330
|
+
).rejects.toThrow(/exit 1.*Essential container exited/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("lambda: invokes the function and parses the {applied,version} it reports", async () => {
|
|
334
|
+
const mock = createMockCloudExecutor({ lambdaInvoke: { statusCode: 200, payload: '{"applied":true,"version":"20260704_02"}' } });
|
|
335
|
+
const out = await createRunMigrationCapability(mock.executor).run(ctx, {
|
|
336
|
+
tool: "custom",
|
|
337
|
+
target: { via: "lambda", function: "orders-migrator", payload: '{"dryRun":false}' },
|
|
338
|
+
});
|
|
339
|
+
expect(out).toEqual({ applied: true, version: "20260704_02" });
|
|
340
|
+
expect(mock.calls).toEqual([{ client: "lambda", method: "invoke", args: { functionName: "orders-migrator", payload: '{"dryRun":false}' } }]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("lambda: throws when the invocation returns a FunctionError", async () => {
|
|
344
|
+
const mock = createMockCloudExecutor({ lambdaInvoke: { statusCode: 200, functionError: "Unhandled", payload: '{"errorMessage":"boom"}' } });
|
|
345
|
+
await expect(
|
|
346
|
+
createRunMigrationCapability(mock.executor).run(ctx, { tool: "custom", target: { via: "lambda", function: "orders-migrator" } }),
|
|
347
|
+
).rejects.toThrow(/Unhandled.*boom/);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("host: runs the command over SSM and parses the trailing report line, ignoring earlier output", async () => {
|
|
351
|
+
const mock = createMockCloudExecutor({
|
|
352
|
+
hostExecStdout: "Applying V3__add_index.sql\nApplying V4__backfill.sql\n{\"applied\":true,\"version\":\"V4\"}",
|
|
353
|
+
});
|
|
354
|
+
const out = await createRunMigrationCapability(mock.executor).run(ctx, {
|
|
355
|
+
tool: "flyway",
|
|
356
|
+
target: { via: "host", host: "i-abc", command: "flyway migrate", cwd: "/opt/app" },
|
|
357
|
+
});
|
|
358
|
+
expect(out).toEqual({ applied: true, version: "V4" });
|
|
359
|
+
expect(mock.calls).toEqual([{ client: "host", method: "exec", args: { host: "i-abc", command: "flyway migrate", cwd: "/opt/app" } }]);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("falls back to applied=true, version='' when the runner reports nothing parseable", async () => {
|
|
363
|
+
const mock = createMockCloudExecutor({ hostExecStdout: "migrations up to date\n" });
|
|
364
|
+
const out = await createRunMigrationCapability(mock.executor).run(ctx, {
|
|
365
|
+
tool: "prisma",
|
|
366
|
+
target: { via: "host", host: "i-abc", command: "prisma migrate deploy" },
|
|
367
|
+
});
|
|
368
|
+
expect(out).toEqual({ applied: true, version: "" });
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("host: throws on a non-zero exit code", async () => {
|
|
372
|
+
const mock = createMockCloudExecutor({ hostExecStdout: "boom" });
|
|
373
|
+
// force a non-zero exit by wrapping the mock's host.exec — simplest: use failHost is a throw; instead assert the clean path above and the ecs/lambda error paths cover non-zero.
|
|
374
|
+
const executor = {
|
|
375
|
+
...mock.executor,
|
|
376
|
+
host: { ...mock.executor.host, exec: async () => ({ stdout: "syntax error", exitCode: 2 }) },
|
|
377
|
+
};
|
|
378
|
+
await expect(
|
|
379
|
+
createRunMigrationCapability(executor).run(ctx, { tool: "flyway", target: { via: "host", host: "i-abc", command: "flyway migrate" } }),
|
|
380
|
+
).rejects.toThrow(/host i-abc.*exited 2/);
|
|
381
|
+
});
|
|
382
|
+
});
|