@intentius/chant-lexicon-aws 0.13.1 → 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 (86) 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 +68 -40
  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 +893 -737
  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 +905 -659
  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 +905 -659
  74. package/src/generated/index.ts +79 -51
  75. package/src/generated/lexicon-aws.json +893 -737
  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/lsp/completions.test.ts +4 -2
  80. package/src/op/activities/floci.test.ts +67 -0
  81. package/src/op/activities/floci.ts +149 -0
  82. package/src/op/activities/index.ts +16 -0
  83. package/src/ownership.test.ts +18 -0
  84. package/src/ownership.ts +15 -0
  85. package/src/plugin.ts +3 -0
  86. package/src/serializer.ts +2 -1
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Injectable cloud I/O boundary for the AWS-leaf capability implementations
3
+ * (#557, epic #551), living in the aws lexicon so a project's cloud verbs are
4
+ * contributed by its active lexicon rather than baked into core (see
5
+ * docs/components/cloud-boundary). Every AWS-leaf capability here goes through
6
+ * a `CloudExecutor` instead of shelling out directly, so:
7
+ *
8
+ * - production code gets a real executor that shells out to the `aws` and
9
+ * `docker` CLIs (this codebase shells out to native CLIs rather than
10
+ * depending on AWS SDK v3 packages);
11
+ * - tests get a `MockCloudExecutor` (./__tests__/mock-cloud-executor.ts) that
12
+ * records calls and returns canned results — no live AWS, no live docker.
13
+ *
14
+ * The `docker` client is genuinely agnostic and shared: its type + real
15
+ * implementation (`realDocker`) come from core; only the AWS-specific clients
16
+ * (ECR, CloudFormation, ECS, CodeDeploy, Lambda, EMR, SSM host, S3, CloudFront,
17
+ * snapshot) are defined here.
18
+ */
19
+
20
+ import { exec } from "node:child_process";
21
+ import { promisify } from "node:util";
22
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { realDocker, type DockerClient } from "@intentius/chant/components/verbs/cloud-executor";
26
+ import { q } from "@intentius/chant/components/verbs/process-runner";
27
+
28
+ export type { DockerClient };
29
+
30
+ const execFileAsync = promisify(exec);
31
+
32
+ // ── ECR (registry auth/login only — push/pull go through docker) ──────────
33
+
34
+ export interface EcrClient {
35
+ /** Authenticate the local docker client against a registry (`aws ecr get-login-password | docker login`). */
36
+ login(registry: string): Promise<void>;
37
+ }
38
+
39
+ // ── CloudFormation ──────────────────────────────────────────────────────────
40
+
41
+ export interface CfnChange {
42
+ action: "Add" | "Modify" | "Remove" | "Import" | "Dynamic";
43
+ logicalResourceId: string;
44
+ resourceType: string;
45
+ /** True when CloudFormation must replace (destroy + recreate) this resource to apply the change. */
46
+ replacement: boolean;
47
+ /** Resource-specific detail, when the executor's changeset backend provides it (e.g. property/scope). */
48
+ details?: string[];
49
+ }
50
+
51
+ export interface CfnChangeSet {
52
+ changeSetName: string;
53
+ stackName: string;
54
+ status: string;
55
+ /** True the first time this stack is created (no prior stack exists). */
56
+ isCreate: boolean;
57
+ changes: CfnChange[];
58
+ }
59
+
60
+ export interface CfnCreateChangeSetArgs {
61
+ stackName: string;
62
+ templatePath: string;
63
+ parameters?: Record<string, string>;
64
+ }
65
+
66
+ export interface CfnExecuteChangeSetArgs {
67
+ stackName: string;
68
+ changeSetName: string;
69
+ }
70
+
71
+ export interface CfnStackStatus {
72
+ stackStatus: string;
73
+ outputs: Record<string, string>;
74
+ }
75
+
76
+ export interface CloudFormationClient {
77
+ /** Create (but do not execute) a changeset, returning its proposed changes for preview/safety checks. */
78
+ createChangeSet(args: CfnCreateChangeSetArgs): Promise<CfnChangeSet>;
79
+ /** Execute a previously created changeset. */
80
+ executeChangeSet(args: CfnExecuteChangeSetArgs): Promise<void>;
81
+ /** Delete a changeset without executing it (used when a safety policy blocks the apply). */
82
+ deleteChangeSet(args: CfnExecuteChangeSetArgs): Promise<void>;
83
+ /** Poll a stack until it reaches a terminal status (`*_COMPLETE`/`*_FAILED`), returning final status + outputs. */
84
+ waitForStack(stackName: string, opts?: { intervalMs?: number; timeoutMs?: number }): Promise<CfnStackStatus>;
85
+ /** Current stack status + outputs, without waiting. */
86
+ describeStack(stackName: string): Promise<CfnStackStatus>;
87
+ /** Trigger CloudFormation's native rollback-to-last-known-good-state for a stack (saga compensation). */
88
+ rollbackStack(stackName: string): Promise<void>;
89
+ }
90
+
91
+ // ── ECS ──────────────────────────────────────────────────────────────────��─
92
+
93
+ export interface EcsUpdateServiceArgs {
94
+ cluster: string;
95
+ service: string;
96
+ taskDefinition?: string;
97
+ desiredCount?: number;
98
+ forceNewDeployment?: boolean;
99
+ }
100
+
101
+ export interface EcsServiceState {
102
+ runningCount: number;
103
+ desiredCount: number;
104
+ /** True once `runningCount === desiredCount` and no deployments are in flight. */
105
+ stable: boolean;
106
+ }
107
+
108
+ export interface EcsRunTaskArgs {
109
+ cluster: string;
110
+ /** Task definition family (`:revision` optional) to run. */
111
+ taskDefinition: string;
112
+ /** Container to apply the command override to. Defaults to the task def's single container when omitted. */
113
+ container?: string;
114
+ /** Command (argv) override — e.g. the migration runner's invocation. */
115
+ command?: string[];
116
+ /** Launch type. Default: "FARGATE". */
117
+ launchType?: "FARGATE" | "EC2";
118
+ /** awsvpc subnets (required for FARGATE). */
119
+ subnets?: string[];
120
+ /** awsvpc security groups. */
121
+ securityGroups?: string[];
122
+ /** Whether the task gets a public IP (needed for FARGATE tasks in a public subnet pulling images). Default: false. */
123
+ assignPublicIp?: boolean;
124
+ }
125
+
126
+ export interface EcsRunTaskResult {
127
+ /** Terminal task status (`STOPPED`). */
128
+ lastStatus: string;
129
+ /** Exit code of the (first) container, once the task has stopped; undefined if the container never started (e.g. image pull failure). */
130
+ exitCode: number | undefined;
131
+ /** Reason the task stopped, when it did not run to a clean container exit. */
132
+ stoppedReason?: string;
133
+ }
134
+
135
+ export interface EcsClient {
136
+ /** Roll a new task definition/desired count out to a service; returns the new deployment's id. */
137
+ updateService(args: EcsUpdateServiceArgs): Promise<{ deploymentId: string }>;
138
+ /** Current running/desired counts for a service, used by `wait-steady-state`. */
139
+ describeService(cluster: string, service: string): Promise<EcsServiceState>;
140
+ /** Roll a service back to a previously recorded task definition/count (saga compensation). */
141
+ rollbackService(args: EcsUpdateServiceArgs): Promise<void>;
142
+ /** Run a one-off task (e.g. a DB migration) and return its arn for `waitForTask`. */
143
+ runTask(args: EcsRunTaskArgs): Promise<{ taskArn: string }>;
144
+ /** Wait for a one-off task to stop, returning its terminal status and container exit code. */
145
+ waitForTask(cluster: string, taskArn: string): Promise<EcsRunTaskResult>;
146
+ }
147
+
148
+ // ── CodeDeploy ──────────────────────────────────────────────────────────────
149
+
150
+ export interface CodeDeployCreateArgs {
151
+ application: string;
152
+ deploymentGroup: string;
153
+ revision: { type: "s3"; uri: string } | { type: "github"; repository: string; commitId: string };
154
+ strategy?: "in-place" | "blue-green";
155
+ }
156
+
157
+ export interface CodeDeployStatus {
158
+ status: string;
159
+ /** True for a terminal status (`Succeeded`, `Failed`, `Stopped`). */
160
+ terminal: boolean;
161
+ }
162
+
163
+ export interface CodeDeployClient {
164
+ /** Create a deployment of a revision to a deployment group; returns the deployment id. */
165
+ createDeployment(args: CodeDeployCreateArgs): Promise<{ deploymentId: string }>;
166
+ /** Poll a deployment until it reaches a terminal status. */
167
+ waitForDeployment(deploymentId: string, opts?: { intervalMs?: number; timeoutMs?: number }): Promise<CodeDeployStatus>;
168
+ /** Stop an in-flight deployment and roll the deployment group back to the last known-good revision (native auto-rollback, invoked as saga compensation). */
169
+ stopAndRollback(deploymentId: string): Promise<void>;
170
+ }
171
+
172
+ // ── host (registry-less image delivery — #564's `load-image-on-host`) ──────
173
+
174
+ export interface HostCopyFileArgs {
175
+ /** Target host (SSM instance id, hostname, or host group — same identifier space as `copy-to-host`/`remote-exec`). */
176
+ host: string;
177
+ /** Source path of the file to copy (typically an archive-relative image tarball path). */
178
+ from: string;
179
+ /** Destination path on the host. */
180
+ to: string;
181
+ }
182
+
183
+ export interface HostDockerLoadArgs {
184
+ /** Target host the tarball was copied to. */
185
+ host: string;
186
+ /** Path of the tarball on the host (matches `HostCopyFileArgs.to`). */
187
+ path: string;
188
+ }
189
+
190
+ export interface HostClient {
191
+ /** Copy a file (an image tarball from the build archive) onto a host, via SSM Run Command / SCP-over-SSH depending on config — the same transport `copy-to-host` documents. */
192
+ copyFile(args: HostCopyFileArgs): Promise<void>;
193
+ /** Run `docker load` on the host against a previously copied tarball; returns the digest `docker load` reports, straight into the host's local Docker store (no registry involved). */
194
+ dockerLoad(args: HostDockerLoadArgs): Promise<{ digest: string }>;
195
+ /** Run a shell command on a host via SSM Run Command, waiting for it to finish; returns captured stdout and exit code (rejects on a non-zero SSM invocation). */
196
+ exec(args: { host: string; command: string; cwd?: string }): Promise<{ stdout: string; exitCode: number }>;
197
+ }
198
+
199
+ // ── Lambda (#558's one new capability: lambda-deploy) ───────────────────────
200
+
201
+ export interface LambdaUpdateCodeArgs {
202
+ functionName: string;
203
+ /** Container image URI (registry/repo@sha256:...) for an image-package Lambda. */
204
+ imageUri: string;
205
+ }
206
+
207
+ export interface LambdaPublishVersionArgs {
208
+ functionName: string;
209
+ }
210
+
211
+ export interface LambdaUpdateAliasArgs {
212
+ functionName: string;
213
+ alias: string;
214
+ version: string;
215
+ }
216
+
217
+ export interface LambdaClient {
218
+ /** Point the function at a new container image; returns the function ARN (unqualified). */
219
+ updateFunctionCode(args: LambdaUpdateCodeArgs): Promise<{ functionArn: string }>;
220
+ /** Wait for the function's last update to finish applying (`Successful`/`Failed`). */
221
+ waitForUpdate(functionName: string): Promise<{ status: string }>;
222
+ /** Publish an immutable numbered version from the function's current `$LATEST`. */
223
+ publishVersion(args: LambdaPublishVersionArgs): Promise<{ version: string; functionArn: string }>;
224
+ /** Repoint a named alias at a published version (e.g. "live" -> "42"). */
225
+ updateAlias(args: LambdaUpdateAliasArgs): Promise<{ aliasArn: string }>;
226
+ /** Current published version an alias points at, so rollback can restore it. */
227
+ getAliasVersion(functionName: string, alias: string): Promise<string | undefined>;
228
+ /** Synchronously invoke a function (e.g. a migration runner) with an optional JSON payload; returns the status code, response payload text, and any function error. */
229
+ invoke(args: { functionName: string; payload?: string }): Promise<{ statusCode: number; payload: string; functionError?: string }>;
230
+ }
231
+
232
+ // ── EMR (job submission — #561's one new client) ─────────────────────────────
233
+
234
+ export interface EmrStartJobRunArgs {
235
+ /** EMR Serverless application id, or EMR-on-EC2 cluster id. */
236
+ clusterOrApplicationId: string;
237
+ /** Entry point artifact reference (resolved by the graph before this executor is called, e.g. an S3 URI). */
238
+ jar: string;
239
+ args?: string[];
240
+ executionRoleArn?: string;
241
+ }
242
+
243
+ export interface EmrJobRunStatus {
244
+ /** Terminal job state (`COMPLETED`, `FAILED`, `CANCELLED`), or an in-flight state (`RUNNING`, `PENDING`) while polling. */
245
+ state: string;
246
+ }
247
+
248
+ export interface EmrAddStepArgs {
249
+ /** EMR-on-EC2 cluster id to submit the step to. */
250
+ clusterId: string;
251
+ /** Step name (shown in the EMR console). */
252
+ name: string;
253
+ /** Jar the step runs — an S3 jar, or `command-runner.jar` with a `spark-submit …` arg list. */
254
+ jar: string;
255
+ args?: string[];
256
+ /** What EMR does if the step fails. Default: "CONTINUE". */
257
+ actionOnFailure?: "CONTINUE" | "CANCEL_AND_WAIT" | "TERMINATE_CLUSTER";
258
+ }
259
+
260
+ export interface EmrClient {
261
+ /** Start a job run (EMR Serverless application or EMR-on-EC2 cluster) against a published artifact; returns the run id for polling. */
262
+ startJobRun(args: EmrStartJobRunArgs): Promise<{ runId: string }>;
263
+ /** Submit a step to a long-running EMR-on-EC2 cluster; returns the step id for polling. */
264
+ addStep(args: EmrAddStepArgs): Promise<{ stepId: string }>;
265
+ /** Poll a job run until it reaches a terminal state (`COMPLETED`/`FAILED`/`CANCELLED`). */
266
+ waitForJobRun(runId: string, opts?: { intervalMs?: number; timeoutMs?: number }): Promise<EmrJobRunStatus>;
267
+ /** Current state of a job run, without waiting. */
268
+ describeJobRun(runId: string): Promise<EmrJobRunStatus>;
269
+ /** Cancel an in-flight job run (saga compensation). */
270
+ cancelJobRun(runId: string): Promise<void>;
271
+ }
272
+
273
+ // ── The aggregate executor ───────────────────────────────────────────────────
274
+
275
+ /**
276
+ * The full injectable cloud I/O surface the AWS-leaf capabilities depend on.
277
+ * A capability module never imports `node:child_process`/an AWS SDK/`net`
278
+ * directly — it takes a `CloudExecutor` (defaulted to `realCloudExecutor()`
279
+ * at module scope, overridable via each capability family's
280
+ * `create*Capability(executor)` factory) so tests can swap in a full mock.
281
+ */
282
+ // ── S3 / CloudFront (deploy-time asset sync + CDN invalidation) ───────────────
283
+
284
+ export interface S3SyncArgs {
285
+ from: string;
286
+ to: string;
287
+ /** Delete destination keys not present in the source. */
288
+ delete?: boolean;
289
+ }
290
+
291
+ export interface S3Client {
292
+ /** `aws s3 sync from to [--delete]`; returns how many objects were uploaded/deleted. */
293
+ sync(args: S3SyncArgs): Promise<{ uploaded: number; deleted: number }>;
294
+ /** `aws s3 cp from to` — upload a single local file to an S3 URI. */
295
+ cp(args: { from: string; to: string }): Promise<void>;
296
+ }
297
+
298
+ export interface CloudFrontInvalidateArgs {
299
+ distributionId: string;
300
+ paths: string[];
301
+ }
302
+
303
+ export interface CloudFrontClient {
304
+ /** `aws cloudfront create-invalidation`; returns the invalidation batch id. */
305
+ createInvalidation(args: CloudFrontInvalidateArgs): Promise<{ invalidationId: string }>;
306
+ }
307
+
308
+ // ── Snapshot (safety family: on-demand backup before a sticky apply) ──────────
309
+
310
+ export type SnapshotResourceKind = "dynamodb-table" | "rds-instance" | "opensearch-domain" | "ebs-volume";
311
+
312
+ export interface SnapshotClient {
313
+ /** Take an on-demand snapshot/backup of a resource, dispatched by kind; returns the backup/snapshot identifier `rollback-previous` restores from. */
314
+ create(args: { resource: string; resourceKind: SnapshotResourceKind }): Promise<{ snapshotId: string }>;
315
+ /** Restore a resource from a prior snapshot/backup, dispatched by the snapshot-id shape (DynamoDB/RDS ARN); waits for the restore to become available. */
316
+ restore(args: { resource: string; snapshotId: string }): Promise<void>;
317
+ }
318
+
319
+ export interface CloudExecutor {
320
+ docker: DockerClient;
321
+ ecr: EcrClient;
322
+ cloudformation: CloudFormationClient;
323
+ ecs: EcsClient;
324
+ codeDeploy: CodeDeployClient;
325
+ lambda: LambdaClient;
326
+ emr: EmrClient;
327
+ host: HostClient;
328
+ s3: S3Client;
329
+ cloudfront: CloudFrontClient;
330
+ snapshot: SnapshotClient;
331
+ }
332
+
333
+ // ── Real executor — shells out to `docker`/`aws`; used outside tests ────────
334
+
335
+ /**
336
+ * Inject `--endpoint-url` into an `aws …` command when an endpoint is set, so the
337
+ * same component can target a local AWS emulator (Floci, LocalStack, …) or any
338
+ * custom endpoint without a wrapper. We add the flag ourselves rather than rely
339
+ * on the CLI reading `AWS_ENDPOINT_URL` — older `aws` v2 releases (<2.13) don't.
340
+ * Non-`aws` commands (docker, …) pass through untouched.
341
+ */
342
+ export function applyAwsEndpoint(command: string, endpoint: string | undefined): string {
343
+ if (!endpoint || !/^aws\s/.test(command)) return command;
344
+ return command.replace(/^aws\s/, `aws --endpoint-url ${q(endpoint)} `);
345
+ }
346
+
347
+ function run(command: string): Promise<{ stdout: string; stderr: string }> {
348
+ return execFileAsync(applyAwsEndpoint(command, process.env.AWS_ENDPOINT_URL), {
349
+ maxBuffer: 64 * 1024 * 1024,
350
+ });
351
+ }
352
+
353
+ const realEcr: EcrClient = {
354
+ async login(registry) {
355
+ await run(`aws ecr get-login-password | docker login --username AWS --password-stdin ${q(registry)}`);
356
+ },
357
+ };
358
+
359
+ /** CREATE for a stack that doesn't exist (or is stuck in REVIEW_IN_PROGRESS from a prior unexecuted CREATE), else UPDATE. */
360
+ async function cfnChangeSetType(stackName: string): Promise<"CREATE" | "UPDATE"> {
361
+ try {
362
+ const { stdout } = await run(`aws cloudformation describe-stacks --stack-name ${q(stackName)}`);
363
+ const status = (JSON.parse(stdout) as { Stacks?: Array<{ StackStatus?: string }> }).Stacks?.[0]?.StackStatus;
364
+ return status === "REVIEW_IN_PROGRESS" ? "CREATE" : "UPDATE";
365
+ } catch {
366
+ return "CREATE"; // describe-stacks errors when the stack doesn't exist
367
+ }
368
+ }
369
+
370
+ const realCloudFormation: CloudFormationClient = {
371
+ async createChangeSet(args) {
372
+ const changeSetName = `cs-${Date.now()}`;
373
+ const params = Object.entries(args.parameters ?? {})
374
+ .map(([k, v]) => `ParameterKey=${k},ParameterValue=${v}`)
375
+ .join(" ");
376
+ const paramFlag = params ? ` --parameters ${params}` : "";
377
+ // A change set defaults to type UPDATE, which fails on a stack that doesn't
378
+ // exist yet ("Stack ... does not exist"). Pick CREATE for a new stack, and
379
+ // for one still in REVIEW_IN_PROGRESS (a prior CREATE change set never
380
+ // executed), mirroring what `aws cloudformation deploy` does under the hood.
381
+ const changeSetType = await cfnChangeSetType(args.stackName);
382
+ await run(
383
+ `aws cloudformation create-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)} ` +
384
+ `--change-set-type ${changeSetType} ` +
385
+ `--template-body file://${args.templatePath} --capabilities CAPABILITY_NAMED_IAM${paramFlag}`,
386
+ );
387
+ await run(
388
+ `aws cloudformation wait change-set-create-complete --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)}`,
389
+ ).catch(() => undefined); // "no changes" also lands here; describe below reports the real status.
390
+ const { stdout } = await run(
391
+ `aws cloudformation describe-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(changeSetName)}`,
392
+ );
393
+ const described = JSON.parse(stdout) as {
394
+ Status: string;
395
+ Changes?: Array<{
396
+ ResourceChange: {
397
+ Action: string;
398
+ LogicalResourceId: string;
399
+ ResourceType: string;
400
+ Replacement?: string;
401
+ Details?: Array<{ Target?: { Name?: string } }>;
402
+ };
403
+ }>;
404
+ };
405
+ return {
406
+ changeSetName,
407
+ stackName: args.stackName,
408
+ status: described.Status,
409
+ isCreate: described.Status === "CREATE_COMPLETE" && !described.Changes?.length,
410
+ changes: (described.Changes ?? []).map((c) => ({
411
+ action: c.ResourceChange.Action as CfnChange["action"],
412
+ logicalResourceId: c.ResourceChange.LogicalResourceId,
413
+ resourceType: c.ResourceChange.ResourceType,
414
+ replacement: c.ResourceChange.Replacement === "True",
415
+ details: c.ResourceChange.Details?.map((d) => d.Target?.Name).filter((x): x is string => !!x),
416
+ })),
417
+ };
418
+ },
419
+ async executeChangeSet(args) {
420
+ await run(
421
+ `aws cloudformation execute-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(args.changeSetName)}`,
422
+ );
423
+ },
424
+ async deleteChangeSet(args) {
425
+ await run(
426
+ `aws cloudformation delete-change-set --stack-name ${q(args.stackName)} --change-set-name ${q(args.changeSetName)}`,
427
+ );
428
+ },
429
+ async describeStack(stackName) {
430
+ const { stdout } = await run(`aws cloudformation describe-stacks --stack-name ${q(stackName)}`);
431
+ const described = JSON.parse(stdout) as {
432
+ Stacks: Array<{ StackStatus: string; Outputs?: Array<{ OutputKey: string; OutputValue: string }> }>;
433
+ };
434
+ const stack = described.Stacks[0];
435
+ const outputs: Record<string, string> = {};
436
+ for (const o of stack?.Outputs ?? []) outputs[o.OutputKey] = o.OutputValue;
437
+ return { stackStatus: stack?.StackStatus ?? "UNKNOWN", outputs };
438
+ },
439
+ async waitForStack(stackName, opts) {
440
+ const intervalMs = opts?.intervalMs ?? 10_000;
441
+ const deadline = opts?.timeoutMs ? Date.now() + opts.timeoutMs : undefined;
442
+ while (true) {
443
+ const status = await realCloudFormation.describeStack(stackName);
444
+ if (!status.stackStatus.endsWith("_IN_PROGRESS")) return status;
445
+ if (deadline && Date.now() > deadline) throw new Error(`waitForStack "${stackName}" timed out`);
446
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
447
+ }
448
+ },
449
+ async rollbackStack(stackName) {
450
+ await run(`aws cloudformation rollback-stack --stack-name ${q(stackName)}`);
451
+ },
452
+ };
453
+
454
+ const realEcs: EcsClient = {
455
+ async updateService(args) {
456
+ const parts = [`aws ecs update-service`, `--cluster ${q(args.cluster)}`, `--service ${q(args.service)}`];
457
+ if (args.taskDefinition) parts.push(`--task-definition ${q(args.taskDefinition)}`);
458
+ if (args.desiredCount !== undefined) parts.push(`--desired-count ${args.desiredCount}`);
459
+ if (args.forceNewDeployment) parts.push(`--force-new-deployment`);
460
+ const { stdout } = await run(parts.join(" "));
461
+ const described = JSON.parse(stdout) as { service: { deployments: Array<{ id: string }> } };
462
+ return { deploymentId: described.service.deployments[0]?.id ?? "" };
463
+ },
464
+ async describeService(cluster, service) {
465
+ const { stdout } = await run(
466
+ `aws ecs describe-services --cluster ${q(cluster)} --services ${q(service)}`,
467
+ );
468
+ const described = JSON.parse(stdout) as {
469
+ services: Array<{ runningCount: number; desiredCount: number; deployments: unknown[] }>;
470
+ };
471
+ const svc = described.services[0];
472
+ const running = svc?.runningCount ?? 0;
473
+ const desired = svc?.desiredCount ?? 0;
474
+ return { runningCount: running, desiredCount: desired, stable: running === desired && (svc?.deployments.length ?? 0) <= 1 };
475
+ },
476
+ async rollbackService(args) {
477
+ await realEcs.updateService(args);
478
+ },
479
+ async runTask(args) {
480
+ const parts = [
481
+ `aws ecs run-task`,
482
+ `--cluster ${q(args.cluster)}`,
483
+ `--task-definition ${q(args.taskDefinition)}`,
484
+ `--launch-type ${args.launchType ?? "FARGATE"}`,
485
+ ];
486
+ if (args.subnets?.length) {
487
+ const net = {
488
+ awsvpcConfiguration: {
489
+ subnets: args.subnets,
490
+ securityGroups: args.securityGroups ?? [],
491
+ assignPublicIp: args.assignPublicIp ? "ENABLED" : "DISABLED",
492
+ },
493
+ };
494
+ parts.push(`--network-configuration ${q(JSON.stringify(net))}`);
495
+ }
496
+ if (args.command?.length) {
497
+ const overrides = { containerOverrides: [{ name: args.container ?? "", command: args.command }] };
498
+ parts.push(`--overrides ${q(JSON.stringify(overrides))}`);
499
+ }
500
+ const { stdout } = await run(parts.join(" "));
501
+ const described = JSON.parse(stdout) as { tasks: Array<{ taskArn: string }>; failures?: Array<{ reason: string }> };
502
+ const taskArn = described.tasks[0]?.taskArn;
503
+ if (!taskArn) throw new Error(`ecs run-task on "${args.cluster}" started no task: ${JSON.stringify(described.failures ?? [])}`);
504
+ return { taskArn };
505
+ },
506
+ async waitForTask(cluster, taskArn) {
507
+ await run(`aws ecs wait tasks-stopped --cluster ${q(cluster)} --tasks ${q(taskArn)}`);
508
+ const { stdout } = await run(`aws ecs describe-tasks --cluster ${q(cluster)} --tasks ${q(taskArn)}`);
509
+ const described = JSON.parse(stdout) as {
510
+ tasks: Array<{ lastStatus: string; stoppedReason?: string; containers: Array<{ exitCode?: number }> }>;
511
+ };
512
+ const task = described.tasks[0];
513
+ return {
514
+ lastStatus: task?.lastStatus ?? "UNKNOWN",
515
+ exitCode: task?.containers?.[0]?.exitCode,
516
+ ...(task?.stoppedReason ? { stoppedReason: task.stoppedReason } : {}),
517
+ };
518
+ },
519
+ };
520
+
521
+ const realCodeDeploy: CodeDeployClient = {
522
+ async createDeployment(args) {
523
+ const revisionFlag =
524
+ args.revision.type === "s3"
525
+ ? `--s3-location bundleType=zip,bucket=${q(args.revision.uri.replace("s3://", "").split("/")[0])},key=${q(
526
+ args.revision.uri.replace("s3://", "").split("/").slice(1).join("/"),
527
+ )}`
528
+ : `--github-location repository=${q(args.revision.repository)},commitId=${q(args.revision.commitId)}`;
529
+ const { stdout } = await run(
530
+ `aws deploy create-deployment --application-name ${q(args.application)} ` +
531
+ `--deployment-group-name ${q(args.deploymentGroup)} --revision revisionType=${
532
+ args.revision.type === "s3" ? "S3" : "GitHub"
533
+ },${revisionFlag}`,
534
+ );
535
+ const described = JSON.parse(stdout) as { deploymentId: string };
536
+ return { deploymentId: described.deploymentId };
537
+ },
538
+ async waitForDeployment(deploymentId, opts) {
539
+ const intervalMs = opts?.intervalMs ?? 10_000;
540
+ const deadline = opts?.timeoutMs ? Date.now() + opts.timeoutMs : undefined;
541
+ while (true) {
542
+ const { stdout } = await run(`aws deploy get-deployment --deployment-id ${q(deploymentId)}`);
543
+ const described = JSON.parse(stdout) as { deploymentInfo: { status: string } };
544
+ const status = described.deploymentInfo.status;
545
+ if (["Succeeded", "Failed", "Stopped"].includes(status)) return { status, terminal: true };
546
+ if (deadline && Date.now() > deadline) throw new Error(`waitForDeployment "${deploymentId}" timed out`);
547
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
548
+ }
549
+ },
550
+ async stopAndRollback(deploymentId) {
551
+ // CodeDeploy's native auto-rollback-on-failure already reverts to the last
552
+ // known-good revision when a deployment fails; explicitly stopping with
553
+ // rollback covers the case where the caller is compensating a deployment
554
+ // that is still in flight.
555
+ await run(`aws deploy stop-deployment --deployment-id ${q(deploymentId)} --auto-rollback-enabled`);
556
+ },
557
+ };
558
+
559
+ const realHost: HostClient = {
560
+ async copyFile(args) {
561
+ // SSM Run Command, per the same transport `copy-to-host` documents: stage
562
+ // the tarball via an S3 hop (`aws s3 cp` then a remote `aws s3 cp` inside
563
+ // the command document) is the common pattern for files too large for an
564
+ // inline SSM document; kept to a single conceptual step here since the
565
+ // executor boundary is what tests substitute, not this shell recipe.
566
+ await run(
567
+ `aws ssm send-command --instance-ids ${q(args.host)} --document-name AWS-RunShellScript ` +
568
+ `--parameters ${q(JSON.stringify({ commands: [`aws s3 cp ${args.from} ${args.to}`] }))}`,
569
+ );
570
+ },
571
+ async dockerLoad(args) {
572
+ const { stdout } = await run(
573
+ `aws ssm send-command --instance-ids ${q(args.host)} --document-name AWS-RunShellScript ` +
574
+ `--parameters ${q(JSON.stringify({ commands: [`docker load -i ${args.path}`] }))}`,
575
+ );
576
+ const match = stdout.match(/[Ll]oaded image(?: ID)?:\s*(\S+)/);
577
+ return { digest: match?.[1] ?? stdout.trim() };
578
+ },
579
+ async exec(args) {
580
+ const command = args.cwd ? `cd ${args.cwd} && ${args.command}` : args.command;
581
+ const { stdout: idOut } = await run(
582
+ `aws ssm send-command --instance-ids ${q(args.host)} --document-name AWS-RunShellScript ` +
583
+ `--parameters ${q(JSON.stringify({ commands: [command] }))} --query 'Command.CommandId' --output text`,
584
+ );
585
+ const commandId = idOut.trim();
586
+ for (;;) {
587
+ await sleep(2000);
588
+ const { stdout } = await run(
589
+ `aws ssm get-command-invocation --command-id ${q(commandId)} --instance-id ${q(args.host)} --output json`,
590
+ ).catch(() => ({ stdout: "" }));
591
+ if (!stdout) continue; // invocation not registered yet
592
+ const inv = JSON.parse(stdout) as { Status: string; ResponseCode?: number; StandardOutputContent?: string; StandardErrorContent?: string };
593
+ if (["Success", "Failed", "Cancelled", "TimedOut"].includes(inv.Status)) {
594
+ if (inv.Status !== "Success") {
595
+ throw new Error(`remote-exec on ${args.host} ${inv.Status}: ${inv.StandardErrorContent?.trim() ?? ""}`);
596
+ }
597
+ return { stdout: inv.StandardOutputContent ?? "", exitCode: inv.ResponseCode ?? 0 };
598
+ }
599
+ }
600
+ },
601
+ };
602
+
603
+ const realLambda: LambdaClient = {
604
+ async updateFunctionCode(args) {
605
+ const { stdout } = await run(
606
+ `aws lambda update-function-code --function-name ${q(args.functionName)} --image-uri ${q(args.imageUri)}`,
607
+ );
608
+ const described = JSON.parse(stdout) as { FunctionArn: string };
609
+ return { functionArn: described.FunctionArn };
610
+ },
611
+ async waitForUpdate(functionName) {
612
+ await run(`aws lambda wait function-updated-v2 --function-name ${q(functionName)}`);
613
+ const { stdout } = await run(`aws lambda get-function --function-name ${q(functionName)}`);
614
+ const described = JSON.parse(stdout) as { Configuration: { LastUpdateStatus: string } };
615
+ return { status: described.Configuration.LastUpdateStatus };
616
+ },
617
+ async publishVersion(args) {
618
+ const { stdout } = await run(`aws lambda publish-version --function-name ${q(args.functionName)}`);
619
+ const described = JSON.parse(stdout) as { Version: string; FunctionArn: string };
620
+ return { version: described.Version, functionArn: described.FunctionArn };
621
+ },
622
+ async updateAlias(args) {
623
+ const { stdout } = await run(
624
+ `aws lambda update-alias --function-name ${q(args.functionName)} --name ${q(args.alias)} --function-version ${q(args.version)}`,
625
+ );
626
+ const described = JSON.parse(stdout) as { AliasArn: string };
627
+ return { aliasArn: described.AliasArn };
628
+ },
629
+ async getAliasVersion(functionName, alias) {
630
+ try {
631
+ const { stdout } = await run(`aws lambda get-alias --function-name ${q(functionName)} --name ${q(alias)}`);
632
+ const described = JSON.parse(stdout) as { FunctionVersion: string };
633
+ return described.FunctionVersion;
634
+ } catch {
635
+ return undefined; // alias does not exist yet (first deploy) — nothing to restore on rollback.
636
+ }
637
+ },
638
+ async invoke(args) {
639
+ // `aws lambda invoke` writes the response payload to a file arg and its
640
+ // metadata (StatusCode/FunctionError) to stdout, so use a throwaway temp
641
+ // dir for both the request payload (fileb:// — raw bytes, version-agnostic)
642
+ // and the response, and clean it up regardless of outcome.
643
+ const dir = mkdtempSync(join(tmpdir(), "chant-lambda-"));
644
+ const outFile = join(dir, "response.json");
645
+ try {
646
+ let payloadFlag = "";
647
+ if (args.payload !== undefined) {
648
+ const payloadFile = join(dir, "payload.json");
649
+ writeFileSync(payloadFile, args.payload);
650
+ payloadFlag = ` --payload fileb://${payloadFile}`;
651
+ }
652
+ const { stdout } = await run(
653
+ `aws lambda invoke --function-name ${q(args.functionName)}${payloadFlag} ${q(outFile)}`,
654
+ );
655
+ const meta = JSON.parse(stdout) as { StatusCode: number; FunctionError?: string };
656
+ return {
657
+ statusCode: meta.StatusCode,
658
+ payload: readFileSync(outFile, "utf8"),
659
+ ...(meta.FunctionError ? { functionError: meta.FunctionError } : {}),
660
+ };
661
+ } finally {
662
+ rmSync(dir, { recursive: true, force: true });
663
+ }
664
+ },
665
+ };
666
+
667
+ const realEmr: EmrClient = {
668
+ async startJobRun(args) {
669
+ const parts = [
670
+ `aws emr-serverless start-job-run`,
671
+ `--application-id ${q(args.clusterOrApplicationId)}`,
672
+ `--execution-role-arn ${q(args.executionRoleArn ?? "")}`,
673
+ `--job-driver ${q(JSON.stringify({ sparkSubmit: { entryPoint: args.jar, entryPointArguments: args.args ?? [] } }))}`,
674
+ ];
675
+ const { stdout } = await run(parts.join(" "));
676
+ const described = JSON.parse(stdout) as { jobRunId: string };
677
+ return { runId: described.jobRunId };
678
+ },
679
+ async addStep(args) {
680
+ const step = {
681
+ Name: args.name,
682
+ ActionOnFailure: args.actionOnFailure ?? "CONTINUE",
683
+ HadoopJarStep: { Jar: args.jar, Args: args.args ?? [] },
684
+ };
685
+ const { stdout } = await run(
686
+ `aws emr add-steps --cluster-id ${q(args.clusterId)} --steps ${q(JSON.stringify([step]))}`,
687
+ );
688
+ const described = JSON.parse(stdout) as { StepIds: string[] };
689
+ const stepId = described.StepIds?.[0];
690
+ if (!stepId) throw new Error(`emr add-steps on "${args.clusterId}" returned no step id`);
691
+ return { stepId };
692
+ },
693
+ async describeJobRun(runId) {
694
+ const { stdout } = await run(`aws emr-serverless get-job-run --job-run-id ${q(runId)}`);
695
+ const described = JSON.parse(stdout) as { jobRun: { state: string } };
696
+ return { state: described.jobRun.state };
697
+ },
698
+ async waitForJobRun(runId, opts) {
699
+ const intervalMs = opts?.intervalMs ?? 10_000;
700
+ const deadline = opts?.timeoutMs ? Date.now() + opts.timeoutMs : undefined;
701
+ const terminal = new Set(["COMPLETED", "FAILED", "CANCELLED"]);
702
+ while (true) {
703
+ const status = await realEmr.describeJobRun(runId);
704
+ if (terminal.has(status.state)) return status;
705
+ if (deadline && Date.now() > deadline) throw new Error(`waitForJobRun "${runId}" timed out`);
706
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
707
+ }
708
+ },
709
+ async cancelJobRun(runId) {
710
+ await run(`aws emr-serverless cancel-job-run --job-run-id ${q(runId)}`);
711
+ },
712
+ };
713
+
714
+
715
+ const realS3: S3Client = {
716
+ async sync(args) {
717
+ const flags = args.delete ? " --delete" : "";
718
+ const { stdout } = await run(`aws s3 sync ${q(args.from)} ${q(args.to)}${flags} --no-progress`);
719
+ // `aws s3 sync` prints one `upload: …` / `delete: …` line per object touched.
720
+ const count = (re: RegExp) => stdout.split("\n").filter((l) => re.test(l)).length;
721
+ return { uploaded: count(/^upload:/), deleted: count(/^delete:/) };
722
+ },
723
+ async cp(args) {
724
+ await run(`aws s3 cp ${q(args.from)} ${q(args.to)} --no-progress`);
725
+ },
726
+ };
727
+
728
+ const realCloudFront: CloudFrontClient = {
729
+ async createInvalidation(args) {
730
+ const paths = args.paths.map(q).join(" ");
731
+ const { stdout } = await run(
732
+ `aws cloudfront create-invalidation --distribution-id ${q(args.distributionId)} --paths ${paths}`,
733
+ );
734
+ const id = (JSON.parse(stdout) as { Invalidation?: { Id?: string } }).Invalidation?.Id;
735
+ return { invalidationId: id ?? "" };
736
+ },
737
+ };
738
+
739
+ const realSnapshot: SnapshotClient = {
740
+ async create({ resource, resourceKind }) {
741
+ const id = `${resource}-${Date.now()}`;
742
+ switch (resourceKind) {
743
+ case "dynamodb-table": {
744
+ const { stdout } = await run(
745
+ `aws dynamodb create-backup --table-name ${q(resource)} --backup-name ${q(id)} --query 'BackupDetails.BackupArn' --output text`,
746
+ );
747
+ return { snapshotId: stdout.trim() };
748
+ }
749
+ case "rds-instance": {
750
+ const { stdout } = await run(
751
+ `aws rds create-db-snapshot --db-instance-identifier ${q(resource)} --db-snapshot-identifier ${q(id)} --query 'DBSnapshot.DBSnapshotArn' --output text`,
752
+ );
753
+ return { snapshotId: stdout.trim() };
754
+ }
755
+ case "ebs-volume": {
756
+ const { stdout } = await run(
757
+ `aws ec2 create-snapshot --volume-id ${q(resource)} --description ${q(`chant snapshot-before ${id}`)} --query 'SnapshotId' --output text`,
758
+ );
759
+ return { snapshotId: stdout.trim() };
760
+ }
761
+ case "opensearch-domain":
762
+ // OpenSearch manual snapshots require a pre-registered S3 repository + a
763
+ // signed _snapshot REST call, not a one-shot CLI verb — out of scope here.
764
+ throw new Error(
765
+ `snapshot-before: opensearch-domain needs a registered S3 snapshot repository; not yet supported (resource "${resource}")`,
766
+ );
767
+ }
768
+ },
769
+ async restore({ resource, snapshotId }) {
770
+ // Dispatch by the snapshot-id shape produced by `create` above.
771
+ if (snapshotId.includes(":dynamodb:") || snapshotId.includes("/backup/")) {
772
+ await run(`aws dynamodb restore-table-from-backup --target-table-name ${q(resource)} --backup-arn ${q(snapshotId)}`);
773
+ await run(`aws dynamodb wait table-exists --table-name ${q(resource)}`);
774
+ return;
775
+ }
776
+ if (snapshotId.includes(":rds:") || snapshotId.startsWith("rds:")) {
777
+ await run(`aws rds restore-db-instance-from-db-snapshot --db-instance-identifier ${q(resource)} --db-snapshot-identifier ${q(snapshotId)}`);
778
+ await run(`aws rds wait db-instance-available --db-instance-identifier ${q(resource)}`);
779
+ return;
780
+ }
781
+ throw new Error(`rollback-previous: cannot infer the restore mechanism from snapshot id "${snapshotId}" (expected a DynamoDB or RDS backup ARN)`);
782
+ },
783
+ };
784
+
785
+ /** Build a `CloudExecutor` that shells out to real `docker`/`aws` CLIs and probes real bolt ports. Never used in tests. */
786
+ export function realCloudExecutor(): CloudExecutor {
787
+ return {
788
+ docker: realDocker,
789
+ ecr: realEcr,
790
+ cloudformation: realCloudFormation,
791
+ ecs: realEcs,
792
+ codeDeploy: realCodeDeploy,
793
+ lambda: realLambda,
794
+ emr: realEmr,
795
+ host: realHost,
796
+ s3: realS3,
797
+ cloudfront: realCloudFront,
798
+ snapshot: realSnapshot,
799
+ };
800
+ }
801
+
802
+ /** Lazily-constructed process-wide default so importing a capability module never shells out at import time. */
803
+ let defaultExecutor: CloudExecutor | undefined;
804
+
805
+ /** The default `CloudExecutor` each capability factory falls back to when none is supplied. */
806
+ export function defaultCloudExecutor(): CloudExecutor {
807
+ if (!defaultExecutor) defaultExecutor = realCloudExecutor();
808
+ return defaultExecutor;
809
+ }
810
+
811
+ /** Sleep for `ms`. Shared by every polling capability (`wait-*`) between attempts. */
812
+ export function sleep(ms: number): Promise<void> {
813
+ return new Promise((resolve) => setTimeout(resolve, ms));
814
+ }