@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.
- 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 +68 -40
- 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 +893 -737
- 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 +905 -659
- 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 +905 -659
- package/src/generated/index.ts +79 -51
- package/src/generated/lexicon-aws.json +893 -737
- package/src/import/live-export.ts +2 -1
- package/src/index.ts +5 -0
- package/src/lint/audit-catalog.ts +45 -0
- package/src/lsp/completions.test.ts +4 -2
- 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,121 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createCodeDeployCapability, createCopyToHostCapability, createRemoteExecCapability } from "./host-delivery";
|
|
3
|
+
import { createMockCloudExecutor } from "./__tests__/mock-cloud-executor";
|
|
4
|
+
|
|
5
|
+
describe("code-deploy (#557 — AWS CodeDeploy)", () => {
|
|
6
|
+
it("creates a deployment and waits for a terminal status", async () => {
|
|
7
|
+
const mock = createMockCloudExecutor();
|
|
8
|
+
const capability = createCodeDeployCapability(mock.executor);
|
|
9
|
+
|
|
10
|
+
const output = await capability.run(
|
|
11
|
+
{ env: "dev", component: "neo4j-cluster" },
|
|
12
|
+
{ application: "neo4j", deploymentGroup: "neo4j-0", revision: { type: "s3", uri: "s3://bucket/neo4j.zip" } },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(output.status).toBe("Succeeded");
|
|
16
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["createDeployment", "waitForDeployment"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accepts a bare wired string as the revision (matches the Neo4j pilot's `revision: \"@Seed.templateUri\"`)", async () => {
|
|
20
|
+
const mock = createMockCloudExecutor();
|
|
21
|
+
const capability = createCodeDeployCapability(mock.executor);
|
|
22
|
+
|
|
23
|
+
await capability.run(
|
|
24
|
+
{ env: "dev", component: "neo4j-cluster" },
|
|
25
|
+
{ instance: 1, revision: "s3://bucket/neo4j-seed-uri.zip" },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const createCall = mock.calls.find((c) => c.method === "createDeployment")!;
|
|
29
|
+
expect(createCall.args).toMatchObject({
|
|
30
|
+
application: "neo4j-cluster",
|
|
31
|
+
deploymentGroup: "neo4j-cluster-1",
|
|
32
|
+
revision: { type: "s3", uri: "s3://bucket/neo4j-seed-uri.zip" },
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("derives application from ctx.component and deploymentGroup from instance when both are omitted", async () => {
|
|
37
|
+
const mock = createMockCloudExecutor();
|
|
38
|
+
const capability = createCodeDeployCapability(mock.executor);
|
|
39
|
+
await capability.run({ env: "dev", component: "neo4j-cluster" }, { instance: 0, revision: "@Seed.templateUri" });
|
|
40
|
+
const createCall = mock.calls.find((c) => c.method === "createDeployment")!;
|
|
41
|
+
expect(createCall.args).toMatchObject({ application: "neo4j-cluster", deploymentGroup: "neo4j-cluster-0" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("throws when the deployment ends in a non-Succeeded terminal status", async () => {
|
|
45
|
+
const mock = createMockCloudExecutor({ deployments: {} });
|
|
46
|
+
const capability = createCodeDeployCapability(mock.executor);
|
|
47
|
+
// Script the deployment id this run will create to fail.
|
|
48
|
+
const runPromise = capability.run(
|
|
49
|
+
{ env: "dev", component: "neo4j-cluster" },
|
|
50
|
+
{ instance: 2, revision: "@Seed.templateUri" },
|
|
51
|
+
);
|
|
52
|
+
// The mock assigns deployment ids sequentially; set its status to Failed before awaiting.
|
|
53
|
+
mock.setDeployment("mock-deployment-1", { terminalStatus: "Failed" });
|
|
54
|
+
await expect(runPromise).rejects.toThrow(/ended Failed/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rollback stops and rolls back the most recent deployment for the same application/deploymentGroup", async () => {
|
|
58
|
+
const mock = createMockCloudExecutor();
|
|
59
|
+
const capability = createCodeDeployCapability(mock.executor);
|
|
60
|
+
const input = { instance: 1, revision: "@Seed.templateUri" };
|
|
61
|
+
await capability.run({ env: "dev", component: "neo4j-cluster" }, input);
|
|
62
|
+
await capability.rollback!({ env: "dev", component: "neo4j-cluster" }, input);
|
|
63
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["createDeployment", "waitForDeployment", "stopAndRollback"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("rollback is a no-op when this capability instance never deployed to that target", async () => {
|
|
67
|
+
const mock = createMockCloudExecutor();
|
|
68
|
+
const capability = createCodeDeployCapability(mock.executor);
|
|
69
|
+
await capability.rollback!({ env: "dev", component: "neo4j-cluster" }, { instance: 9, revision: "x" });
|
|
70
|
+
expect(mock.calls).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const hostCtx = { env: "dev", component: "svc" };
|
|
75
|
+
|
|
76
|
+
describe("remote-exec (#557 — SSM Run Command)", () => {
|
|
77
|
+
it("runs the command on the host and returns its stdout + exit code", async () => {
|
|
78
|
+
const mock = createMockCloudExecutor({ hostExecStdout: "hello\n" });
|
|
79
|
+
const out = await createRemoteExecCapability(mock.executor).run(hostCtx, { host: "i-123", command: "echo hello" });
|
|
80
|
+
expect(out).toEqual({ stdout: "hello\n", exitCode: 0 });
|
|
81
|
+
expect(mock.calls).toEqual([{ client: "host", method: "exec", args: { host: "i-123", command: "echo hello", cwd: undefined } }]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("passes cwd through", async () => {
|
|
85
|
+
const mock = createMockCloudExecutor();
|
|
86
|
+
await createRemoteExecCapability(mock.executor).run(hostCtx, { host: "i-1", command: "ls", cwd: "/app" });
|
|
87
|
+
expect(mock.calls[0]!.args).toMatchObject({ cwd: "/app" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects the not-yet-supported ssh transport with a clear message", async () => {
|
|
91
|
+
const mock = createMockCloudExecutor();
|
|
92
|
+
await expect(
|
|
93
|
+
createRemoteExecCapability(mock.executor).run(hostCtx, { host: "h", command: "x", via: "ssh" }),
|
|
94
|
+
).rejects.toThrow(/ssh transport not yet supported/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("declares no rollback", () => {
|
|
98
|
+
expect(createRemoteExecCapability(createMockCloudExecutor().executor).rollback).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("copy-to-host (#557 — SSM Run Command)", () => {
|
|
103
|
+
it("has the host pull the file from S3 and reports the byte count", async () => {
|
|
104
|
+
const mock = createMockCloudExecutor({ hostExecStdout: "2048\n" });
|
|
105
|
+
const out = await createCopyToHostCapability(mock.executor).run(hostCtx, {
|
|
106
|
+
from: "s3://assets/app.jar",
|
|
107
|
+
host: "i-9",
|
|
108
|
+
to: "/opt/app.jar",
|
|
109
|
+
});
|
|
110
|
+
expect(out).toEqual({ bytesCopied: 2048 });
|
|
111
|
+
const call = mock.calls.find((c) => c.method === "exec")!;
|
|
112
|
+
expect((call.args as { command: string }).command).toContain("aws s3 cp s3://assets/app.jar /opt/app.jar");
|
|
113
|
+
expect((call.args as { command: string }).command).toContain("stat -c %s /opt/app.jar");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reports 0 bytes when the host returns no size", async () => {
|
|
117
|
+
const mock = createMockCloudExecutor({ hostExecStdout: "" });
|
|
118
|
+
const out = await createCopyToHostCapability(mock.executor).run(hostCtx, { from: "s3://b/x", host: "i", to: "/x" });
|
|
119
|
+
expect(out).toEqual({ bytesCopied: 0 });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host / code delivery family — run against a mutable host.
|
|
3
|
+
*
|
|
4
|
+
* `code-deploy` (AWS CodeDeploy) is the managed, nearly AWS-only leaf: create
|
|
5
|
+
* a deployment of a revision (S3/GitHub) to a deployment group, appspec-driven
|
|
6
|
+
* lifecycle hooks (BeforeInstall/AfterInstall/ApplicationStart/ValidateService),
|
|
7
|
+
* in-place or blue/green, native automatic rollback, wait for terminal status.
|
|
8
|
+
* Pairs with `publish-artifact` — the revision is the published S3 bundle
|
|
9
|
+
* (`@publish.uri`).
|
|
10
|
+
*
|
|
11
|
+
* `copy-to-host` / `remote-exec` (SSM Run Command or SSH) are the portable,
|
|
12
|
+
* lower-level generic verbs for host bootstrap, on-host migrations, and
|
|
13
|
+
* single-host compose where CodeDeploy is not used or not available
|
|
14
|
+
* (no clean Azure/GCP peer — see docs/components/cloud-boundary).
|
|
15
|
+
*
|
|
16
|
+
* `code-deploy` is a real implementation (#557, epic #551) over the
|
|
17
|
+
* injectable `CloudExecutor` (./cloud-executor.ts) — the capability the
|
|
18
|
+
* Neo4j fan-out pilot uses once per instance. `copy-to-host` and `remote-exec`
|
|
19
|
+
* are also real, over the executor's `host.exec` (SSM Run Command, waited to
|
|
20
|
+
* completion); the SSH transport is not yet implemented.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
24
|
+
import { defaultCloudExecutor, type CloudExecutor } from "./cloud-executor";
|
|
25
|
+
|
|
26
|
+
// ── code-deploy (AWS CodeDeploy) ─────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export interface CodeDeployInput {
|
|
29
|
+
/** CodeDeploy application name. Default: derived from `ctx.component` when omitted (per-instance fan-out steps, e.g. the Neo4j pilot, don't repeat it per node). */
|
|
30
|
+
application?: string;
|
|
31
|
+
/** CodeDeploy deployment group name. Default: `<application>-<instance>` when `instance` is given, else `<application>`. */
|
|
32
|
+
deploymentGroup?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Revision location — an S3 bundle (typically `"@publish.uri"`), a GitHub
|
|
35
|
+
* reference, or a bare wired string (e.g. `"@Seed.templateUri"`, as the
|
|
36
|
+
* Neo4j pilot passes) treated as an S3 URI shorthand.
|
|
37
|
+
*/
|
|
38
|
+
revision: { type: "s3"; uri: string } | { type: "github"; repository: string; commitId: string } | string;
|
|
39
|
+
/** Deployment strategy. Default: "in-place". */
|
|
40
|
+
strategy?: "in-place" | "blue-green";
|
|
41
|
+
/** Cluster-relative instance index, for a per-instance fan-out step (e.g. one Neo4j node). Folded into the deployment group name when `deploymentGroup` is not given explicitly. */
|
|
42
|
+
instance?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CodeDeployOutput {
|
|
46
|
+
/** CodeDeploy deployment id, for polling terminal status. */
|
|
47
|
+
deploymentId: string;
|
|
48
|
+
/** Terminal deployment status once complete (`Succeeded`, `Failed`, `Stopped`). */
|
|
49
|
+
status: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Normalize `CodeDeployInput.revision` to the strict `{type, uri|repository+commitId}` shape the executor takes. */
|
|
53
|
+
function normalizeRevision(revision: CodeDeployInput["revision"]): CodeDeployCreateArgsRevision {
|
|
54
|
+
return typeof revision === "string" ? { type: "s3", uri: revision } : revision;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type CodeDeployCreateArgsRevision =
|
|
58
|
+
| { type: "s3"; uri: string }
|
|
59
|
+
| { type: "github"; repository: string; commitId: string };
|
|
60
|
+
|
|
61
|
+
/** Resolve the application/deployment-group names, defaulting from `ctx.component`/`instance` when the input omits them (matches the Neo4j pilot's per-instance shorthand). */
|
|
62
|
+
function resolveTargets(
|
|
63
|
+
ctx: { component: string },
|
|
64
|
+
input: CodeDeployInput,
|
|
65
|
+
): { application: string; deploymentGroup: string } {
|
|
66
|
+
const application = input.application ?? ctx.component;
|
|
67
|
+
const deploymentGroup =
|
|
68
|
+
input.deploymentGroup ?? (input.instance !== undefined ? `${application}-${input.instance}` : application);
|
|
69
|
+
return { application, deploymentGroup };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Deploy a revision to a host (fleet) via AWS CodeDeploy: create the
|
|
74
|
+
* deployment, then wait for a terminal status. Rollback re-invokes
|
|
75
|
+
* `stopAndRollback` against the deployment id this step created (captured in
|
|
76
|
+
* a closure-scoped map keyed by application/deploymentGroup, since the
|
|
77
|
+
* driver's saga rollback calls `rollback(ctx, sameInput)` rather than passing
|
|
78
|
+
* `run`'s output) — riding CodeDeploy's own native automatic rollback to the
|
|
79
|
+
* last known-good revision rather than a chant-scripted compensation, the
|
|
80
|
+
* "auto" rollback style the Neo4j pilot's docs comment contrasts with the
|
|
81
|
+
* ALB/ECS pilot's component-declared one.
|
|
82
|
+
*/
|
|
83
|
+
export function createCodeDeployCapability(
|
|
84
|
+
executor: CloudExecutor = defaultCloudExecutor(),
|
|
85
|
+
): Capability<CodeDeployInput, CodeDeployOutput> {
|
|
86
|
+
const lastDeploymentByTarget = new Map<string, string>();
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
kind: "code-deploy",
|
|
90
|
+
async run(ctx, input) {
|
|
91
|
+
const { application, deploymentGroup } = resolveTargets(ctx, input);
|
|
92
|
+
const { deploymentId } = await executor.codeDeploy.createDeployment({
|
|
93
|
+
application,
|
|
94
|
+
deploymentGroup,
|
|
95
|
+
revision: normalizeRevision(input.revision),
|
|
96
|
+
strategy: input.strategy,
|
|
97
|
+
});
|
|
98
|
+
lastDeploymentByTarget.set(`${application}/${deploymentGroup}`, deploymentId);
|
|
99
|
+
const { status } = await executor.codeDeploy.waitForDeployment(deploymentId);
|
|
100
|
+
if (status !== "Succeeded") {
|
|
101
|
+
throw new Error(`code-deploy "${application}/${deploymentGroup}": deployment ${deploymentId} ended ${status}`);
|
|
102
|
+
}
|
|
103
|
+
return { deploymentId, status };
|
|
104
|
+
},
|
|
105
|
+
async rollback(ctx, input) {
|
|
106
|
+
const { application, deploymentGroup } = resolveTargets(ctx, input);
|
|
107
|
+
const deploymentId = lastDeploymentByTarget.get(`${application}/${deploymentGroup}`);
|
|
108
|
+
if (!deploymentId) return; // nothing this capability instance deployed for this target — nothing to unwind.
|
|
109
|
+
await executor.codeDeploy.stopAndRollback(deploymentId);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Default `code-deploy` capability, backed by the real `CloudExecutor`. */
|
|
115
|
+
export const codeDeployCapability: Capability<CodeDeployInput, CodeDeployOutput> = createCodeDeployCapability();
|
|
116
|
+
|
|
117
|
+
// ── copy-to-host ─────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export interface CopyToHostInput {
|
|
120
|
+
/** Source path (local, or archive-relative reference). */
|
|
121
|
+
from: string;
|
|
122
|
+
/** Target host (SSM instance id, hostname, or host group). */
|
|
123
|
+
host: string;
|
|
124
|
+
/** Destination path on the host. */
|
|
125
|
+
to: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface CopyToHostOutput {
|
|
129
|
+
/** Number of bytes copied. */
|
|
130
|
+
bytesCopied: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Copy a file to a host via SSM Run Command: the host pulls `from` (an S3 URI)
|
|
135
|
+
* to `to` with `aws s3 cp`, then `stat`s the result for the byte count. `from`
|
|
136
|
+
* must be reachable from the host (typically a prior `publish-artifact`'s URI).
|
|
137
|
+
*/
|
|
138
|
+
export function createCopyToHostCapability(executor: CloudExecutor = defaultCloudExecutor()): Capability<CopyToHostInput, CopyToHostOutput> {
|
|
139
|
+
return {
|
|
140
|
+
kind: "copy-to-host",
|
|
141
|
+
rollbackPolicy: "needs-opt-out",
|
|
142
|
+
async run(_ctx, input) {
|
|
143
|
+
const { stdout } = await executor.host.exec({
|
|
144
|
+
host: input.host,
|
|
145
|
+
command: `aws s3 cp ${input.from} ${input.to} >/dev/null 2>&1 && stat -c %s ${input.to}`,
|
|
146
|
+
});
|
|
147
|
+
return { bytesCopied: Number.parseInt(stdout.trim(), 10) || 0 };
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Default `copy-to-host` capability, backed by the real `CloudExecutor`. */
|
|
153
|
+
export const copyToHostCapability: Capability<CopyToHostInput, CopyToHostOutput> = createCopyToHostCapability();
|
|
154
|
+
|
|
155
|
+
// ── remote-exec ──────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export interface RemoteExecInput {
|
|
158
|
+
/** Target host (SSM instance id, hostname, or host group). */
|
|
159
|
+
host: string;
|
|
160
|
+
/** Command to run on the host. */
|
|
161
|
+
command: string;
|
|
162
|
+
/** Working directory on the host. */
|
|
163
|
+
cwd?: string;
|
|
164
|
+
/** Transport. Default: "ssm". */
|
|
165
|
+
via?: "ssm" | "ssh";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export interface RemoteExecOutput {
|
|
169
|
+
/** Process exit code. */
|
|
170
|
+
exitCode: number;
|
|
171
|
+
/** Captured stdout. */
|
|
172
|
+
stdout: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Run a command on a remote host via SSM Run Command, waiting for it to finish
|
|
177
|
+
* and returning its stdout + exit code. SSH transport is not yet supported.
|
|
178
|
+
*/
|
|
179
|
+
export function createRemoteExecCapability(executor: CloudExecutor = defaultCloudExecutor()): Capability<RemoteExecInput, RemoteExecOutput> {
|
|
180
|
+
return {
|
|
181
|
+
kind: "remote-exec",
|
|
182
|
+
rollbackPolicy: "needs-opt-out",
|
|
183
|
+
async run(_ctx, input) {
|
|
184
|
+
if (input.via === "ssh") {
|
|
185
|
+
throw new Error(`remote-exec: ssh transport not yet supported for host "${input.host}" — use the default ssm transport`);
|
|
186
|
+
}
|
|
187
|
+
return executor.host.exec({ host: input.host, command: input.command, cwd: input.cwd });
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Default `remote-exec` capability, backed by the real `CloudExecutor`. */
|
|
193
|
+
export const remoteExecCapability: Capability<RemoteExecInput, RemoteExecOutput> = createRemoteExecCapability();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The aws lexicon's component/release surface: the AWS-leaf capabilities, their
|
|
3
|
+
* typed step-builders, the injectable aws `CloudExecutor`, and the
|
|
4
|
+
* `awsCapabilityPlugin` core loads when a project declares `lexicons: ["aws"]`.
|
|
5
|
+
* Component authors import the AWS verbs' builders from here
|
|
6
|
+
* (`@intentius/chant-lexicon-aws/components`), the way agnostic verbs come from
|
|
7
|
+
* `@intentius/chant/components`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { awsCapabilityPlugin, AWS_VERB_FAMILIES } from "./capability-plugin";
|
|
11
|
+
export * from "./builders";
|
|
12
|
+
export * from "./config-bom";
|
|
13
|
+
export * from "./apply";
|
|
14
|
+
export * from "./job-submission";
|
|
15
|
+
export * from "./host-delivery";
|
|
16
|
+
export * from "./safety";
|
|
17
|
+
export * from "./wait-aws";
|
|
18
|
+
export * from "./publish";
|
|
19
|
+
export * from "./cloud-executor";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createEmrStartJobRunCapability, createEmrSubmitStepCapability } from "./job-submission";
|
|
3
|
+
import { createMockCloudExecutor } from "./__tests__/mock-cloud-executor";
|
|
4
|
+
|
|
5
|
+
describe("emr-start-job-run (#561)", () => {
|
|
6
|
+
it("starts a job run against the resolved artifact reference and returns the run id", async () => {
|
|
7
|
+
const mock = createMockCloudExecutor();
|
|
8
|
+
const capability = createEmrStartJobRunCapability(mock.executor);
|
|
9
|
+
|
|
10
|
+
const output = await capability.run(
|
|
11
|
+
{ env: "dev", component: "emr-job" },
|
|
12
|
+
{ clusterOrApplicationId: "app-123", jar: "s3://jar-bucket/jar-lib.jar", args: ["--input", "s3://data/in"] },
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(output.runId).toBe("mock-job-run-1");
|
|
16
|
+
const call = mock.calls.find((c) => c.client === "emr" && c.method === "startJobRun")!;
|
|
17
|
+
expect(call.args).toMatchObject({
|
|
18
|
+
clusterOrApplicationId: "app-123",
|
|
19
|
+
jar: "s3://jar-bucket/jar-lib.jar",
|
|
20
|
+
args: ["--input", "s3://data/in"],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("falls back to ctx.vars.emrApplicationId when clusterOrApplicationId is omitted (matches the emr-job pilot's bare step, which wires the artifact reference but not a literal application id)", async () => {
|
|
25
|
+
const mock = createMockCloudExecutor();
|
|
26
|
+
const capability = createEmrStartJobRunCapability(mock.executor);
|
|
27
|
+
|
|
28
|
+
await capability.run(
|
|
29
|
+
{ env: "dev", component: "emr-job", vars: { emrApplicationId: "app-from-env" } },
|
|
30
|
+
{ jar: "s3://jar-bucket/jar-lib.jar" },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const call = mock.calls.find((c) => c.client === "emr" && c.method === "startJobRun")!;
|
|
34
|
+
expect(call.args).toMatchObject({ clusterOrApplicationId: "app-from-env" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("falls back to ctx.component as a last resort when neither clusterOrApplicationId nor ctx.vars.emrApplicationId is given", async () => {
|
|
38
|
+
const mock = createMockCloudExecutor();
|
|
39
|
+
const capability = createEmrStartJobRunCapability(mock.executor);
|
|
40
|
+
|
|
41
|
+
await capability.run({ env: "dev", component: "emr-job" }, { jar: "s3://jar-bucket/jar-lib.jar" });
|
|
42
|
+
|
|
43
|
+
const call = mock.calls.find((c) => c.client === "emr" && c.method === "startJobRun")!;
|
|
44
|
+
expect(call.args).toMatchObject({ clusterOrApplicationId: "emr-job" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("never resolves the @<component>.publish.uri reference itself — it receives whatever the driver already resolved `jar` to", async () => {
|
|
48
|
+
const mock = createMockCloudExecutor();
|
|
49
|
+
const capability = createEmrStartJobRunCapability(mock.executor);
|
|
50
|
+
|
|
51
|
+
// Passing the raw reference string through unresolved documents that this
|
|
52
|
+
// capability is not where cross-component wiring happens — ../driver.ts's
|
|
53
|
+
// resolveWiring does that before `run` is ever called.
|
|
54
|
+
await capability.run({ env: "dev", component: "emr-job" }, { jar: "@jar-lib.publish.uri" });
|
|
55
|
+
|
|
56
|
+
const call = mock.calls.find((c) => c.client === "emr" && c.method === "startJobRun")!;
|
|
57
|
+
expect(call.args).toMatchObject({ jar: "@jar-lib.publish.uri" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("declares no rollback — starting a job run has nothing to compensate", () => {
|
|
61
|
+
const capability = createEmrStartJobRunCapability(createMockCloudExecutor().executor);
|
|
62
|
+
expect(capability.rollback).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("emr-submit-step", () => {
|
|
67
|
+
it("submits a step to the cluster and returns the step id", async () => {
|
|
68
|
+
const mock = createMockCloudExecutor();
|
|
69
|
+
const out = await createEmrSubmitStepCapability(mock.executor).run(
|
|
70
|
+
{ env: "dev", component: "emr-job" },
|
|
71
|
+
{ clusterId: "j-123", name: "nightly-rollup", jar: "s3://jar-bucket/jar-lib.jar", args: ["--date", "2026-07-04"] },
|
|
72
|
+
);
|
|
73
|
+
expect(out).toEqual({ stepId: "s-MOCK0001" });
|
|
74
|
+
const call = mock.calls.find((c) => c.client === "emr" && c.method === "addStep")!;
|
|
75
|
+
expect(call.args).toEqual({
|
|
76
|
+
clusterId: "j-123",
|
|
77
|
+
name: "nightly-rollup",
|
|
78
|
+
jar: "s3://jar-bucket/jar-lib.jar",
|
|
79
|
+
args: ["--date", "2026-07-04"],
|
|
80
|
+
actionOnFailure: undefined,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("passes an unresolved artifact reference straight through (the graph resolves it before run)", async () => {
|
|
85
|
+
const mock = createMockCloudExecutor();
|
|
86
|
+
await createEmrSubmitStepCapability(mock.executor).run(
|
|
87
|
+
{ env: "dev", component: "emr-job" },
|
|
88
|
+
{ clusterId: "j-123", name: "step", jar: "@jar-lib.publish.uri", actionOnFailure: "CANCEL_AND_WAIT" },
|
|
89
|
+
);
|
|
90
|
+
const call = mock.calls.find((c) => c.method === "addStep")!;
|
|
91
|
+
expect(call.args).toMatchObject({ jar: "@jar-lib.publish.uri", actionOnFailure: "CANCEL_AND_WAIT" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("declares no rollback — a submitted step has nothing to compensate", () => {
|
|
95
|
+
expect(createEmrSubmitStepCapability(createMockCloudExecutor().executor).rollback).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* job submission family — point a running compute service at an artifact.
|
|
3
|
+
* Cloud-shaped (EMR here; the same shape reuses for Glue / Batch / Step
|
|
4
|
+
* Functions / SageMaker per docs/components/cloud-boundary). Typically
|
|
5
|
+
* consumes a producer component's published artifact, e.g.
|
|
6
|
+
* `jar: "@jar-lib.publish.uri"`.
|
|
7
|
+
*
|
|
8
|
+
* `emr-start-job-run` is a real implementation (#561, epic #551) — just
|
|
9
|
+
* enough against the injectable `CloudExecutor` (./cloud-executor.ts) to run
|
|
10
|
+
* the JAR-producer -> EMR-consumer cross-component example end to end (a
|
|
11
|
+
* mocked cloud is acceptable per #561's acceptance criteria; the full EMR
|
|
12
|
+
* surface — retries, Serverless vs EMR-on-EC2 branching, Glue/Batch peers —
|
|
13
|
+
* stays out of scope, per #561 explicitly deferring "the emr-start-job-run
|
|
14
|
+
* cloud impl beyond what the example needs"). `emr-submit-step` is a
|
|
15
|
+
* different verb (submit a step to an already-running EMR-on-EC2 cluster
|
|
16
|
+
* rather than starting an ephemeral job run), also real over the executor
|
|
17
|
+
* (`aws emr add-steps`) and unit-tested against the mock — the same
|
|
18
|
+
* "mocked cloud is acceptable" basis #561 set for its sibling, since a live
|
|
19
|
+
* long-running EMR cluster is not part of gating CI.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
23
|
+
import { defaultCloudExecutor, type CloudExecutor } from "./cloud-executor";
|
|
24
|
+
|
|
25
|
+
// ── emr-start-job-run ────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface EmrStartJobRunInput {
|
|
28
|
+
/**
|
|
29
|
+
* EMR (Serverless) application id, or EMR on EC2 cluster id. Optional here
|
|
30
|
+
* (unlike the underlying `CloudExecutor.emr.startJobRun` args) because the
|
|
31
|
+
* epic's worked JAR->EMR example (component-contract fixture
|
|
32
|
+
* `../__fixtures__/emr-job-consumer.json`, docs/components/composition-and-wiring.mdx)
|
|
33
|
+
* wires it as env config rather than a literal in the component
|
|
34
|
+
* declaration; falls back to `ctx.vars.emrApplicationId`, matching how
|
|
35
|
+
* `wait-cluster-healthy` (../verbs/wait-verify.ts) falls back to
|
|
36
|
+
* `ctx.vars.clusterEndpoints` for the same reason (env resolution is the
|
|
37
|
+
* caller's `DeployContext.vars`, not a literal every component must repeat).
|
|
38
|
+
*/
|
|
39
|
+
clusterOrApplicationId?: string;
|
|
40
|
+
/** Entry point artifact reference — resolved by the graph before this capability runs (e.g. from `"@jar-lib.publish.uri"` to a concrete S3 URI). */
|
|
41
|
+
jar: string;
|
|
42
|
+
/** Arguments passed to the job's main class/entry point. */
|
|
43
|
+
args?: string[];
|
|
44
|
+
/** Execution role ARN. */
|
|
45
|
+
executionRoleArn?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EmrStartJobRunOutput {
|
|
49
|
+
/** EMR job run id, for polling via `wait-job`. */
|
|
50
|
+
runId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Start an EMR job run (Serverless application or EMR-on-EC2 cluster)
|
|
55
|
+
* against a published artifact. `input.jar` arrives already resolved by the
|
|
56
|
+
* driver's graph-driven wiring (`@<component>.publish.uri`) — this capability
|
|
57
|
+
* never resolves the reference itself, keeping cross-component wiring
|
|
58
|
+
* entirely the graph's job (../driver.ts's `resolveWiring`), never a
|
|
59
|
+
* capability- or orchestrator-level special case. No rollback: starting a
|
|
60
|
+
* job run has no "undo" that restores prior state — a failed/cancelled run
|
|
61
|
+
* simply produced no usable output, matching `wait-for-stack`/`wait-job`'s
|
|
62
|
+
* read-only-observation story (there is nothing to compensate for having
|
|
63
|
+
* asked the cluster to run something).
|
|
64
|
+
*/
|
|
65
|
+
export function createEmrStartJobRunCapability(
|
|
66
|
+
executor: CloudExecutor = defaultCloudExecutor(),
|
|
67
|
+
): Capability<EmrStartJobRunInput, EmrStartJobRunOutput> {
|
|
68
|
+
return {
|
|
69
|
+
kind: "emr-start-job-run",
|
|
70
|
+
rollbackPolicy: "needs-opt-out",
|
|
71
|
+
async run(ctx, input) {
|
|
72
|
+
const clusterOrApplicationId =
|
|
73
|
+
input.clusterOrApplicationId ?? (ctx.vars?.emrApplicationId as string | undefined) ?? ctx.component;
|
|
74
|
+
const { runId } = await executor.emr.startJobRun({
|
|
75
|
+
clusterOrApplicationId,
|
|
76
|
+
jar: input.jar,
|
|
77
|
+
args: input.args,
|
|
78
|
+
executionRoleArn: input.executionRoleArn,
|
|
79
|
+
});
|
|
80
|
+
return { runId };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Default `emr-start-job-run` capability, backed by the real `CloudExecutor`. */
|
|
86
|
+
export const emrStartJobRunCapability: Capability<EmrStartJobRunInput, EmrStartJobRunOutput> =
|
|
87
|
+
createEmrStartJobRunCapability();
|
|
88
|
+
|
|
89
|
+
// ── emr-submit-step ──────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface EmrSubmitStepInput {
|
|
92
|
+
/** EMR-on-EC2 cluster id to submit the step to. */
|
|
93
|
+
clusterId: string;
|
|
94
|
+
/** Step name, shown in the EMR console. */
|
|
95
|
+
name: string;
|
|
96
|
+
/** Entry point artifact reference (e.g. `"@jar-lib.publish.uri"`) — resolved by the graph before this capability runs, exactly like `emr-start-job-run`'s `jar`. */
|
|
97
|
+
jar: string;
|
|
98
|
+
/** Arguments passed to the step. */
|
|
99
|
+
args?: string[];
|
|
100
|
+
/** What EMR does if the step fails. Default: "CONTINUE". */
|
|
101
|
+
actionOnFailure?: "CONTINUE" | "CANCEL_AND_WAIT" | "TERMINATE_CLUSTER";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface EmrSubmitStepOutput {
|
|
105
|
+
/** EMR step id, for polling via `wait-job`. */
|
|
106
|
+
stepId: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Submit a step to a long-running EMR-on-EC2 cluster (as opposed to
|
|
111
|
+
* `emr-start-job-run`, which starts an ephemeral Serverless/EC2 job run) via
|
|
112
|
+
* `aws emr add-steps` through the injectable `CloudExecutor`. Returns the step
|
|
113
|
+
* id for polling via `wait-job`. Like its sibling, `input.jar` arrives
|
|
114
|
+
* already resolved by the driver's graph-driven wiring, and there is no
|
|
115
|
+
* rollback — a submitted step has no "undo" that restores prior state.
|
|
116
|
+
*/
|
|
117
|
+
export function createEmrSubmitStepCapability(
|
|
118
|
+
executor: CloudExecutor = defaultCloudExecutor(),
|
|
119
|
+
): Capability<EmrSubmitStepInput, EmrSubmitStepOutput> {
|
|
120
|
+
return {
|
|
121
|
+
kind: "emr-submit-step",
|
|
122
|
+
rollbackPolicy: "needs-opt-out",
|
|
123
|
+
async run(_ctx, input) {
|
|
124
|
+
const { stepId } = await executor.emr.addStep({
|
|
125
|
+
clusterId: input.clusterId,
|
|
126
|
+
name: input.name,
|
|
127
|
+
jar: input.jar,
|
|
128
|
+
args: input.args,
|
|
129
|
+
actionOnFailure: input.actionOnFailure,
|
|
130
|
+
});
|
|
131
|
+
return { stepId };
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Default `emr-submit-step` capability, backed by the real `CloudExecutor`. */
|
|
137
|
+
export const emrSubmitStepCapability: Capability<EmrSubmitStepInput, EmrSubmitStepOutput> =
|
|
138
|
+
createEmrSubmitStepCapability();
|