@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,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");
|