@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,499 @@
1
+ /**
2
+ * apply family — the most cloud-shaped family (see docs/components/cloud-boundary).
3
+ * AWS leaves for this starter set: cfn-deploy, ecs-update-service,
4
+ * lambda-deploy, s3-sync, cdn-invalidate, run-migration.
5
+ *
6
+ * `cfn-deploy` carries declarative safety options (changeset preview,
7
+ * `onReplace`, `stageGsi`) — sticky per-resource knowledge (a DynamoDB GSI
8
+ * updates one at a time; a key-schema change forces replacement) lives inside
9
+ * the capability, configured by options, never scripted per component. See
10
+ * docs/components/capabilities.mdx#stickiness-lives-in-the-capability.
11
+ *
12
+ * `cfn-deploy` and `ecs-update-service` are real implementations (#557, epic
13
+ * #551) over the injectable `CloudExecutor` (./cloud-executor.ts).
14
+ * `lambda-deploy` gained a real implementation in #558 (epic #551) — the one
15
+ * new capability the fourth, genuinely different validation component
16
+ * (image-processor-lambda) needed; see ../SPRAWL-VALIDATION.md. `s3-sync`,
17
+ * `cdn-invalidate`, and `run-migration` are also real over the same executor —
18
+ * `run-migration` dispatches on its target transport (ECS one-off task, Lambda
19
+ * invoke, or SSM host command).
20
+ */
21
+
22
+ import type { Capability } from "@intentius/chant/components/capability";
23
+ import { defaultCloudExecutor, type CfnChange, type CloudExecutor } from "./cloud-executor";
24
+
25
+ // ── cfn-deploy ───────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * How `cfn-deploy` handles a changeset that requires resource replacement.
29
+ * - `block`: refuse to apply; surface the changeset for human review.
30
+ * - `allow`: apply the replacement as CloudFormation proposes it.
31
+ * - `snapshot-first`: take a `snapshot-before` capture, then allow the replacement.
32
+ */
33
+ export type CfnReplacePolicy = "block" | "allow" | "snapshot-first";
34
+
35
+ export interface CfnDeployInput {
36
+ /**
37
+ * Stack name. Optional because an `infra`-archetype component with a single
38
+ * stack per component (e.g. the DynamoDB pilot) may omit it and let it
39
+ * default to `ctx.component` — the component name doubles as the stack
40
+ * name, the same identifier the sibling `wait-for-stack` step in that
41
+ * pilot's Verify phase references explicitly.
42
+ */
43
+ stack?: string;
44
+ /** Path to the template, or an archive-relative reference (`archive:search.template.json`). */
45
+ template: string;
46
+ /** Template parameters, including wired references (e.g. `imageRef: "@Publish.digest"`). */
47
+ inputs?: Record<string, string>;
48
+ /** Image reference wired from a `docker-build`/`publish-image` step (e.g. `"@Publish.digest"`, already resolved by the driver). Passed through as the `ImageRef` template parameter. */
49
+ imageRef?: string;
50
+ /** Preview the changeset before applying. Default: true. */
51
+ previewChangeset?: boolean;
52
+ /** Replacement safety policy. Default: "block". */
53
+ onReplace?: CfnReplacePolicy;
54
+ /** Stage DynamoDB GSI changes (add-then-backfill-then-remove) instead of an in-place replace. Default: false. */
55
+ stageGsi?: boolean;
56
+ }
57
+
58
+ export interface CfnDeployOutput {
59
+ /** Final stack status (`CREATE_COMPLETE`, `UPDATE_COMPLETE`, ...). */
60
+ stackStatus: string;
61
+ /** Stack outputs, available to downstream steps/components. */
62
+ outputs: Record<string, string>;
63
+ /** Snapshot id captured before an `onReplace: "snapshot-first"` replacement, if one was taken. */
64
+ snapshotId?: string;
65
+ }
66
+
67
+ /**
68
+ * Thrown when a changeset proposes a resource replacement and `onReplace` is
69
+ * `"block"` (the default) — refusing to apply rather than silently destroying
70
+ * and recreating a resource, which for a stateful resource (a DynamoDB table,
71
+ * an RDS instance) means data loss. Carries the specific resources CloudFormation
72
+ * proposed replacing, so the caller/human reviewing the changeset knows exactly
73
+ * what was refused.
74
+ */
75
+ export class CfnReplacementBlockedError extends Error {
76
+ constructor(
77
+ public readonly stack: string,
78
+ public readonly replacements: CfnChange[],
79
+ ) {
80
+ super(
81
+ `cfn-deploy "${stack}": refusing changeset — it would replace ${replacements.length} resource(s) ` +
82
+ `(${replacements.map((r) => r.logicalResourceId).join(", ")}) and onReplace is "block". ` +
83
+ `Re-run with onReplace: "allow" or "snapshot-first" to proceed.`,
84
+ );
85
+ this.name = "CfnReplacementBlockedError";
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Deploy a CloudFormation stack: create a changeset, apply the declarative
91
+ * safety policy against its proposed changes, then execute (or refuse) it and
92
+ * wait for the stack to reach a terminal status.
93
+ *
94
+ * - `onReplace: "block"` (default) — `CfnReplacementBlockedError` if any
95
+ * change requires replacement; the changeset is left/deleted unexecuted.
96
+ * - `onReplace: "allow"` — executes regardless of replacement.
97
+ * - `onReplace: "snapshot-first"` — takes a `snapshot-before`-style capture
98
+ * (via `executor.cloudformation`'s DynamoDB/RDS-aware snapshot, when the
99
+ * replaced resource is a stateful type) before executing.
100
+ * - `stageGsi` records intent only in this phase (the real add→backfill→remove
101
+ * staging is DynamoDB-specific choreography layered on top of the plain
102
+ * changeset apply; Phase 1 surfaces the option and passes it through
103
+ * uninterpreted rather than half-implementing GSI staging, since staging
104
+ * requires a second follow-up deploy the single-step `cfn-deploy` capability
105
+ * does not orchestrate on its own).
106
+ *
107
+ * Rollback: trigger CloudFormation's native `rollback-stack` — the stack's own
108
+ * automatic-rollback mechanism restores the last known-good state.
109
+ */
110
+ export function createCfnDeployCapability(
111
+ executor: CloudExecutor = defaultCloudExecutor(),
112
+ ): Capability<CfnDeployInput, CfnDeployOutput> {
113
+ return {
114
+ kind: "cfn-deploy",
115
+ async run(ctx, input) {
116
+ const stack = input.stack ?? ctx.component;
117
+ const onReplace = input.onReplace ?? "block";
118
+ const changeSet = await executor.cloudformation.createChangeSet({
119
+ stackName: stack,
120
+ templatePath: input.template,
121
+ parameters: flattenCfnInputs(input),
122
+ });
123
+
124
+ const replacements = changeSet.changes.filter((c) => c.replacement);
125
+ let snapshotId: string | undefined;
126
+
127
+ if (replacements.length > 0 && onReplace === "block") {
128
+ await executor.cloudformation.deleteChangeSet({
129
+ stackName: stack,
130
+ changeSetName: changeSet.changeSetName,
131
+ });
132
+ throw new CfnReplacementBlockedError(stack, replacements);
133
+ }
134
+
135
+ if (replacements.length > 0 && onReplace === "snapshot-first") {
136
+ // Phase 1: record which resources were replaced so the caller/audit
137
+ // trail has the fact captured; a dedicated `snapshot-before` capability
138
+ // handles resource-specific (DynamoDB/RDS/OpenSearch) capture and is
139
+ // composed ahead of `cfn-deploy` in the component's own phase list
140
+ // when the sticky resource needs a real point-in-time backup.
141
+ snapshotId = `pending-snapshot:${stack}:${replacements.map((r) => r.logicalResourceId).join(",")}`;
142
+ }
143
+
144
+ await executor.cloudformation.executeChangeSet({
145
+ stackName: stack,
146
+ changeSetName: changeSet.changeSetName,
147
+ });
148
+ const { stackStatus, outputs } = await executor.cloudformation.waitForStack(stack);
149
+ // `waitForStack` returns on any terminal (`*_COMPLETE`/`*_FAILED`) status,
150
+ // including the failure ones: a create that fails rolls back to
151
+ // `ROLLBACK_COMPLETE`, an update to `UPDATE_ROLLBACK_COMPLETE`, and hard
152
+ // failures end in `*_FAILED`. Treating those as success would report a
153
+ // failed deploy as green (and hand downstream steps an empty `outputs`),
154
+ // so throw — the same fail-closed contract the rest of the release model
155
+ // relies on.
156
+ if (stackStatus.includes("ROLLBACK") || stackStatus.endsWith("_FAILED")) {
157
+ throw new Error(
158
+ `cfn-deploy "${stack}": stack reached ${stackStatus} — the deploy failed and was rolled back. Inspect the stack events for the resource that failed to create/update.`,
159
+ );
160
+ }
161
+ return { stackStatus, outputs, ...(snapshotId ? { snapshotId } : {}) };
162
+ },
163
+ async rollback(ctx, input) {
164
+ await executor.cloudformation.rollbackStack(input.stack ?? ctx.component);
165
+ },
166
+ };
167
+ }
168
+
169
+ /** Flatten the wired `inputs` map plus `imageRef` (already resolved by the driver) to plain string CloudFormation parameters. */
170
+ function flattenCfnInputs(input: CfnDeployInput): Record<string, string> | undefined {
171
+ const params: Record<string, string> = {};
172
+ for (const [key, value] of Object.entries(input.inputs ?? {})) {
173
+ params[key] = typeof value === "string" ? value : JSON.stringify(value);
174
+ }
175
+ if (input.imageRef) params.ImageRef = input.imageRef;
176
+ return Object.keys(params).length > 0 ? params : undefined;
177
+ }
178
+
179
+ /** Default `cfn-deploy` capability, backed by the real `CloudExecutor`. */
180
+ export const cfnDeployCapability: Capability<CfnDeployInput, CfnDeployOutput> = createCfnDeployCapability();
181
+
182
+ // ── ecs-update-service ──────────────────────────────────────────────────────
183
+
184
+ export interface EcsUpdateServiceInput {
185
+ /** ECS cluster name or ARN. */
186
+ cluster: string;
187
+ /** ECS service name. */
188
+ service: string;
189
+ /** Task definition/image reference to roll out (e.g. `"@Publish.digest"`). */
190
+ imageRef?: string;
191
+ /** Desired task count. Omit to leave unchanged. */
192
+ desiredCount?: number;
193
+ /** Force a new deployment even if the task definition is unchanged. Default: false. */
194
+ forceNewDeployment?: boolean;
195
+ }
196
+
197
+ export interface EcsUpdateServiceOutput {
198
+ /** ARN of the new/updated service deployment. */
199
+ deploymentId: string;
200
+ }
201
+
202
+ /**
203
+ * Roll a new task definition/image out to an ECS service via `UpdateService`.
204
+ * Rollback re-invokes `updateService` with the same input — a best-effort
205
+ * capability-level compensation for the common case (recorded here so the
206
+ * capability is never rollback-silent); a component whose service swap needs
207
+ * a specific prior task definition/count restored (rather than a re-apply of
208
+ * the same input) supplies its own explicit rollback phase instead, as the
209
+ * ALB/ECS pilot does with `rollback-previous`.
210
+ */
211
+ export function createEcsUpdateServiceCapability(
212
+ executor: CloudExecutor = defaultCloudExecutor(),
213
+ ): Capability<EcsUpdateServiceInput, EcsUpdateServiceOutput> {
214
+ return {
215
+ kind: "ecs-update-service",
216
+ async run(_ctx, input) {
217
+ const { deploymentId } = await executor.ecs.updateService({
218
+ cluster: input.cluster,
219
+ service: input.service,
220
+ taskDefinition: input.imageRef,
221
+ desiredCount: input.desiredCount,
222
+ forceNewDeployment: input.forceNewDeployment,
223
+ });
224
+ return { deploymentId };
225
+ },
226
+ async rollback(_ctx, input) {
227
+ await executor.ecs.rollbackService({
228
+ cluster: input.cluster,
229
+ service: input.service,
230
+ taskDefinition: input.imageRef,
231
+ desiredCount: input.desiredCount,
232
+ });
233
+ },
234
+ };
235
+ }
236
+
237
+ /** Default `ecs-update-service` capability, backed by the real `CloudExecutor`. */
238
+ export const ecsUpdateServiceCapability: Capability<EcsUpdateServiceInput, EcsUpdateServiceOutput> =
239
+ createEcsUpdateServiceCapability();
240
+
241
+ // ── lambda-deploy ────────────────────────────────────────────────────────────
242
+
243
+ export interface LambdaDeployInput {
244
+ /** Function name or ARN. */
245
+ functionName: string;
246
+ /** Reference to the packaged code — an image URI (e.g. `"@Publish.uri"` from `publish-image`) for a container-image function. */
247
+ codeRef: string;
248
+ /** Publish a new immutable version after updating code. Default: true. */
249
+ publish?: boolean;
250
+ /** Alias to repoint at the new version (e.g. "live"). */
251
+ alias?: string;
252
+ }
253
+
254
+ export interface LambdaDeployOutput {
255
+ /** Published function version. */
256
+ version: string;
257
+ /** Function ARN (version-qualified if `publish` was true). */
258
+ functionArn: string;
259
+ }
260
+
261
+ /**
262
+ * Update a Lambda function's code to a new container image, wait for the
263
+ * update to apply, and (by default) publish an immutable version and repoint
264
+ * `alias` at it — the same digest-promotion invariant every other apply verb
265
+ * follows (`imageRef`/`codeRef` is a resolved `publish-image` digest, never a
266
+ * rebuild). Rollback restores whatever version `alias` pointed at before this
267
+ * step ran (captured up front), repointing the alias back — mirroring
268
+ * `ecs-update-service`'s best-effort re-apply style rather than a native
269
+ * automatic rollback (Lambda has none for code updates).
270
+ */
271
+ export function createLambdaDeployCapability(
272
+ executor: CloudExecutor = defaultCloudExecutor(),
273
+ ): Capability<LambdaDeployInput, LambdaDeployOutput> {
274
+ const previousVersionByTarget = new Map<string, string | undefined>();
275
+
276
+ return {
277
+ kind: "lambda-deploy",
278
+ async run(_ctx, input) {
279
+ const alias = input.alias ?? "live";
280
+ if (!previousVersionByTarget.has(input.functionName)) {
281
+ previousVersionByTarget.set(
282
+ input.functionName,
283
+ await executor.lambda.getAliasVersion(input.functionName, alias),
284
+ );
285
+ }
286
+
287
+ const { functionArn } = await executor.lambda.updateFunctionCode({
288
+ functionName: input.functionName,
289
+ imageUri: input.codeRef,
290
+ });
291
+ const { status } = await executor.lambda.waitForUpdate(input.functionName);
292
+ if (status !== "Successful") {
293
+ throw new Error(`lambda-deploy "${input.functionName}": code update ended "${status}"`);
294
+ }
295
+
296
+ if (input.publish === false) {
297
+ return { version: "$LATEST", functionArn };
298
+ }
299
+ const published = await executor.lambda.publishVersion({ functionName: input.functionName });
300
+ await executor.lambda.updateAlias({ functionName: input.functionName, alias, version: published.version });
301
+ return { version: published.version, functionArn: published.functionArn };
302
+ },
303
+ async rollback(_ctx, input) {
304
+ const alias = input.alias ?? "live";
305
+ const previousVersion = previousVersionByTarget.get(input.functionName);
306
+ if (!previousVersion) return; // no prior alias version recorded — nothing to restore.
307
+ await executor.lambda.updateAlias({ functionName: input.functionName, alias, version: previousVersion });
308
+ },
309
+ };
310
+ }
311
+
312
+ /** Default `lambda-deploy` capability, backed by the real `CloudExecutor`. */
313
+ export const lambdaDeployCapability: Capability<LambdaDeployInput, LambdaDeployOutput> = createLambdaDeployCapability();
314
+
315
+ // ── s3-sync ──────────────────────────────────────────────────────────────────
316
+
317
+ export interface S3SyncInput {
318
+ /** Local path or archive-relative path to sync from. */
319
+ from: string;
320
+ /** Destination S3 URI (e.g. `s3://bucket/prefix`). */
321
+ to: string;
322
+ /** Delete destination keys not present in the source. Default: false. */
323
+ delete?: boolean;
324
+ }
325
+
326
+ export interface S3SyncOutput {
327
+ /** Number of objects uploaded. */
328
+ uploaded: number;
329
+ /** Number of objects deleted (when `delete: true`). */
330
+ deleted: number;
331
+ }
332
+
333
+ /** Sync a directory of static assets to an S3 bucket via `aws s3 sync` (endpoint-aware through the `CloudExecutor`). */
334
+ export function createS3SyncCapability(executor: CloudExecutor = defaultCloudExecutor()): Capability<S3SyncInput, S3SyncOutput> {
335
+ return {
336
+ kind: "s3-sync",
337
+ rollbackPolicy: "needs-opt-out",
338
+ async run(_ctx, input) {
339
+ return executor.s3.sync({ from: input.from, to: input.to, delete: input.delete });
340
+ },
341
+ };
342
+ }
343
+
344
+ /** Default `s3-sync` capability, backed by the real `CloudExecutor`. */
345
+ export const s3SyncCapability: Capability<S3SyncInput, S3SyncOutput> = createS3SyncCapability();
346
+
347
+ // ── cdn-invalidate ───────────────────────────────────────────────────────────
348
+
349
+ export interface CdnInvalidateInput {
350
+ /** CloudFront distribution id. */
351
+ distributionId: string;
352
+ /** Path patterns to invalidate. Default: `["/*"]`. */
353
+ paths?: string[];
354
+ }
355
+
356
+ export interface CdnInvalidateOutput {
357
+ /** Invalidation batch id, for polling completion. */
358
+ invalidationId: string;
359
+ }
360
+
361
+ /** Invalidate CDN cache paths after a content update (e.g. following `s3-sync`), via `aws cloudfront create-invalidation`. */
362
+ export function createCdnInvalidateCapability(executor: CloudExecutor = defaultCloudExecutor()): Capability<CdnInvalidateInput, CdnInvalidateOutput> {
363
+ return {
364
+ kind: "cdn-invalidate",
365
+ rollbackPolicy: "needs-opt-out",
366
+ async run(_ctx, input) {
367
+ return executor.cloudfront.createInvalidation({
368
+ distributionId: input.distributionId,
369
+ paths: input.paths ?? ["/*"],
370
+ });
371
+ },
372
+ };
373
+ }
374
+
375
+ /** Default `cdn-invalidate` capability, backed by the real `CloudExecutor`. */
376
+ export const cdnInvalidateCapability: Capability<CdnInvalidateInput, CdnInvalidateOutput> = createCdnInvalidateCapability();
377
+
378
+ // ── run-migration ────────────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Where a migration runs. Discriminated on `via` so the composition grammar
382
+ * stays honest about the transport (each is a different AWS surface), while the
383
+ * verb reports a single `{applied, version}` regardless.
384
+ * - `ecs-task`: run the migration image as a one-off ECS/Fargate task (the most
385
+ * common DB-migration transport — a Rails/Django/Flyway `migrate` container).
386
+ * - `lambda`: synchronously invoke a migration function.
387
+ * - `host`: run a migration command on an SSM-managed host (reuses the same
388
+ * transport as `remote-exec`).
389
+ */
390
+ export type MigrationTarget =
391
+ | {
392
+ via: "ecs-task";
393
+ cluster: string;
394
+ taskDefinition: string;
395
+ /** Container to override the command on; defaults to the task def's single container. */
396
+ container?: string;
397
+ /** Command (argv) the migration runs; omit to use the task def's own command. */
398
+ command?: string[];
399
+ launchType?: "FARGATE" | "EC2";
400
+ subnets?: string[];
401
+ securityGroups?: string[];
402
+ assignPublicIp?: boolean;
403
+ }
404
+ | { via: "lambda"; function: string; payload?: string }
405
+ | { via: "host"; host: string; command: string; cwd?: string };
406
+
407
+ export interface RunMigrationInput {
408
+ /** Migration tool/runner identifier (e.g. "flyway", "prisma", "custom"). Informational — recorded for provenance, does not change the transport. */
409
+ tool: string;
410
+ /** Where the migration runs. */
411
+ target: MigrationTarget;
412
+ /** Reference to the migration artifact (e.g. `"@Publish.digest"` for a migration image). Informational. */
413
+ artifactRef?: string;
414
+ }
415
+
416
+ export interface RunMigrationOutput {
417
+ /** True if any migrations were applied (false if already up to date, when the runner reports it). */
418
+ applied: boolean;
419
+ /** Migration version/checksum reached, when the runner reports one (empty string otherwise). */
420
+ version: string;
421
+ }
422
+
423
+ /**
424
+ * A runner that reports its outcome does so as a trailing JSON object
425
+ * `{"applied":bool,"version":"..."}` — on stdout (host) or in the Lambda
426
+ * response payload. Parse the last such line; when the runner emits nothing
427
+ * parseable, fall back to `applied` = "the process exited cleanly" and an empty
428
+ * version (an ECS task exposes only its exit code — no captured stdout — so it
429
+ * always takes this fallback).
430
+ */
431
+ function parseMigrationReport(text: string, appliedFallback: boolean): RunMigrationOutput {
432
+ for (const line of text.split("\n").map((l) => l.trim()).reverse()) {
433
+ if (!line.startsWith("{")) continue;
434
+ try {
435
+ const parsed = JSON.parse(line) as { applied?: boolean; version?: string };
436
+ if (typeof parsed.applied === "boolean" || typeof parsed.version === "string") {
437
+ return { applied: parsed.applied ?? appliedFallback, version: parsed.version ?? "" };
438
+ }
439
+ } catch {
440
+ // not the report line — keep scanning older lines.
441
+ }
442
+ }
443
+ return { applied: appliedFallback, version: "" };
444
+ }
445
+
446
+ /**
447
+ * Run a database/schema migration against a target as a deploy step, dispatching
448
+ * on the target transport (ECS one-off task, Lambda invoke, or SSM host command)
449
+ * through the injectable `CloudExecutor`. A non-clean outcome (non-zero exit, a
450
+ * Lambda `FunctionError`) throws so the phase fails rather than proceeding on an
451
+ * unmigrated schema.
452
+ */
453
+ export function createRunMigrationCapability(executor: CloudExecutor = defaultCloudExecutor()): Capability<RunMigrationInput, RunMigrationOutput> {
454
+ return {
455
+ kind: "run-migration",
456
+ rollbackPolicy: "needs-opt-out",
457
+ async run(_ctx, input) {
458
+ const target = input.target;
459
+ switch (target.via) {
460
+ case "ecs-task": {
461
+ const { taskArn } = await executor.ecs.runTask({
462
+ cluster: target.cluster,
463
+ taskDefinition: target.taskDefinition,
464
+ container: target.container,
465
+ command: target.command,
466
+ launchType: target.launchType,
467
+ subnets: target.subnets,
468
+ securityGroups: target.securityGroups,
469
+ assignPublicIp: target.assignPublicIp,
470
+ });
471
+ const result = await executor.ecs.waitForTask(target.cluster, taskArn);
472
+ if (result.exitCode !== 0) {
473
+ throw new Error(
474
+ `run-migration (ecs-task ${target.cluster}): task ${result.lastStatus} with exit ${result.exitCode ?? "none"}${result.stoppedReason ? ` (${result.stoppedReason})` : ""}`,
475
+ );
476
+ }
477
+ return { applied: true, version: "" };
478
+ }
479
+ case "lambda": {
480
+ const res = await executor.lambda.invoke({ functionName: target.function, payload: target.payload });
481
+ if (res.functionError) {
482
+ throw new Error(`run-migration (lambda ${target.function}): ${res.functionError} — ${res.payload.trim()}`);
483
+ }
484
+ return parseMigrationReport(res.payload, true);
485
+ }
486
+ case "host": {
487
+ const { stdout, exitCode } = await executor.host.exec({ host: target.host, command: target.command, cwd: target.cwd });
488
+ if (exitCode !== 0) {
489
+ throw new Error(`run-migration (host ${target.host}): command exited ${exitCode}`);
490
+ }
491
+ return parseMigrationReport(stdout, true);
492
+ }
493
+ }
494
+ },
495
+ };
496
+ }
497
+
498
+ /** Default `run-migration` capability, backed by the real `CloudExecutor`. */
499
+ export const runMigrationCapability: Capability<RunMigrationInput, RunMigrationOutput> = createRunMigrationCapability();
@@ -0,0 +1,49 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { phase, projectToJson, type Component } from "@intentius/chant/components/component";
3
+ import { publishImage, publishArtifact, publishAsset, cfnDeploy, waitJob } from "./builders";
4
+
5
+ describe("aws step builders (#658)", () => {
6
+ test("a step builder tags its input with the verb kind", () => {
7
+ expect(publishImage({ from: "archive", to: "$env.registry" })).toEqual({
8
+ kind: "publish-image",
9
+ from: "archive",
10
+ to: "$env.registry",
11
+ });
12
+ expect(cfnDeploy({ template: "archive:t.json", imageRef: "@Publish.digest" })).toEqual({
13
+ kind: "cfn-deploy",
14
+ template: "archive:t.json",
15
+ imageRef: "@Publish.digest",
16
+ });
17
+ });
18
+
19
+ test("publishAsset is an alias of publishArtifact (same publish-artifact kind)", () => {
20
+ expect(publishAsset).toBe(publishArtifact);
21
+ expect(publishAsset({ from: "archive", to: "$env.s3" })).toEqual({
22
+ kind: "publish-artifact",
23
+ from: "archive",
24
+ to: "$env.s3",
25
+ });
26
+ });
27
+
28
+ test("a component authored with aws builders projects identically to kind-literals", () => {
29
+ const built: Component = {
30
+ name: "svc",
31
+ dependsOn: [],
32
+ deploy: [
33
+ phase("Publish", [publishImage({ from: "archive", to: "$env.registry" })]),
34
+ phase("Apply", [cfnDeploy({ template: "archive:t.json", imageRef: "@Publish.digest" })]),
35
+ phase("Verify", [waitJob({ runId: "@Apply.runId" })]),
36
+ ],
37
+ };
38
+ const literal: Component = {
39
+ name: "svc",
40
+ dependsOn: [],
41
+ deploy: [
42
+ phase("Publish", [{ kind: "publish-image", from: "archive", to: "$env.registry" }]),
43
+ phase("Apply", [{ kind: "cfn-deploy", template: "archive:t.json", imageRef: "@Publish.digest" }]),
44
+ phase("Verify", [{ kind: "wait-job", runId: "@Apply.runId" }]),
45
+ ],
46
+ };
47
+ expect(projectToJson(built)).toEqual(projectToJson(literal));
48
+ });
49
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Typed step-builders for the aws lexicon's verbs — the same ergonomic sugar
3
+ * core offers for its agnostic verbs (#658), reusing the exported `step`
4
+ * projection from `@intentius/chant/components`. `cfnDeploy({ template })` is
5
+ * exactly `{ kind: "cfn-deploy", template }`, but with per-verb argument
6
+ * checking from each capability's own `Input` type.
7
+ */
8
+
9
+ import { step } from "@intentius/chant/components";
10
+ import type { ExtractConfigBomInput } from "./config-bom";
11
+ import type { PublishImageInput, PublishArtifactInput } from "./publish";
12
+ import type {
13
+ CfnDeployInput,
14
+ EcsUpdateServiceInput,
15
+ LambdaDeployInput,
16
+ S3SyncInput,
17
+ CdnInvalidateInput,
18
+ RunMigrationInput,
19
+ } from "./apply";
20
+ import type { EmrStartJobRunInput, EmrSubmitStepInput } from "./job-submission";
21
+ import type { CodeDeployInput, CopyToHostInput, RemoteExecInput } from "./host-delivery";
22
+ import type { WaitForStackInput, WaitSteadyStateInput, WaitJobInput } from "./wait-aws";
23
+ import type { SnapshotBeforeInput, RollbackPreviousInput } from "./safety";
24
+
25
+ // ── sbom ─────────────────────────────────────────────────────────────────────
26
+ export const extractConfigBom = step<ExtractConfigBomInput>("extract-config-bom");
27
+
28
+ // ── publish ──────────────────────────────────────────────────────────────────
29
+ export const publishImage = step<PublishImageInput>("publish-image");
30
+ export const loadImageOnHost = step<PublishImageInput>("load-image-on-host");
31
+ export const publishArtifact = step<PublishArtifactInput>("publish-artifact");
32
+ /** Alias for {@link publishArtifact} — the docs/epic use both names for the same verb. */
33
+ export const publishAsset = publishArtifact;
34
+
35
+ // ── apply ────────────────────────────────────────────────────────────────────
36
+ export const cfnDeploy = step<CfnDeployInput>("cfn-deploy");
37
+ export const ecsUpdateService = step<EcsUpdateServiceInput>("ecs-update-service");
38
+ export const lambdaDeploy = step<LambdaDeployInput>("lambda-deploy");
39
+ export const s3Sync = step<S3SyncInput>("s3-sync");
40
+ export const cdnInvalidate = step<CdnInvalidateInput>("cdn-invalidate");
41
+ export const runMigration = step<RunMigrationInput>("run-migration");
42
+
43
+ // ── job submission ───────────────────────────────────────────────────────────
44
+ export const emrStartJobRun = step<EmrStartJobRunInput>("emr-start-job-run");
45
+ export const emrSubmitStep = step<EmrSubmitStepInput>("emr-submit-step");
46
+
47
+ // ── host / code delivery ─────────────────────────────────────────────────────
48
+ export const codeDeploy = step<CodeDeployInput>("code-deploy");
49
+ export const copyToHost = step<CopyToHostInput>("copy-to-host");
50
+ export const remoteExec = step<RemoteExecInput>("remote-exec");
51
+
52
+ // ── wait / verify (cloud-specific) ───────────────────────────────────────────
53
+ export const waitForStack = step<WaitForStackInput>("wait-for-stack");
54
+ export const waitSteadyState = step<WaitSteadyStateInput>("wait-steady-state");
55
+ export const waitJob = step<WaitJobInput>("wait-job");
56
+
57
+ // ── safety / rollback ────────────────────────────────────────────────────────
58
+ export const snapshotBefore = step<SnapshotBeforeInput>("snapshot-before");
59
+ export const rollbackPrevious = step<RollbackPreviousInput>("rollback-previous");