@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.
Files changed (85) hide show
  1. package/dist/components/__tests__/mock-cloud-executor.d.ts +106 -0
  2. package/dist/components/__tests__/mock-cloud-executor.d.ts.map +1 -0
  3. package/dist/components/apply.d.ts +242 -0
  4. package/dist/components/apply.d.ts.map +1 -0
  5. package/dist/components/builders.d.ts +37 -0
  6. package/dist/components/builders.d.ts.map +1 -0
  7. package/dist/components/capability-plugin.d.ts +23 -0
  8. package/dist/components/capability-plugin.d.ts.map +1 -0
  9. package/dist/components/cloud-executor.d.ts +350 -0
  10. package/dist/components/cloud-executor.d.ts.map +1 -0
  11. package/dist/components/config-bom.d.ts +135 -0
  12. package/dist/components/config-bom.d.ts.map +1 -0
  13. package/dist/components/host-delivery.d.ts +110 -0
  14. package/dist/components/host-delivery.d.ts.map +1 -0
  15. package/dist/components/index.d.ts +19 -0
  16. package/dist/components/index.d.ts.map +1 -0
  17. package/dist/components/job-submission.d.ts +89 -0
  18. package/dist/components/job-submission.d.ts.map +1 -0
  19. package/dist/components/publish.d.ts +194 -0
  20. package/dist/components/publish.d.ts.map +1 -0
  21. package/dist/components/safety.d.ts +49 -0
  22. package/dist/components/safety.d.ts.map +1 -0
  23. package/dist/components/wait-aws.d.ts +65 -0
  24. package/dist/components/wait-aws.d.ts.map +1 -0
  25. package/dist/generated/index.d.ts +7 -4
  26. package/dist/generated/index.d.ts.map +1 -1
  27. package/dist/import/live-export.d.ts.map +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/integrity.json +4 -4
  31. package/dist/lint/audit-catalog.d.ts +10 -0
  32. package/dist/lint/audit-catalog.d.ts.map +1 -0
  33. package/dist/manifest.json +1 -1
  34. package/dist/meta.json +141 -30
  35. package/dist/op/activities/floci.d.ts +54 -0
  36. package/dist/op/activities/floci.d.ts.map +1 -0
  37. package/dist/op/activities/index.d.ts +8 -0
  38. package/dist/op/activities/index.d.ts.map +1 -0
  39. package/dist/ownership.d.ts +10 -0
  40. package/dist/ownership.d.ts.map +1 -0
  41. package/dist/plugin.d.ts.map +1 -1
  42. package/dist/serializer.d.ts.map +1 -1
  43. package/dist/types/index.d.ts +145 -38
  44. package/package.json +12 -2
  45. package/src/components/__fixtures__/schemas/cyclonedx-1.5.schema.json +3800 -0
  46. package/src/components/__fixtures__/schemas/cyclonedx-spdx-license.schema.json +621 -0
  47. package/src/components/__fixtures__/schemas/jsf-0.82.schema.json +240 -0
  48. package/src/components/__fixtures__/schemas/spdx-2.3.schema.json +740 -0
  49. package/src/components/__fixtures__/synthesized-template.json +53 -0
  50. package/src/components/__tests__/mock-cloud-executor.ts +425 -0
  51. package/src/components/apply.test.ts +382 -0
  52. package/src/components/apply.ts +499 -0
  53. package/src/components/builders.test.ts +49 -0
  54. package/src/components/builders.ts +59 -0
  55. package/src/components/capability-plugin.ts +73 -0
  56. package/src/components/cloud-executor.test.ts +25 -0
  57. package/src/components/cloud-executor.ts +814 -0
  58. package/src/components/config-bom.test.ts +273 -0
  59. package/src/components/config-bom.ts +310 -0
  60. package/src/components/host-delivery.test.ts +121 -0
  61. package/src/components/host-delivery.ts +193 -0
  62. package/src/components/index.ts +19 -0
  63. package/src/components/job-submission.test.ts +97 -0
  64. package/src/components/job-submission.ts +138 -0
  65. package/src/components/pilots-e2e.test.ts +460 -0
  66. package/src/components/presets-e2e.test.ts +166 -0
  67. package/src/components/publish.test.ts +318 -0
  68. package/src/components/publish.ts +356 -0
  69. package/src/components/safety.test.ts +47 -0
  70. package/src/components/safety.ts +78 -0
  71. package/src/components/wait-aws.test.ts +79 -0
  72. package/src/components/wait-aws.ts +132 -0
  73. package/src/generated/index.d.ts +145 -38
  74. package/src/generated/index.ts +12 -9
  75. package/src/generated/lexicon-aws.json +141 -30
  76. package/src/import/live-export.ts +2 -1
  77. package/src/index.ts +5 -0
  78. package/src/lint/audit-catalog.ts +45 -0
  79. package/src/op/activities/floci.test.ts +67 -0
  80. package/src/op/activities/floci.ts +149 -0
  81. package/src/op/activities/index.ts +16 -0
  82. package/src/ownership.test.ts +18 -0
  83. package/src/ownership.ts +15 -0
  84. package/src/plugin.ts +3 -0
  85. package/src/serializer.ts +2 -1
@@ -0,0 +1,460 @@
1
+ /**
2
+ * End-to-end verification for #557/#558/#561: the three pilots (#555) plus
3
+ * the fourth validation component (#558, image-processor-lambda) run to
4
+ * completion through the real, merged interpret driver (#556, ../driver.ts),
5
+ * dispatching to the real AWS-leaf capability implementations
6
+ * (../verbs/{build,publish,apply,host-delivery,wait-verify,job-submission}.ts)
7
+ * — not the typed stubs `createCapabilityRegistry()` still returns for
8
+ * non-pilot verbs.
9
+ *
10
+ * Every capability here is wired to a `MockCloudExecutor`
11
+ * (../verbs/__tests__/mock-cloud-executor.ts): no live AWS, no live docker.
12
+ * This suite is the acceptance-criteria proof from all three issues:
13
+ * - correct capability dispatch across all four components through one driver;
14
+ * - `cfn-deploy` refuses a data-losing replacement when `onReplace: "block"`;
15
+ * - every mutating capability the pilots use declares a `rollback`, or the
16
+ * component supplies its own explicit compensation phase (documented, not
17
+ * silent) where the capability itself has none;
18
+ * - the fourth component (#558) needed exactly one new capability
19
+ * (`lambda-deploy`) and zero driver edits — see ../SPRAWL-VALIDATION.md;
20
+ * - the JAR-producer/EMR-consumer pair (#561) resolves a cross-component
21
+ * artifact reference (`@jar-lib.publish.uri`) purely via the graph, with
22
+ * zero driver edits — ../driver.ts was not modified by #561.
23
+ */
24
+
25
+ import { describe, expect, it } from "vitest";
26
+ import { CapabilityRegistry } from "@intentius/chant/components/capability";
27
+ import { runInterpretDriver, DriverRunFailure, type DriverComponent } from "@intentius/chant/components/driver";
28
+ import { neo4jCluster } from "@intentius/chant/components/pilots/neo4j-fanout.pilot";
29
+ import { ordersTable } from "@intentius/chant/components/pilots/dynamodb.pilot";
30
+ import { searchService } from "@intentius/chant/components/pilots/alb-ecs.pilot";
31
+ import { imageProcessor } from "@intentius/chant/components/pilots/lambda.pilot";
32
+ import { jarLib, emrJob } from "@intentius/chant/components/pilots/jar-emr.pilot";
33
+ import { projectToJson } from "@intentius/chant/components/pilots/project";
34
+ import { createMockCloudExecutor, type MockCloudExecutor } from "./__tests__/mock-cloud-executor";
35
+ import { createDockerBuildCapability } from "@intentius/chant/components/verbs/build";
36
+ import { createPublishImageCapability } from "./publish";
37
+ import {
38
+ createCfnDeployCapability,
39
+ createEcsUpdateServiceCapability,
40
+ createLambdaDeployCapability,
41
+ CfnReplacementBlockedError,
42
+ } from "./apply";
43
+ import { createCodeDeployCapability } from "./host-delivery";
44
+ import { createWaitClusterHealthyCapability } from "@intentius/chant/components/verbs/wait-verify";
45
+ import {
46
+ createWaitForStackCapability,
47
+ createWaitSteadyStateCapability,
48
+ createWaitJobCapability,
49
+ } from "./wait-aws";
50
+ import { createEmrStartJobRunCapability } from "./job-submission";
51
+
52
+ /** Build a fresh registry with every real #557/#558/#561 capability wired to one shared mock executor, plus the still-stubbed verbs the pilots also reference (run-migration, health-gate, rollback-previous — none of which #557/#558/#561 scopes). */
53
+ function buildRegistry(mock: MockCloudExecutor): CapabilityRegistry {
54
+ const registry = new CapabilityRegistry();
55
+ registry.register(createDockerBuildCapability(mock.executor));
56
+ registry.register(createPublishImageCapability(mock.executor));
57
+ registry.register(createCfnDeployCapability(mock.executor));
58
+ registry.register(createEcsUpdateServiceCapability(mock.executor));
59
+ registry.register(createCodeDeployCapability(mock.executor));
60
+ registry.register(createLambdaDeployCapability(mock.executor));
61
+ registry.register(createWaitForStackCapability(mock.executor));
62
+ registry.register(createWaitSteadyStateCapability(mock.executor));
63
+ registry.register(createWaitClusterHealthyCapability(mock.executor));
64
+ // #561: still a stub for jar-lib/emr-job's `publish-artifact` (Publish,
65
+ // ../verbs/publish.ts) — out of #561's scope (a producer publishing a JVM
66
+ // artifact needs `jvm-build` + `publish-artifact`; neither is an AWS leaf
67
+ // #557/#561 implements), so faked here to succeed and return a resolvable
68
+ // `uri`/`digest`, exactly like `run-migration`/`health-gate` below for the
69
+ // same reason. `emr-start-job-run`/`wait-job` ARE real #561 implementations
70
+ // (../verbs/job-submission.ts, ../verbs/wait-verify.ts) — registered below,
71
+ // not faked.
72
+ registry.register({ kind: "jvm-build", run: async () => ({ archivePath: "lib.jar", digest: "sha256:jar-src" }) });
73
+ registry.register({
74
+ kind: "publish-artifact",
75
+ run: async () => ({ uri: "s3://jar-bucket/jar-lib.jar", digest: "sha256:jar-published" }),
76
+ });
77
+ registry.register(createEmrStartJobRunCapability(mock.executor));
78
+ registry.register(createWaitJobCapability(mock.executor));
79
+ // Non-AWS-leaf / non-pilot-scoped verbs the pilots still reference — out of
80
+ // #557/#558/#561's scope (still typed stubs in ../verbs/*.ts), so faked here
81
+ // to succeed so the happy-path E2E run can reach completion.
82
+ // `rollback-previous` backs the ALB/ECS pilot's own compensation phase.
83
+ registry.register({ kind: "run-migration", run: async () => ({ applied: true, version: "1" }) });
84
+ registry.register({ kind: "health-gate", run: async () => ({ healthy: true }) });
85
+ registry.register({ kind: "rollback-previous", run: async () => ({ restored: true }) });
86
+ return registry;
87
+ }
88
+
89
+ /** Strip the Neo4j pilot's Node-1 human-approval gate — the local driver rejects any `gate` step (matches driver.test.ts's documented behavior); this suite is about capability dispatch, not gate handling, which #556 already covers. */
90
+ function neo4jWithoutGate(): DriverComponent {
91
+ const json = projectToJson(neo4jCluster) as unknown as DriverComponent;
92
+ return {
93
+ ...json,
94
+ deploy: json.deploy.map((p) => ({
95
+ ...p,
96
+ steps: p.steps.filter((s) => (s as { kind?: string }).kind !== "gate"),
97
+ })),
98
+ };
99
+ }
100
+
101
+ describe("Pilots end-to-end through the real interpret driver (#557 acceptance criteria)", () => {
102
+ it("runs all three pilots to completion, dispatching every step to its real #557 capability", async () => {
103
+ const mock = createMockCloudExecutor({
104
+ stacks: {
105
+ "orders-table": { outputs: {} },
106
+ "search-service": { outputs: {} },
107
+ "neo4j-cluster": { outputs: {} },
108
+ },
109
+ ecsServices: { "prod-cluster/search": { runningCount: 2, desiredCount: 2, stable: true } },
110
+ clusters: {
111
+ // The Neo4j pilot's per-instance phases omit `cluster`, so this
112
+ // implementation falls back to `ctx.vars.clusterEndpoints` — supplied
113
+ // below via `runInterpretDriver`'s `vars`.
114
+ "az0:7687,az1:7687,az2:7687": { healthyCount: 3 },
115
+ },
116
+ });
117
+ const registry = buildRegistry(mock);
118
+
119
+ const dynamo = projectToJson(ordersTable) as unknown as DriverComponent;
120
+ const albEcs = projectToJson(searchService) as unknown as DriverComponent;
121
+ // The ALB/ECS pilot depends on "shared-alb", a cross-stack producer not
122
+ // among the three pilots themselves — add a trivial stub component so the
123
+ // graph resolves, exactly as driver.test.ts's own sprawl-metric case does.
124
+ const sharedAlb: DriverComponent = {
125
+ name: "shared-alb",
126
+ dependsOn: [],
127
+ deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "shared-alb", template: "archive:alb.json" }] }],
128
+ };
129
+ // The pilot's ecs-update-service step wires `cluster: "$env.cluster"` — a
130
+ // literal the driver deliberately leaves unresolved (env resolution is the
131
+ // caller's job, not the driver's; see driver.ts's resolveWiring docstring).
132
+ // Substitute the concrete env value here, exactly as a real caller would
133
+ // before invoking the driver.
134
+ const albEcsResolved: DriverComponent = {
135
+ ...albEcs,
136
+ deploy: albEcs.deploy.map((p) => ({
137
+ ...p,
138
+ steps: p.steps.map((s) =>
139
+ (s as { kind?: string }).kind === "ecs-update-service" ? { ...s, cluster: "prod-cluster" } : s,
140
+ ),
141
+ })),
142
+ rollback: albEcs.rollback?.map((p) => ({
143
+ ...p,
144
+ steps: p.steps.map((s) => ({ ...s, cluster: "cluster" in s ? "prod-cluster" : undefined })),
145
+ })),
146
+ };
147
+
148
+ const result = await runInterpretDriver([sharedAlb, dynamo, neo4jWithoutGate(), albEcsResolved], registry, {
149
+ env: "dev",
150
+ vars: { registry: "123.dkr.ecr.us-east-1.amazonaws.com", cluster: "prod-cluster", clusterEndpoints: "az0:7687,az1:7687,az2:7687" },
151
+ });
152
+
153
+ expect(result.ok).toBe(true);
154
+ expect(result.results.map((r) => r.component).sort()).toEqual(
155
+ ["shared-alb", "orders-table", "neo4j-cluster", "search-service"].sort(),
156
+ );
157
+
158
+ // Dispatch assertions: every `deploy`-phase step landed on the capability
159
+ // its `kind` names. `docker-build` itself is the component's top-level
160
+ // `build` field, not a `deploy` step — the driver (#556) only interprets
161
+ // `deploy`/`rollback` phases, so build/archival happens upstream of this
162
+ // driver run (see epic #551 §4); `publish-image` (a real `deploy` step,
163
+ // the Publish phase) is what this run exercises for the image path.
164
+ const callsByClient = mock.calls.map((c) => `${c.client}.${c.method}`);
165
+ expect(callsByClient).toContain("docker.load"); // search-service's publish-image (loads the archived tarball)
166
+ expect(callsByClient).toContain("docker.push"); // search-service's publish-image
167
+ expect(callsByClient).toContain("cloudformation.createChangeSet"); // every cfn-deploy (shared-alb, orders-table, neo4j x3, search-service)
168
+ expect(callsByClient).toContain("ecs.updateService"); // search-service's ecs-update-service
169
+ expect(callsByClient).toContain("codeDeploy.createDeployment"); // neo4j's per-instance code-deploy
170
+ expect(callsByClient).toContain("neo4j.probe"); // neo4j's wait-cluster-healthy
171
+
172
+ // search-service depends on shared-alb, so it must run in a strictly later wave.
173
+ const albWave = result.waves.findIndex((w) => w.includes("shared-alb"));
174
+ const searchWave = result.waves.findIndex((w) => w.includes("search-service"));
175
+ expect(searchWave).toBeGreaterThan(albWave);
176
+ });
177
+
178
+ it("runs all four components — the three pilots plus the #558 image-processor-lambda validation component — to completion through one driver, dispatching every step to its real capability", async () => {
179
+ const mock = createMockCloudExecutor({
180
+ stacks: {
181
+ "orders-table": { outputs: {} },
182
+ "search-service": { outputs: {} },
183
+ "neo4j-cluster": { outputs: {} },
184
+ },
185
+ ecsServices: { "prod-cluster/search": { runningCount: 2, desiredCount: 2, stable: true } },
186
+ clusters: {
187
+ "az0:7687,az1:7687,az2:7687": { healthyCount: 3 },
188
+ },
189
+ });
190
+ const registry = buildRegistry(mock);
191
+
192
+ const dynamo = projectToJson(ordersTable) as unknown as DriverComponent;
193
+ const albEcs = projectToJson(searchService) as unknown as DriverComponent;
194
+ const lambda = projectToJson(imageProcessor) as unknown as DriverComponent;
195
+ const sharedAlb: DriverComponent = {
196
+ name: "shared-alb",
197
+ dependsOn: [],
198
+ deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "shared-alb", template: "archive:alb.json" }] }],
199
+ };
200
+ const albEcsResolved: DriverComponent = {
201
+ ...albEcs,
202
+ deploy: albEcs.deploy.map((p) => ({
203
+ ...p,
204
+ steps: p.steps.map((s) =>
205
+ (s as { kind?: string }).kind === "ecs-update-service" ? { ...s, cluster: "prod-cluster" } : s,
206
+ ),
207
+ })),
208
+ rollback: albEcs.rollback?.map((p) => ({
209
+ ...p,
210
+ steps: p.steps.map((s) => ({ ...s, cluster: "cluster" in s ? "prod-cluster" : undefined })),
211
+ })),
212
+ };
213
+
214
+ const result = await runInterpretDriver(
215
+ [sharedAlb, dynamo, neo4jWithoutGate(), albEcsResolved, lambda],
216
+ registry,
217
+ {
218
+ env: "dev",
219
+ vars: {
220
+ registry: "123.dkr.ecr.us-east-1.amazonaws.com",
221
+ cluster: "prod-cluster",
222
+ clusterEndpoints: "az0:7687,az1:7687,az2:7687",
223
+ },
224
+ },
225
+ );
226
+
227
+ expect(result.ok).toBe(true);
228
+ expect(result.results.map((r) => r.component).sort()).toEqual(
229
+ ["shared-alb", "orders-table", "neo4j-cluster", "search-service", "image-processor-lambda"].sort(),
230
+ );
231
+
232
+ // image-processor-lambda's Apply phase dispatches to lambda-deploy, never
233
+ // cfn-deploy — the distinguishing shape #558 added on top of the original
234
+ // three pilots (all of which apply via CloudFormation).
235
+ const callsByClient = mock.calls.map((c) => `${c.client}.${c.method}`);
236
+ expect(callsByClient).toContain("lambda.updateFunctionCode");
237
+ expect(callsByClient).toContain("lambda.publishVersion");
238
+ expect(callsByClient).toContain("lambda.updateAlias");
239
+ });
240
+
241
+ it('cfn-deploy refuses a data-losing replacement when onReplace: "block" — the DynamoDB pilot\'s declared policy', async () => {
242
+ const mock = createMockCloudExecutor({
243
+ stacks: {
244
+ "orders-table": {
245
+ changes: [
246
+ {
247
+ action: "Modify",
248
+ logicalResourceId: "OrdersTable",
249
+ resourceType: "AWS::DynamoDB::Table",
250
+ replacement: true, // CloudFormation proposes replacing (destroying + recreating) the table.
251
+ },
252
+ ],
253
+ },
254
+ },
255
+ });
256
+ const registry = buildRegistry(mock);
257
+ const dynamo = projectToJson(ordersTable) as unknown as DriverComponent;
258
+
259
+ // The DynamoDB pilot declares onReplace: "block" (see dynamodb.pilot.ts) —
260
+ // confirm the composition itself, then confirm the driver run actually
261
+ // refuses rather than silently applying a destructive replacement.
262
+ const applyStep = dynamo.deploy[0]!.steps[0] as { onReplace?: string };
263
+ expect(applyStep.onReplace).toBe("block");
264
+
265
+ await expect(runInterpretDriver([dynamo], registry, { env: "dev" })).rejects.toThrow(DriverRunFailure);
266
+
267
+ try {
268
+ await runInterpretDriver([dynamo], registry, { env: "dev" });
269
+ expect.unreachable();
270
+ } catch (err) {
271
+ expect(err).toBeInstanceOf(DriverRunFailure);
272
+ const failure = err as DriverRunFailure;
273
+ expect(failure.result.ok).toBe(false);
274
+ expect(failure.result.failedComponent).toBe("orders-table");
275
+ const failedStep = failure.result.results[0]!.records.find((r) => r.status === "fail");
276
+ expect(failedStep?.kind).toBe("cfn-deploy");
277
+ expect(failedStep?.error).toMatch(/refusing changeset/);
278
+ }
279
+
280
+ // The changeset must never have been executed — no mutation happened.
281
+ expect(mock.calls.some((c) => c.method === "executeChangeSet")).toBe(false);
282
+ });
283
+
284
+ it('cfn-deploy allows the same replacing changeset when onReplace: "allow" is declared', async () => {
285
+ const mock = createMockCloudExecutor({
286
+ stacks: {
287
+ "orders-table": {
288
+ changes: [
289
+ {
290
+ action: "Modify",
291
+ logicalResourceId: "OrdersTable",
292
+ resourceType: "AWS::DynamoDB::Table",
293
+ replacement: true,
294
+ },
295
+ ],
296
+ },
297
+ },
298
+ });
299
+ const registry = buildRegistry(mock);
300
+ const dynamoAllow: DriverComponent = {
301
+ ...(projectToJson(ordersTable) as unknown as DriverComponent),
302
+ deploy: [
303
+ {
304
+ phase: "Apply",
305
+ steps: [{ kind: "cfn-deploy", stack: "orders-table", template: "archive:orders-table.template.json", onReplace: "allow" }],
306
+ },
307
+ ],
308
+ };
309
+ const result = await runInterpretDriver([dynamoAllow], registry, { env: "dev" });
310
+ expect(result.ok).toBe(true);
311
+ expect(mock.calls.some((c) => c.method === "executeChangeSet")).toBe(true);
312
+ });
313
+ });
314
+
315
+ describe("Cross-component artifact outputs: jar-lib producer -> emr-job consumer (#561 acceptance criteria)", () => {
316
+ it("orders the producer before the consumer and resolves @jar-lib.publish.uri into the consumer's Submit step, purely via the graph", async () => {
317
+ const mock = createMockCloudExecutor({ jobRuns: {} });
318
+ const registry = buildRegistry(mock);
319
+
320
+ const jarLibJson = projectToJson(jarLib) as unknown as DriverComponent;
321
+ const emrJobJson = projectToJson(emrJob) as unknown as DriverComponent;
322
+
323
+ const result = await runInterpretDriver([emrJobJson, jarLibJson], registry, {
324
+ env: "dev",
325
+ vars: { s3: "s3://jar-bucket", inputPath: "s3://data/in", outputPath: "s3://data/out" },
326
+ });
327
+
328
+ expect(result.ok).toBe(true);
329
+ // jar-lib (the producer) must land in a strictly earlier wave than
330
+ // emr-job (the consumer) — resolveComponentGraph ordering from `dependsOn`.
331
+ expect(result.waves).toEqual([["jar-lib"], ["emr-job"]]);
332
+ expect(result.order).toEqual(["jar-lib", "emr-job"]);
333
+
334
+ // The consumer's emr-start-job-run call received the producer's published
335
+ // S3 URI, not the literal "@jar-lib.publish.uri" string — resolved by
336
+ // ../driver.ts's resolveWiring/resolveComponentGraph, never by any
337
+ // orchestrator- or capability-level special-casing of "jar-lib"/"emr-job".
338
+ const startJobRunCall = mock.calls.find((c) => c.client === "emr" && c.method === "startJobRun")!;
339
+ expect(startJobRunCall.args).toMatchObject({ jar: "s3://jar-bucket/jar-lib.jar" });
340
+
341
+ // Verify phase's @Submit.runId prior-step reference also resolved.
342
+ const waitCall = mock.calls.find((c) => c.client === "emr" && c.method === "waitForJobRun")!;
343
+ expect(waitCall.args).toBe("mock-job-run-1");
344
+ });
345
+
346
+ it("fails the run if the EMR job ends in a non-COMPLETED terminal state, without ever touching driver.ts's generic dispatch", async () => {
347
+ const mock = createMockCloudExecutor({ jobRuns: { "mock-job-run-1": { terminalState: "FAILED" } } });
348
+ const registry = buildRegistry(mock);
349
+
350
+ const jarLibJson = projectToJson(jarLib) as unknown as DriverComponent;
351
+ const emrJobJson = projectToJson(emrJob) as unknown as DriverComponent;
352
+
353
+ await expect(
354
+ runInterpretDriver([jarLibJson, emrJobJson], registry, {
355
+ env: "dev",
356
+ vars: { s3: "s3://jar-bucket", inputPath: "s3://data/in", outputPath: "s3://data/out" },
357
+ }),
358
+ ).rejects.toThrow(DriverRunFailure);
359
+ });
360
+
361
+ it("emr-start-job-run declares no rollback — starting a job run has nothing to compensate once it's running (documented opt-out, matching wait-for-stack's read-only-observation story)", () => {
362
+ const mock = createMockCloudExecutor();
363
+ expect(createEmrStartJobRunCapability(mock.executor).rollback).toBeUndefined();
364
+ expect(createWaitJobCapability(mock.executor).rollback).toBeUndefined();
365
+ });
366
+ });
367
+
368
+ describe("Forced mid-deploy failure — saga rollback through the real driver + real capabilities (#565 acceptance criteria)", () => {
369
+ it("Neo4j fan-out: a failing Node 1 code-deploy unwinds in reverse through native rollbacks — code-deploy's own stopAndRollback for Node 1, then Seed's code-deploy stopAndRollback, then Seed's cfn-deploy native rollbackStack — dispatched purely by capability kind, never by pilot/component name", async () => {
370
+ const mock = createMockCloudExecutor({
371
+ stacks: { "neo4j-cluster": { outputs: {} } },
372
+ clusters: { "az0:7687,az1:7687,az2:7687": { healthyCount: 3 } },
373
+ // Node 1's code-deploy deployment (the second createDeployment call
374
+ // overall — Seed's is the first) ends "Failed", forcing the mid-deploy
375
+ // failure this test is about.
376
+ deployments: { "mock-deployment-2": { terminalStatus: "Failed" } },
377
+ });
378
+ const registry = buildRegistry(mock);
379
+
380
+ const neo4j = neo4jWithoutGate();
381
+ let failure: DriverRunFailure;
382
+ try {
383
+ await runInterpretDriver([neo4j], registry, {
384
+ env: "dev",
385
+ vars: { clusterEndpoints: "az0:7687,az1:7687,az2:7687" },
386
+ });
387
+ expect.unreachable("expected runInterpretDriver to throw DriverRunFailure");
388
+ return;
389
+ } catch (err) {
390
+ expect(err).toBeInstanceOf(DriverRunFailure);
391
+ failure = err as DriverRunFailure;
392
+ }
393
+
394
+ expect(failure.result.ok).toBe(false);
395
+ expect(failure.result.failedComponent).toBe("neo4j-cluster");
396
+ const records = failure.result.results[0]!.records;
397
+
398
+ // Node 1's code-deploy is the step that actually failed.
399
+ const failedStep = records.find((r) => r.status === "fail");
400
+ expect(failedStep?.kind).toBe("code-deploy");
401
+
402
+ // Every executed step before the failure gets unwound: Node 1's cfn-deploy
403
+ // (executed, since code-deploy runs after it in the same phase) and both
404
+ // of Seed's steps (cfn-deploy, code-deploy) — Seed's wait-cluster-healthy
405
+ // has no rollback (read-only observation) and is reported as opted-out,
406
+ // not silently skipped.
407
+ const rolledBack = records.filter((r) => r.status === "rolled-back").map((r) => r.kind);
408
+ expect(rolledBack).toEqual(["cfn-deploy", "code-deploy", "cfn-deploy"]);
409
+
410
+ const optedOut = records.filter((r) => r.status === "rollback-opted-out");
411
+ expect(optedOut.map((r) => r.kind)).toEqual(["wait-cluster-healthy"]);
412
+ expect(optedOut[0]?.error).toMatch(/declares no rollback/);
413
+
414
+ // The native platform mechanisms actually fired — not a chant-scripted
415
+ // re-apply: CodeDeploy's own stopAndRollback for both code-deploy steps
416
+ // that ran, and CloudFormation's own rollbackStack for the cfn-deploy that
417
+ // ran (Node 1's; Seed's stack update is unwound the same way once Node 1's
418
+ // steps are done unwinding — both are the same "neo4j-cluster" stack, so
419
+ // one rollbackStack call covers it, matching real CFN semantics where a
420
+ // stack has one rollback target regardless of how many updates preceded
421
+ // it).
422
+ const stopAndRollbackCalls = mock.calls.filter((c) => c.client === "codeDeploy" && c.method === "stopAndRollback");
423
+ expect(stopAndRollbackCalls.length).toBeGreaterThanOrEqual(1);
424
+ const cfnRollbackCalls = mock.calls.filter((c) => c.client === "cloudformation" && c.method === "rollbackStack");
425
+ expect(cfnRollbackCalls.length).toBeGreaterThanOrEqual(1);
426
+ });
427
+ });
428
+
429
+ describe("Every mutating #557 capability declares a rollback, or the pilot supplies an explicit opt-out", () => {
430
+ it("cfn-deploy, ecs-update-service, and code-deploy declare a native rollback", () => {
431
+ const mock = createMockCloudExecutor();
432
+ expect(createCfnDeployCapability(mock.executor).rollback).toBeDefined();
433
+ expect(createEcsUpdateServiceCapability(mock.executor).rollback).toBeDefined();
434
+ expect(createCodeDeployCapability(mock.executor).rollback).toBeDefined();
435
+ });
436
+
437
+ it("docker-build and publish-image declare no rollback by design (no remote/mutable state to compensate) — an explicit, documented opt-out, not silence", () => {
438
+ const mock = createMockCloudExecutor();
439
+ expect(createDockerBuildCapability(mock.executor).rollback).toBeUndefined();
440
+ expect(createPublishImageCapability(mock.executor).rollback).toBeUndefined();
441
+ });
442
+
443
+ it("wait-for-stack / wait-steady-state / wait-cluster-healthy declare no rollback — read-only observation, nothing to compensate", () => {
444
+ const mock = createMockCloudExecutor();
445
+ expect(createWaitForStackCapability(mock.executor).rollback).toBeUndefined();
446
+ expect(createWaitSteadyStateCapability(mock.executor).rollback).toBeUndefined();
447
+ expect(createWaitClusterHealthyCapability(mock.executor).rollback).toBeUndefined();
448
+ });
449
+
450
+ it("the ALB/ECS pilot supplies its own explicit rollback phase (rollback-previous) precisely because ecs-update-service's own rollback is a best-effort re-apply, not a true prior-state restore", () => {
451
+ // Documents the pilot-level opt-out this issue's acceptance criteria asks
452
+ // for: the component, not the capability, owns the compensation here.
453
+ expect(searchService.rollback).toBeDefined();
454
+ expect(searchService.rollback![0]!.steps[0]).toMatchObject({ kind: "rollback-previous" });
455
+ });
456
+
457
+ it("the DynamoDB pilot declares no component-level rollback — a blocked replacement is a stop, not something to compensate (documented opt-out)", () => {
458
+ expect(ordersTable.rollback).toBeUndefined();
459
+ });
460
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * End-to-end verification for #566: all three presets run to completion
3
+ * through the real interpret driver (../driver.ts), dispatching to real
4
+ * capability implementations wired to a `MockCloudExecutor` — no live AWS, no
5
+ * live docker — mirroring ../pilots/pilots-e2e.test.ts's pattern for the
6
+ * hand-composed pilots.
7
+ *
8
+ * Also proves the acceptance criterion "a preset-based and a hand-composed
9
+ * component produce equivalent contracts and both run through the driver":
10
+ * `EcsFargateComponent`'s expansion and `alb-ecs.pilot.ts`'s hand-composed
11
+ * `searchService` are run through the same driver instance side by side and
12
+ * assert identical dispatch behavior.
13
+ */
14
+
15
+ import { describe, expect, it } from "vitest";
16
+ import { CapabilityRegistry } from "@intentius/chant/components/capability";
17
+ import { runInterpretDriver, type DriverComponent } from "@intentius/chant/components/driver";
18
+ import { projectToJson } from "@intentius/chant/components/component";
19
+ import { searchService } from "@intentius/chant/components/pilots/alb-ecs.pilot";
20
+ import { EcsFargateComponent } from "@intentius/chant/components/presets/ecs-fargate";
21
+ import { LambdaComponent } from "@intentius/chant/components/presets/lambda";
22
+ import { SingleHostComposeComponent } from "@intentius/chant/components/presets/single-host-compose";
23
+ import { createMockCloudExecutor, type MockCloudExecutor } from "./__tests__/mock-cloud-executor";
24
+ import { createDockerBuildCapability } from "@intentius/chant/components/verbs/build";
25
+ import { createPublishImageCapability, createLoadImageOnHostCapability } from "./publish";
26
+ import { createCfnDeployCapability, createEcsUpdateServiceCapability, createLambdaDeployCapability } from "./apply";
27
+ import { createWaitSteadyStateCapability } from "./wait-aws";
28
+
29
+ /** Same registry-building convention as ../pilots/pilots-e2e.test.ts: real capabilities wired to one shared mock executor, plus fakes for verbs this suite's presets reference but that stay typed stubs (out of scope for #566, matching the pilots' own accounting). */
30
+ function buildRegistry(mock: MockCloudExecutor): CapabilityRegistry {
31
+ const registry = new CapabilityRegistry();
32
+ registry.register(createDockerBuildCapability(mock.executor));
33
+ registry.register(createPublishImageCapability(mock.executor));
34
+ registry.register(createLoadImageOnHostCapability(mock.executor));
35
+ registry.register(createCfnDeployCapability(mock.executor));
36
+ registry.register(createEcsUpdateServiceCapability(mock.executor));
37
+ registry.register(createLambdaDeployCapability(mock.executor));
38
+ registry.register(createWaitSteadyStateCapability(mock.executor));
39
+ registry.register({ kind: "health-gate", run: async () => ({ healthy: true }) });
40
+ registry.register({ kind: "rollback-previous", run: async () => ({ restored: true }) });
41
+ registry.register({ kind: "copy-to-host", run: async () => ({ bytesCopied: 128 }) });
42
+ registry.register({ kind: "remote-exec", run: async () => ({ exitCode: 0, stdout: "" }) });
43
+ registry.register({ kind: "wait-endpoint", run: async () => ({ status: 200 }) });
44
+ return registry;
45
+ }
46
+
47
+ describe("Preset library end-to-end through the real interpret driver (#566 acceptance criteria)", () => {
48
+ it("EcsFargateComponent expands to a component that runs to completion, dispatching every step to its real capability", async () => {
49
+ const mock = createMockCloudExecutor({
50
+ stacks: { "shared-alb": { outputs: {} }, "search-service-preset": { outputs: {} } },
51
+ ecsServices: { "prod-cluster/search": { runningCount: 2, desiredCount: 2, stable: true } },
52
+ });
53
+ const registry = buildRegistry(mock);
54
+
55
+ const sharedAlb: DriverComponent = {
56
+ name: "shared-alb",
57
+ dependsOn: [],
58
+ deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "shared-alb", template: "archive:alb.json" }] }],
59
+ };
60
+ const service = projectToJson(
61
+ EcsFargateComponent({
62
+ name: "search-service-preset",
63
+ service: "search",
64
+ healthPath: "/healthz",
65
+ sharedAlbStack: "shared-alb",
66
+ cluster: "prod-cluster",
67
+ }),
68
+ ) as unknown as DriverComponent;
69
+
70
+ const result = await runInterpretDriver([sharedAlb, service], registry, {
71
+ env: "dev",
72
+ vars: { registry: "123.dkr.ecr.us-east-1.amazonaws.com" },
73
+ });
74
+
75
+ expect(result.ok).toBe(true);
76
+ const callsByClient = mock.calls.map((c) => `${c.client}.${c.method}`);
77
+ expect(callsByClient).toContain("docker.push"); // publish-image
78
+ expect(callsByClient).toContain("ecs.updateService"); // ecs-update-service
79
+ expect(callsByClient).toContain("ecs.describeService"); // wait-steady-state
80
+ });
81
+
82
+ it("a preset-based EcsFargateComponent and the hand-composed alb-ecs.pilot.ts searchService drive identical capability dispatch through the same registry", async () => {
83
+ const mock = createMockCloudExecutor({
84
+ stacks: { "shared-alb": { outputs: {} }, "search-service": { outputs: {} } },
85
+ ecsServices: { "prod-cluster/search": { runningCount: 2, desiredCount: 2, stable: true } },
86
+ });
87
+ const registry = buildRegistry(mock);
88
+
89
+ const sharedAlb: DriverComponent = {
90
+ name: "shared-alb",
91
+ dependsOn: [],
92
+ deploy: [{ phase: "Apply", steps: [{ kind: "cfn-deploy", stack: "shared-alb", template: "archive:alb.json" }] }],
93
+ };
94
+ const handComposed = projectToJson(searchService) as unknown as DriverComponent;
95
+ const handComposedResolved: DriverComponent = {
96
+ ...handComposed,
97
+ deploy: handComposed.deploy.map((p) => ({
98
+ ...p,
99
+ steps: p.steps.map((s) =>
100
+ (s as { kind?: string }).kind === "ecs-update-service" ? { ...s, cluster: "prod-cluster" } : s,
101
+ ),
102
+ })),
103
+ rollback: handComposed.rollback?.map((p) => ({
104
+ ...p,
105
+ steps: p.steps.map((s) => ({ ...s, cluster: "cluster" in s ? "prod-cluster" : undefined })),
106
+ })),
107
+ };
108
+
109
+ const result = await runInterpretDriver([sharedAlb, handComposedResolved], registry, {
110
+ env: "dev",
111
+ vars: { registry: "123.dkr.ecr.us-east-1.amazonaws.com" },
112
+ });
113
+
114
+ expect(result.ok).toBe(true);
115
+ // The same dispatch shape the preset-based run above exercises: both the
116
+ // preset expansion and the hand-composed pilot are, underneath, the exact
117
+ // same contract driving the exact same driver — Level 2 convenience over
118
+ // Level 1 primitives, never a second orchestrator behavior.
119
+ const callsByClient = mock.calls.map((c) => `${c.client}.${c.method}`);
120
+ expect(callsByClient).toContain("docker.push");
121
+ expect(callsByClient).toContain("ecs.updateService");
122
+ expect(callsByClient).toContain("ecs.describeService");
123
+ });
124
+
125
+ it("LambdaComponent expands to a component that runs to completion, dispatching to lambda-deploy (no cfn-deploy at all)", async () => {
126
+ const mock = createMockCloudExecutor({});
127
+ const registry = buildRegistry(mock);
128
+
129
+ const component = projectToJson(
130
+ LambdaComponent({ name: "thumbnailer-preset", functionName: "thumbnailer" }),
131
+ ) as unknown as DriverComponent;
132
+
133
+ const result = await runInterpretDriver([component], registry, {
134
+ env: "dev",
135
+ vars: { registry: "123.dkr.ecr.us-east-1.amazonaws.com" },
136
+ });
137
+
138
+ expect(result.ok).toBe(true);
139
+ const callsByClient = mock.calls.map((c) => `${c.client}.${c.method}`);
140
+ expect(callsByClient).toContain("lambda.updateFunctionCode");
141
+ expect(callsByClient).toContain("lambda.publishVersion");
142
+ expect(callsByClient).toContain("lambda.updateAlias");
143
+ expect(callsByClient.some((c) => c.startsWith("cloudformation."))).toBe(false);
144
+ });
145
+
146
+ it("SingleHostComposeComponent expands to a component that runs to completion, dispatching to load-image-on-host (registry-less) and remote-exec", async () => {
147
+ const mock = createMockCloudExecutor({});
148
+ const registry = buildRegistry(mock);
149
+
150
+ const component = projectToJson(
151
+ SingleHostComposeComponent({ name: "monitoring-host-preset", healthPath: "/-/healthy", healthPort: 9090 }),
152
+ ) as unknown as DriverComponent;
153
+
154
+ const result = await runInterpretDriver([component], registry, {
155
+ env: "dev",
156
+ vars: { host: "10.0.0.5" },
157
+ });
158
+
159
+ expect(result.ok).toBe(true);
160
+ const callsByClient = mock.calls.map((c) => `${c.client}.${c.method}`);
161
+ expect(callsByClient).toContain("host.copyFile"); // load-image-on-host
162
+ expect(callsByClient).toContain("host.dockerLoad"); // load-image-on-host
163
+ expect(callsByClient.some((c) => c.startsWith("docker.push"))).toBe(false); // never touches a registry
164
+ expect(callsByClient.some((c) => c.startsWith("ecr."))).toBe(false);
165
+ });
166
+ });