@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,318 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createLoadImageOnHostCapability,
|
|
4
|
+
createPublishImageCapability,
|
|
5
|
+
createPublishArtifactCapability,
|
|
6
|
+
loadImageOnHostCapability as loadImageOnHost,
|
|
7
|
+
publishImageCapability as publishImage,
|
|
8
|
+
selectPublishBackend,
|
|
9
|
+
} from "./publish";
|
|
10
|
+
import { writeFileSync, mkdtempSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { createDockerBuildCapability } from "@intentius/chant/components/verbs/build";
|
|
15
|
+
import { createMockCloudExecutor } from "./__tests__/mock-cloud-executor";
|
|
16
|
+
import { createMockProcessRunner } from "@intentius/chant/components/verbs/__tests__/mock-process-runner";
|
|
17
|
+
|
|
18
|
+
const ctx = { env: "dev", component: "search-service" };
|
|
19
|
+
|
|
20
|
+
describe("publish-image (#557)", () => {
|
|
21
|
+
it("loads the archived tarball, tags for the destination registry, logs in, and pushes — promoting by digest", async () => {
|
|
22
|
+
const mock = createMockCloudExecutor();
|
|
23
|
+
const capability = createPublishImageCapability(mock.executor);
|
|
24
|
+
|
|
25
|
+
const output = await capability.run(ctx, { from: "archive/search.tar", to: "123.dkr.ecr.us-east-1.amazonaws.com/search" });
|
|
26
|
+
|
|
27
|
+
expect(output.digest).toMatch(/^sha256:/);
|
|
28
|
+
expect(output.uri).toBe(`123.dkr.ecr.us-east-1.amazonaws.com/search@${output.digest}`);
|
|
29
|
+
expect(mock.calls.map((c) => c.method)).toEqual(["load", "tag", "login", "push"]);
|
|
30
|
+
const loginCall = mock.calls.find((c) => c.method === "login")!;
|
|
31
|
+
expect(loginCall.args).toBe("123.dkr.ecr.us-east-1.amazonaws.com");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("pushes additional tags alongside the digest", async () => {
|
|
35
|
+
const mock = createMockCloudExecutor();
|
|
36
|
+
const capability = createPublishImageCapability(mock.executor);
|
|
37
|
+
|
|
38
|
+
await capability.run(ctx, {
|
|
39
|
+
from: "archive/search.tar",
|
|
40
|
+
to: "123.dkr.ecr.us-east-1.amazonaws.com/search",
|
|
41
|
+
tags: ["latest", "v1.2.3"],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const pushCalls = mock.calls.filter((c) => c.method === "push");
|
|
45
|
+
// one push for the digest-qualified reference, one per extra tag
|
|
46
|
+
expect(pushCalls).toHaveLength(3);
|
|
47
|
+
const pushedImages = pushCalls.map((c) => (c.args as { image: string }).image);
|
|
48
|
+
expect(pushedImages.some((i) => i.endsWith(":latest"))).toBe(true);
|
|
49
|
+
expect(pushedImages.some((i) => i.endsWith(":v1.2.3"))).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("surfaces a push failure (e.g. registry auth/network) as a rejected promise", async () => {
|
|
53
|
+
const mock = createMockCloudExecutor({ failDocker: true });
|
|
54
|
+
const capability = createPublishImageCapability(mock.executor);
|
|
55
|
+
await expect(
|
|
56
|
+
capability.run(ctx, { from: "archive/search.tar", to: "123.dkr.ecr.us-east-1.amazonaws.com/search" }),
|
|
57
|
+
).rejects.toThrow(/docker push failed/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("declares no rollback — an already-pushed, content-addressed image is not itself something to undo", () => {
|
|
61
|
+
const capability = createPublishImageCapability(createMockCloudExecutor().executor);
|
|
62
|
+
expect(capability.rollback).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("accepts an archive: wiring reference the same way cfn-deploy's template field does", async () => {
|
|
66
|
+
const mock = createMockCloudExecutor();
|
|
67
|
+
const capability = createPublishImageCapability(mock.executor);
|
|
68
|
+
await capability.run(ctx, { from: "archive:search.tar", to: "123.dkr.ecr.us-east-1.amazonaws.com/search" });
|
|
69
|
+
const loadCall = mock.calls.find((c) => c.method === "load")!;
|
|
70
|
+
expect(loadCall.args).toMatchObject({ inFile: "search.tar" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("requires to — throws a clear error rather than silently no-op-ing when the env forgot to configure a registry", async () => {
|
|
74
|
+
const mock = createMockCloudExecutor();
|
|
75
|
+
const capability = createPublishImageCapability(mock.executor);
|
|
76
|
+
await expect(capability.run(ctx, { from: "archive/search.tar" })).rejects.toThrow(/"to".*is required/);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("publish-image — SBOM/component-BOM referrer attach (#610)", () => {
|
|
81
|
+
it("does nothing (no oras call at all) when neither sbom nor componentBom is supplied", async () => {
|
|
82
|
+
const cloud = createMockCloudExecutor();
|
|
83
|
+
const proc = createMockProcessRunner();
|
|
84
|
+
const capability = createPublishImageCapability(cloud.executor, proc.runner);
|
|
85
|
+
|
|
86
|
+
const output = await capability.run(ctx, { from: "archive/search.tar", to: "123.dkr.ecr.us-east-1.amazonaws.com/search" });
|
|
87
|
+
|
|
88
|
+
expect(output.referrerAttach).toBeUndefined();
|
|
89
|
+
expect(proc.calls).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("attaches the SBOM as an OCI referrer on the pushed digest via oras attach", async () => {
|
|
93
|
+
const cloud = createMockCloudExecutor();
|
|
94
|
+
const proc = createMockProcessRunner();
|
|
95
|
+
const capability = createPublishImageCapability(cloud.executor, proc.runner);
|
|
96
|
+
|
|
97
|
+
const output = await capability.run(ctx, {
|
|
98
|
+
from: "archive/search.tar",
|
|
99
|
+
to: "123.dkr.ecr.us-east-1.amazonaws.com/search",
|
|
100
|
+
sbom: { bytes: '{"fake":"sbom"}', mediaType: "application/spdx+json" },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(output.referrerAttach).toEqual({ attached: true });
|
|
104
|
+
const attachCall = proc.calls.find((c) => c.command.startsWith("oras attach"))!;
|
|
105
|
+
expect(attachCall.command).toContain("--artifact-type 'application/spdx+json'");
|
|
106
|
+
expect(attachCall.command).toContain(`123.dkr.ecr.us-east-1.amazonaws.com/search@${output.digest}`);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("attaches both the SBOM and the component BOM when both are supplied", async () => {
|
|
110
|
+
const cloud = createMockCloudExecutor();
|
|
111
|
+
const proc = createMockProcessRunner();
|
|
112
|
+
const capability = createPublishImageCapability(cloud.executor, proc.runner);
|
|
113
|
+
|
|
114
|
+
await capability.run(ctx, {
|
|
115
|
+
from: "archive/search.tar",
|
|
116
|
+
to: "123.dkr.ecr.us-east-1.amazonaws.com/search",
|
|
117
|
+
sbom: { bytes: '{"fake":"sbom"}', mediaType: "application/spdx+json" },
|
|
118
|
+
componentBom: { bytes: '{"fake":"bom"}', mediaType: "application/vnd.cyclonedx+json" },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const attachCalls = proc.calls.filter((c) => c.command.startsWith("oras attach"));
|
|
122
|
+
expect(attachCalls).toHaveLength(2);
|
|
123
|
+
expect(attachCalls.some((c) => c.command.includes("application/spdx+json"))).toBe(true);
|
|
124
|
+
expect(attachCalls.some((c) => c.command.includes("application/vnd.cyclonedx+json"))).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("reports attached: false with a reason, and does not fail the publish, when oras is not installed", async () => {
|
|
128
|
+
const cloud = createMockCloudExecutor();
|
|
129
|
+
const proc = createMockProcessRunner({ tools: { oras: false } });
|
|
130
|
+
const capability = createPublishImageCapability(cloud.executor, proc.runner);
|
|
131
|
+
|
|
132
|
+
const output = await capability.run(ctx, {
|
|
133
|
+
from: "archive/search.tar",
|
|
134
|
+
to: "123.dkr.ecr.us-east-1.amazonaws.com/search",
|
|
135
|
+
sbom: { bytes: '{"fake":"sbom"}', mediaType: "application/spdx+json" },
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(output.digest).toMatch(/^sha256:/); // the image itself still published successfully.
|
|
139
|
+
expect(output.referrerAttach?.attached).toBe(false);
|
|
140
|
+
expect(output.referrerAttach?.reason).toMatch(/oras.*not installed/);
|
|
141
|
+
expect(proc.calls.some((c) => c.command.startsWith("oras attach"))).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reports attached: false with a reason, and does not fail the publish, when oras attach itself fails", async () => {
|
|
145
|
+
const cloud = createMockCloudExecutor();
|
|
146
|
+
const proc = createMockProcessRunner({ failures: { "oras attach": "oras: unauthorized" } });
|
|
147
|
+
const capability = createPublishImageCapability(cloud.executor, proc.runner);
|
|
148
|
+
|
|
149
|
+
const output = await capability.run(ctx, {
|
|
150
|
+
from: "archive/search.tar",
|
|
151
|
+
to: "123.dkr.ecr.us-east-1.amazonaws.com/search",
|
|
152
|
+
sbom: { bytes: '{"fake":"sbom"}', mediaType: "application/spdx+json" },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(output.digest).toMatch(/^sha256:/);
|
|
156
|
+
expect(output.referrerAttach).toEqual({ attached: false, reason: "oras: unauthorized" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("load-image-on-host never attempts a referrer attach — registry-less, nothing to attach to", async () => {
|
|
160
|
+
const cloud = createMockCloudExecutor();
|
|
161
|
+
const proc = createMockProcessRunner();
|
|
162
|
+
const capability = createLoadImageOnHostCapability(cloud.executor);
|
|
163
|
+
|
|
164
|
+
const output = await capability.run(ctx, {
|
|
165
|
+
from: "archive/search.tar",
|
|
166
|
+
host: "i-abc",
|
|
167
|
+
sbom: { bytes: '{"fake":"sbom"}', mediaType: "application/spdx+json" },
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(output.referrerAttach).toBeUndefined();
|
|
171
|
+
expect(proc.calls).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("load-image-on-host (#564 — registry-less backend)", () => {
|
|
176
|
+
it("copies the archived tarball to the host and docker-loads it there, with no registry/ECR calls at all", async () => {
|
|
177
|
+
const mock = createMockCloudExecutor();
|
|
178
|
+
const capability = createLoadImageOnHostCapability(mock.executor);
|
|
179
|
+
|
|
180
|
+
const output = await capability.run(ctx, { from: "archive/search.tar", host: "i-0123456789abcdef0" });
|
|
181
|
+
|
|
182
|
+
expect(output.digest).toMatch(/^sha256:/);
|
|
183
|
+
expect(output.uri).toBe(`host:i-0123456789abcdef0#${output.digest}`);
|
|
184
|
+
expect(mock.calls.map((c) => `${c.client}.${c.method}`)).toEqual(["host.copyFile", "host.dockerLoad"]);
|
|
185
|
+
expect(mock.calls.some((c) => c.client === "ecr" || c.client === "docker")).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("defaults the on-host path to the archive path's basename under /tmp/chant-archive", async () => {
|
|
189
|
+
const mock = createMockCloudExecutor();
|
|
190
|
+
const capability = createLoadImageOnHostCapability(mock.executor);
|
|
191
|
+
await capability.run(ctx, { from: "archive/search.tar", host: "i-abc" });
|
|
192
|
+
const copyCall = mock.calls.find((c) => c.method === "copyFile")!;
|
|
193
|
+
expect(copyCall.args).toMatchObject({ to: "/tmp/chant-archive/search.tar" });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("accepts an archive: wiring reference and an explicit destination path", async () => {
|
|
197
|
+
const mock = createMockCloudExecutor();
|
|
198
|
+
const capability = createLoadImageOnHostCapability(mock.executor);
|
|
199
|
+
await capability.run(ctx, { from: "archive:search.tar", host: "i-abc", hostPath: "/opt/images/search.tar" });
|
|
200
|
+
const copyCall = mock.calls.find((c) => c.method === "copyFile")!;
|
|
201
|
+
expect(copyCall.args).toMatchObject({ from: "search.tar", to: "/opt/images/search.tar" });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("requires host — throws a clear error rather than silently no-op-ing when the env forgot to configure one", async () => {
|
|
205
|
+
const mock = createMockCloudExecutor();
|
|
206
|
+
const capability = createLoadImageOnHostCapability(mock.executor);
|
|
207
|
+
await expect(capability.run(ctx, { from: "archive/search.tar" })).rejects.toThrow(/"host" is required/);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("surfaces a host copy/load failure (unreachable host) as a rejected promise", async () => {
|
|
211
|
+
const mock = createMockCloudExecutor({ failHost: true });
|
|
212
|
+
const capability = createLoadImageOnHostCapability(mock.executor);
|
|
213
|
+
await expect(capability.run(ctx, { from: "archive/search.tar", host: "i-abc" })).rejects.toThrow(
|
|
214
|
+
/host copy failed/,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("declares no rollback — an already-loaded, content-addressed image on the host is not itself something to undo", () => {
|
|
219
|
+
const capability = createLoadImageOnHostCapability(createMockCloudExecutor().executor);
|
|
220
|
+
expect(capability.rollback).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("promote by digest — deferred publish never rebuilds per environment (#564 acceptance criterion)", () => {
|
|
225
|
+
it("one docker-build's archived image promotes to two environments (registry + host-load) from the same archive, with no second build", async () => {
|
|
226
|
+
// One archive/executor shared across build + both env promotions — the
|
|
227
|
+
// same archive the build produced is what each environment's publish
|
|
228
|
+
// step reads from, never a fresh build.
|
|
229
|
+
const mock = createMockCloudExecutor();
|
|
230
|
+
const { archivePath } = await createDockerBuildCapability(mock.executor).run(ctx, {
|
|
231
|
+
context: ".",
|
|
232
|
+
into: "archive/search.tar",
|
|
233
|
+
});
|
|
234
|
+
expect(mock.calls.filter((c) => c.method === "build")).toHaveLength(1);
|
|
235
|
+
|
|
236
|
+
// dev: env config selects the registry backend.
|
|
237
|
+
const devBackend = selectPublishBackend("publish-image", {
|
|
238
|
+
"publish-image": createPublishImageCapability(mock.executor),
|
|
239
|
+
"load-image-on-host": createLoadImageOnHostCapability(mock.executor),
|
|
240
|
+
});
|
|
241
|
+
const devOutput = await devBackend.run(
|
|
242
|
+
{ env: "dev", component: "search-service" },
|
|
243
|
+
{ from: archivePath, to: "123.dkr.ecr.us-east-1.amazonaws.com/search" },
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// prod: env config selects the registry-less host backend for the *same archive path* — no rebuild.
|
|
247
|
+
const prodBackend = selectPublishBackend("load-image-on-host", {
|
|
248
|
+
"publish-image": createPublishImageCapability(mock.executor),
|
|
249
|
+
"load-image-on-host": createLoadImageOnHostCapability(mock.executor),
|
|
250
|
+
});
|
|
251
|
+
const prodOutput = await prodBackend.run(
|
|
252
|
+
{ env: "prod", component: "search-service" },
|
|
253
|
+
{ from: archivePath, host: "i-prodhost" },
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
// Exactly one build happened for the whole scenario — both environment
|
|
257
|
+
// promotions read the same archived tarball rather than triggering a
|
|
258
|
+
// second `docker build`. `publish-image` reads it via `docker.load`;
|
|
259
|
+
// `load-image-on-host` reads the very same path via `host.copyFile`
|
|
260
|
+
// instead — two different backends, one unrebuilt archive.
|
|
261
|
+
expect(mock.calls.filter((c) => c.method === "build")).toHaveLength(1);
|
|
262
|
+
expect(mock.calls.find((c) => c.method === "load")?.args).toMatchObject({ inFile: archivePath });
|
|
263
|
+
expect(mock.calls.find((c) => c.method === "copyFile")?.args).toMatchObject({ from: archivePath });
|
|
264
|
+
expect(devOutput.digest).toMatch(/^sha256:/);
|
|
265
|
+
expect(prodOutput.digest).toMatch(/^sha256:/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("selectPublishBackend defaults to the two starter-set backends and throws on an unknown kind", () => {
|
|
269
|
+
expect(selectPublishBackend("publish-image")).toBe(publishImage);
|
|
270
|
+
expect(selectPublishBackend("load-image-on-host")).toBe(loadImageOnHost);
|
|
271
|
+
expect(() =>
|
|
272
|
+
selectPublishBackend(
|
|
273
|
+
// @ts-expect-error deliberately invalid kind to exercise the error path
|
|
274
|
+
"something-else",
|
|
275
|
+
),
|
|
276
|
+
).toThrow(/no publish backend registered/);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("publish-artifact (#557)", () => {
|
|
281
|
+
it("uploads the archive file to S3 and returns its uri + content digest", async () => {
|
|
282
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pa-"));
|
|
283
|
+
const file = join(dir, "app.jar");
|
|
284
|
+
writeFileSync(file, "JAR-BYTES");
|
|
285
|
+
const expectedDigest = `sha256:${createHash("sha256").update("JAR-BYTES").digest("hex")}`;
|
|
286
|
+
const mock = createMockCloudExecutor();
|
|
287
|
+
|
|
288
|
+
const out = await createPublishArtifactCapability(mock.executor).run(
|
|
289
|
+
{ env: "dev", component: "jar-lib" },
|
|
290
|
+
{ from: file, to: "s3://artifacts/jars/" },
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(out.digest).toBe(expectedDigest);
|
|
294
|
+
expect(out.uri).toBe("s3://artifacts/jars/app.jar"); // trailing-slash `to` appends the basename
|
|
295
|
+
expect(mock.calls).toEqual([
|
|
296
|
+
{ client: "s3", method: "cp", args: { from: file, to: "s3://artifacts/jars/app.jar" } },
|
|
297
|
+
]);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("strips an archive: prefix and uses `to` verbatim when it is a full key", async () => {
|
|
301
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-pa-"));
|
|
302
|
+
const file = join(dir, "asset.bin");
|
|
303
|
+
writeFileSync(file, "X");
|
|
304
|
+
const mock = createMockCloudExecutor();
|
|
305
|
+
|
|
306
|
+
const out = await createPublishArtifactCapability(mock.executor).run(
|
|
307
|
+
{ env: "dev", component: "c" },
|
|
308
|
+
{ from: `archive:${file}`, to: "s3://b/exact-key" },
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
expect(out.uri).toBe("s3://b/exact-key");
|
|
312
|
+
expect(mock.calls[0]!.args).toEqual({ from: file, to: "s3://b/exact-key" });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("declares no rollback", () => {
|
|
316
|
+
expect(createPublishArtifactCapability(createMockCloudExecutor().executor).rollback).toBeUndefined();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* publish family — deploy-time promotion by identity to the env location.
|
|
3
|
+
*
|
|
4
|
+
* "Make the image available at the deploy target" has more than one backend:
|
|
5
|
+
* `publish-image` promotes into a registry (ECR/ACR/Artifact Registry, pulled
|
|
6
|
+
* by the target); `load-image-on-host` copies the tarball straight onto a host
|
|
7
|
+
* and `docker load`s it (registry-less). Both satisfy the same
|
|
8
|
+
* `PublishImageBackend` interface — the backend is env config, not a pipeline
|
|
9
|
+
* fork. `publish-asset`/`publish-artifact` is the non-image sibling (S3 /
|
|
10
|
+
* CodeArtifact) used by producer/library components (e.g. a jar for EMR).
|
|
11
|
+
* See docs/components/build-archive.mdx.
|
|
12
|
+
*
|
|
13
|
+
* `publish-image` is a real implementation (#557, epic #551): it loads the
|
|
14
|
+
* archived image tarball, tags it for the destination registry, logs in via
|
|
15
|
+
* ECR, and pushes — promoting by digest, per the epic's build-once invariant.
|
|
16
|
+
*
|
|
17
|
+
* `load-image-on-host` is a real implementation (#564, epic #551 "4. Build
|
|
18
|
+
* archive + deferred publish"): it copies the archived tarball onto a host
|
|
19
|
+
* and runs `docker load` there via the injected `CloudExecutor.host` — no
|
|
20
|
+
* registry in the path at all. Both backends promote **by digest**, never
|
|
21
|
+
* rebuilding: the bytes they move are exactly what `docker-build` produced
|
|
22
|
+
* into the archive (see ./build-archive.ts). `selectPublishBackend` below is
|
|
23
|
+
* the per-environment choice between them — env config, not a pipeline fork,
|
|
24
|
+
* per docs/components/build-archive.mdx#backend-selection-is-per-environment.
|
|
25
|
+
*
|
|
26
|
+
* `publish-artifact` (aliased `publish-asset`) uploads a single archive
|
|
27
|
+
* artifact (jar/zip/asset) to S3 via `aws s3 cp` through the same
|
|
28
|
+
* endpoint-aware `CloudExecutor`, returning the object URI and a content digest
|
|
29
|
+
* of the uploaded bytes — the non-image sibling of `publish-image`.
|
|
30
|
+
*
|
|
31
|
+
* **#610 addition: publish-time SBOM/BOM referrer attach.** `publish-image`
|
|
32
|
+
* optionally registers the archive's SBOM (and, when given, the
|
|
33
|
+
* component-level aggregate BOM) as an OCI referrer on the just-pushed image
|
|
34
|
+
* digest via `oras attach` — a registry-side convenience for `oras
|
|
35
|
+
* discover`/`cosign tree` layered on top of the archive-carried copy, never a
|
|
36
|
+
* replacement for it (see ../../lifecycle/build-ledger.ts's module doc: the
|
|
37
|
+
* archive remains the universal home, working for every artifact type and
|
|
38
|
+
* the registry-less `load-image-on-host` path, which this attach step
|
|
39
|
+
* deliberately does not touch — non-image/registry-less publishes stay
|
|
40
|
+
* archive-carried only). Attach is opt-in per call (`input.sbom` supplied),
|
|
41
|
+
* guarded on `oras` availability, and never fails the publish itself: a
|
|
42
|
+
* missing `oras` binary or a failed attach is reported back on the output
|
|
43
|
+
* (`referrerAttach.attached: false` + a reason) rather than thrown, since the
|
|
44
|
+
* image is already successfully promoted by the time this step runs — the
|
|
45
|
+
* one part of this capability that is genuinely best-effort.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
49
|
+
import { readFileSync } from "node:fs";
|
|
50
|
+
import { createHash } from "node:crypto";
|
|
51
|
+
import { defaultCloudExecutor, type CloudExecutor } from "./cloud-executor";
|
|
52
|
+
import { archiveRelativePath } from "@intentius/chant/components/verbs/build-archive";
|
|
53
|
+
import { defaultProcessRunner, q, type ProcessRunner } from "@intentius/chant/components/verbs/process-runner";
|
|
54
|
+
|
|
55
|
+
// ── shared backend interface ─────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Common input shape both image-publish backends accept: promote the image
|
|
59
|
+
* bytes at archive path `from` to wherever the deploy target can consume
|
|
60
|
+
* them. `to` (registry) and `host` (bare host) are each meaningful to only
|
|
61
|
+
* one backend, so a component authors one step shape (see
|
|
62
|
+
* docs/components/build-archive.mdx) and the env-selected backend (see
|
|
63
|
+
* `selectPublishBackend` below) reads whichever field it needs — the
|
|
64
|
+
* unselected backend's field is simply unused, never a pipeline fork.
|
|
65
|
+
*/
|
|
66
|
+
export interface PublishImageInput {
|
|
67
|
+
/** Path of the image tarball inside the build archive (as produced by `docker-build`; an `archive:`-prefixed reference is accepted and stripped). */
|
|
68
|
+
from: string;
|
|
69
|
+
/** Destination registry — required by `publish-image`, ignored by `load-image-on-host` (e.g. `$env.registry` resolved by the orchestrator). */
|
|
70
|
+
to?: string;
|
|
71
|
+
/** Target host — required by `load-image-on-host`, ignored by `publish-image` (SSM instance id, hostname, or host group). */
|
|
72
|
+
host?: string;
|
|
73
|
+
/** Destination path for the tarball on the host, used only by `load-image-on-host`. Default: `/tmp/chant-archive/<basename of from>`. */
|
|
74
|
+
hostPath?: string;
|
|
75
|
+
/** Additional tags to apply alongside the digest, used only by `publish-image`. */
|
|
76
|
+
tags?: string[];
|
|
77
|
+
/**
|
|
78
|
+
* #610: the artifact's software SBOM (typically wired from a prior
|
|
79
|
+
* `generate-sbom` step's `sbom` output — see ./sbom.ts's
|
|
80
|
+
* `GenerateSbomOutput`) to attach as an OCI referrer on the pushed image
|
|
81
|
+
* digest, via `oras attach`. Used only by `publish-image`; ignored by
|
|
82
|
+
* `load-image-on-host` (registry-less — nothing to attach to). Omit to
|
|
83
|
+
* skip the attach step entirely (the archive-carried copy remains the
|
|
84
|
+
* SBOM's home either way).
|
|
85
|
+
*/
|
|
86
|
+
sbom?: { bytes: string; mediaType: string };
|
|
87
|
+
/**
|
|
88
|
+
* #610: the component-level aggregate BOM (wired from
|
|
89
|
+
* ./component-bom.ts's `aggregateComponentBom` output), attached as a
|
|
90
|
+
* second OCI referrer alongside `sbom` when supplied. Optional and
|
|
91
|
+
* independent of `sbom` — a caller may attach either, both, or neither.
|
|
92
|
+
*/
|
|
93
|
+
componentBom?: { bytes: string; mediaType: string };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Result of `publish-image`'s best-effort OCI-referrer attach step (#610) — always present when `input.sbom`/`input.componentBom` was supplied, `undefined` when neither was (nothing to attach, attach step skipped entirely). */
|
|
97
|
+
export interface ReferrerAttachResult {
|
|
98
|
+
/** True once every requested attach (`sbom`, `componentBom`) succeeded. `false` if `oras` was unavailable or any `oras attach` invocation failed. */
|
|
99
|
+
attached: boolean;
|
|
100
|
+
/** Human-readable reason `attached` is `false` (e.g. "oras is not installed"), omitted when `attached` is `true`. */
|
|
101
|
+
reason?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface PublishImageOutput {
|
|
105
|
+
/** Content-addressed digest of the promoted image (`sha256:...`) — what the apply step references. */
|
|
106
|
+
digest: string;
|
|
107
|
+
/** Image reference the apply step can pull/run: `registry/repo@sha256:...` for `publish-image`, a host-local reference for `load-image-on-host`. */
|
|
108
|
+
uri: string;
|
|
109
|
+
/** #610: outcome of the best-effort SBOM/component-BOM referrer attach, when `input.sbom`/`input.componentBom` was supplied. `undefined` for `load-image-on-host` (no attach concept — registry-less) and for `publish-image` calls that supplied neither. */
|
|
110
|
+
referrerAttach?: ReferrerAttachResult;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Common shape both image-publish backends satisfy: promote the image bytes
|
|
115
|
+
* held in the build archive to wherever the deploy target can consume them,
|
|
116
|
+
* and return the identity the apply step references. The backend is
|
|
117
|
+
* selected per environment (`selectPublishBackend`), never per component.
|
|
118
|
+
*/
|
|
119
|
+
export type PublishImageBackend = Capability<PublishImageInput, PublishImageOutput>;
|
|
120
|
+
|
|
121
|
+
// ── publish-image (registry backend) ────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Attempt to `oras attach` one referrer document (SBOM or component BOM) to
|
|
125
|
+
* `imageRef`'s digest. Writes the document's bytes to a scratch file (`oras
|
|
126
|
+
* attach` reads from a file path, not stdin) and shells out through
|
|
127
|
+
* `runner`. Never throws — a failure here is reported back to the caller as
|
|
128
|
+
* `{ ok: false, reason }` so a missing/erroring `oras` never fails the
|
|
129
|
+
* publish itself (see `attachReferrers`'s doc comment for why).
|
|
130
|
+
*/
|
|
131
|
+
async function attachOneReferrer(
|
|
132
|
+
runner: ProcessRunner,
|
|
133
|
+
imageRef: string,
|
|
134
|
+
doc: { bytes: string; mediaType: string },
|
|
135
|
+
label: string,
|
|
136
|
+
): Promise<{ ok: boolean; reason?: string }> {
|
|
137
|
+
try {
|
|
138
|
+
const scratchFile = `/tmp/chant-referrer-attach/${label}-${Date.now()}.json`;
|
|
139
|
+
await runner.run(`mkdir -p ${q("/tmp/chant-referrer-attach")}`);
|
|
140
|
+
await runner.run(`printf '%s' ${q(doc.bytes)} > ${q(scratchFile)}`);
|
|
141
|
+
await runner.run(`oras attach --artifact-type ${q(doc.mediaType)} ${q(imageRef)} ${q(scratchFile)}:${q(doc.mediaType)}`);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Best-effort OCI-referrer attach for a just-pushed image (#610): attaches
|
|
150
|
+
* `input.sbom` and/or `input.componentBom`, when supplied, to `imageRef` via
|
|
151
|
+
* `oras attach`. Guarded on `oras` availability — a missing binary is
|
|
152
|
+
* reported back as `{ attached: false, reason }` rather than thrown, since
|
|
153
|
+
* the image itself is already successfully promoted by the time this step
|
|
154
|
+
* runs (attach is additive registry-side convenience on top of the
|
|
155
|
+
* archive-carried copy, per this module's doc comment, never a condition the
|
|
156
|
+
* core promote-by-digest job depends on). Returns `undefined` when neither
|
|
157
|
+
* `input.sbom` nor `input.componentBom` was supplied — nothing to attach,
|
|
158
|
+
* skipped entirely, no `oras` call at all.
|
|
159
|
+
*/
|
|
160
|
+
async function attachReferrers(
|
|
161
|
+
runner: ProcessRunner,
|
|
162
|
+
imageRef: string,
|
|
163
|
+
input: Pick<PublishImageInput, "sbom" | "componentBom">,
|
|
164
|
+
): Promise<ReferrerAttachResult | undefined> {
|
|
165
|
+
if (!input.sbom && !input.componentBom) return undefined;
|
|
166
|
+
|
|
167
|
+
if (!(await runner.available("oras"))) {
|
|
168
|
+
return { attached: false, reason: `"oras" is not installed or not on PATH — required to attach OCI referrers` };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const results: Array<{ ok: boolean; reason?: string }> = [];
|
|
172
|
+
if (input.sbom) results.push(await attachOneReferrer(runner, imageRef, input.sbom, "sbom"));
|
|
173
|
+
if (input.componentBom) results.push(await attachOneReferrer(runner, imageRef, input.componentBom, "component-bom"));
|
|
174
|
+
|
|
175
|
+
const failed = results.find((r) => !r.ok);
|
|
176
|
+
return failed ? { attached: false, reason: failed.reason } : { attached: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Promote a built image from the archive into the environment's container
|
|
181
|
+
* registry: `docker load` the archived tarball, tag it for `to` (the env
|
|
182
|
+
* registry), `aws ecr get-login-password | docker login`, then `docker push`.
|
|
183
|
+
* Returns the pushed image's registry digest — what `cfn-deploy`/
|
|
184
|
+
* `ecs-update-service` reference via `imageRef: "@Publish.digest"`. No
|
|
185
|
+
* rollback: an already-pushed, still-valid image in the registry is not
|
|
186
|
+
* itself a problem to compensate (immutable, content-addressed, and simply
|
|
187
|
+
* unreferenced if a later step fails) — the opt-out this capability takes.
|
|
188
|
+
*
|
|
189
|
+
* #610: when `input.sbom` and/or `input.componentBom` are supplied, also
|
|
190
|
+
* attaches them as OCI referrers on the pushed digest via `oras attach` (see
|
|
191
|
+
* `attachReferrers` above) — best-effort, guarded on `oras` availability,
|
|
192
|
+
* surfaced on the output's `referrerAttach` rather than failing the publish.
|
|
193
|
+
*/
|
|
194
|
+
export function createPublishImageCapability(
|
|
195
|
+
executor: CloudExecutor = defaultCloudExecutor(),
|
|
196
|
+
processRunner: ProcessRunner = defaultProcessRunner(),
|
|
197
|
+
): PublishImageBackend {
|
|
198
|
+
return {
|
|
199
|
+
kind: "publish-image",
|
|
200
|
+
async run(_ctx, input) {
|
|
201
|
+
if (!input.to) throw new Error(`publish-image "${input.from}": "to" (destination registry) is required`);
|
|
202
|
+
// `docker load` reports the archived image's local reference — a name:tag
|
|
203
|
+
// for a tagged build, or an image id. It's a valid `docker tag` source but
|
|
204
|
+
// never a registry digest, so the promoted digest is read back from the
|
|
205
|
+
// push below, not constructed from this.
|
|
206
|
+
const { digest: localRef } = await executor.docker.load({ inFile: archiveRelativePath(input.from) });
|
|
207
|
+
const repo = input.to.replace(/\/+$/, "");
|
|
208
|
+
// Publish under a tag: docker can neither tag nor push a digest-qualified
|
|
209
|
+
// reference (`repo@sha256:...`), so the transport is a tag and the stable
|
|
210
|
+
// identifier is the digest the push returns.
|
|
211
|
+
const pushRef = `${repo}:latest`;
|
|
212
|
+
await executor.docker.tag({ source: localRef, target: pushRef });
|
|
213
|
+
const registry = repo.split("/")[0]!;
|
|
214
|
+
await executor.ecr.login(registry);
|
|
215
|
+
const { digest: pushed } = await executor.docker.push({ image: pushRef });
|
|
216
|
+
// Normalize to a bare `sha256:...`, whether the client returned that or a
|
|
217
|
+
// full `repo@sha256:...` RepoDigest.
|
|
218
|
+
const digest = pushed.includes("@") ? pushed.slice(pushed.indexOf("@") + 1) : pushed;
|
|
219
|
+
for (const tag of input.tags ?? []) {
|
|
220
|
+
const tagged = `${repo}:${tag}`;
|
|
221
|
+
await executor.docker.tag({ source: localRef, target: tagged });
|
|
222
|
+
await executor.docker.push({ image: tagged });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const referrerAttach = await attachReferrers(processRunner, `${repo}@${digest}`, input);
|
|
226
|
+
return { digest, uri: `${repo}@${digest}`, ...(referrerAttach ? { referrerAttach } : {}) };
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Default `publish-image` capability, backed by the real `CloudExecutor`. */
|
|
232
|
+
export const publishImageCapability: PublishImageBackend = createPublishImageCapability();
|
|
233
|
+
|
|
234
|
+
// ── load-image-on-host (host backend) ───────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/** Default on-host tarball destination when `hostPath` is omitted, derived from the archive path's basename. */
|
|
237
|
+
function defaultHostPath(from: string): string {
|
|
238
|
+
const base = archiveRelativePath(from).split("/").pop() ?? "image.tar";
|
|
239
|
+
return `/tmp/chant-archive/${base}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Copy the image tarball straight onto a host and `docker load` it there —
|
|
244
|
+
* genuinely registry-free promotion (#564): no `docker.push`, no ECR login,
|
|
245
|
+
* no registry ever in the path. Still promotes **by digest**: the tarball
|
|
246
|
+
* copied is the exact archive artifact `docker-build` produced, so the image
|
|
247
|
+
* loaded on the host is byte-identical to the one tested in any other
|
|
248
|
+
* environment, satisfying the same build-once invariant `publish-image`
|
|
249
|
+
* does. No rollback, for the same reason `publish-image` declares none: an
|
|
250
|
+
* already-loaded, content-addressed image sitting in a host's local Docker
|
|
251
|
+
* store is not itself a problem to compensate.
|
|
252
|
+
*
|
|
253
|
+
* See docs/components/build-archive.mdx#registry-less-caveat: this makes
|
|
254
|
+
* *your built* image registry-free. Third-party images a compose file
|
|
255
|
+
* references (`postgres:16`, `redis`) still pull from their upstream
|
|
256
|
+
* registry at `compose up` unless they are archived and loaded the same way.
|
|
257
|
+
*/
|
|
258
|
+
export function createLoadImageOnHostCapability(
|
|
259
|
+
executor: CloudExecutor = defaultCloudExecutor(),
|
|
260
|
+
): PublishImageBackend {
|
|
261
|
+
return {
|
|
262
|
+
kind: "load-image-on-host",
|
|
263
|
+
async run(_ctx, input) {
|
|
264
|
+
if (!input.host) throw new Error(`load-image-on-host "${input.from}": "host" is required`);
|
|
265
|
+
const from = archiveRelativePath(input.from);
|
|
266
|
+
const to = input.hostPath ?? defaultHostPath(input.from);
|
|
267
|
+
await executor.host.copyFile({ host: input.host, from, to });
|
|
268
|
+
const { digest } = await executor.host.dockerLoad({ host: input.host, path: to });
|
|
269
|
+
return { digest, uri: `host:${input.host}#${digest}` };
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Default `load-image-on-host` capability, backed by the real `CloudExecutor`. */
|
|
275
|
+
export const loadImageOnHostCapability: PublishImageBackend = createLoadImageOnHostCapability();
|
|
276
|
+
|
|
277
|
+
// ── per-environment backend selection ───────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
/** The `kind` of either image-publish backend — what an environment's config declares as its choice. */
|
|
280
|
+
export type PublishImageBackendKind = "publish-image" | "load-image-on-host";
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Resolve which publish backend an environment uses, given its declared
|
|
284
|
+
* `kind` (`$env.publish.kind` in the component's env config — see
|
|
285
|
+
* docs/components/build-archive.mdx#backend-selection-is-per-environment).
|
|
286
|
+
* The decision is per environment, not per component: the same component's
|
|
287
|
+
* `Publish` phase runs `publish-image` against dev's ECR and
|
|
288
|
+
* `load-image-on-host` against a locked-down prod host, with no change to
|
|
289
|
+
* the component's own composition — only the env config `kind` differs.
|
|
290
|
+
*
|
|
291
|
+
* Accepts an optional registry of backends so a caller can extend the set
|
|
292
|
+
* (e.g. a third-party plugin registering another `PublishImageBackend`)
|
|
293
|
+
* without this function needing to change; defaults to the two starter-set
|
|
294
|
+
* backends built above.
|
|
295
|
+
*/
|
|
296
|
+
export function selectPublishBackend(
|
|
297
|
+
kind: PublishImageBackendKind,
|
|
298
|
+
backends: Partial<Record<PublishImageBackendKind, PublishImageBackend>> = {
|
|
299
|
+
"publish-image": publishImageCapability,
|
|
300
|
+
"load-image-on-host": loadImageOnHostCapability,
|
|
301
|
+
},
|
|
302
|
+
): PublishImageBackend {
|
|
303
|
+
const backend = backends[kind];
|
|
304
|
+
if (!backend) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`no publish backend registered for kind "${kind}" (known: ${Object.keys(backends).sort().join(", ")})`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return backend;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── publish-asset / publish-artifact ────────────────────────────────────────
|
|
313
|
+
|
|
314
|
+
export interface PublishArtifactInput {
|
|
315
|
+
/** Path of the artifact inside the build archive (e.g. a jar or zip). */
|
|
316
|
+
from: string;
|
|
317
|
+
/** Destination (e.g. `$env.s3` resolved by the orchestrator). */
|
|
318
|
+
to: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export interface PublishArtifactOutput {
|
|
322
|
+
/** Location the artifact was published to — referenced downstream as `@<component>.publish.uri`. */
|
|
323
|
+
uri: string;
|
|
324
|
+
/** Content hash of the published artifact. */
|
|
325
|
+
digest: string;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Promote a non-image artifact (jar, zip, arbitrary asset) from the archive to S3/CodeArtifact. */
|
|
329
|
+
/**
|
|
330
|
+
* Publish a single archive artifact (a jar, zip, or asset) to S3 via `aws s3 cp`
|
|
331
|
+
* (endpoint-aware through the `CloudExecutor`). Returns the object's URI —
|
|
332
|
+
* referenced downstream as `@<component>.publish.uri` — and a content digest of
|
|
333
|
+
* the exact bytes uploaded.
|
|
334
|
+
*/
|
|
335
|
+
export function createPublishArtifactCapability(
|
|
336
|
+
executor: CloudExecutor = defaultCloudExecutor(),
|
|
337
|
+
): Capability<PublishArtifactInput, PublishArtifactOutput> {
|
|
338
|
+
return {
|
|
339
|
+
kind: "publish-artifact",
|
|
340
|
+
async run(_ctx, input) {
|
|
341
|
+
const localPath = archiveRelativePath(input.from);
|
|
342
|
+
const digest = `sha256:${createHash("sha256").update(readFileSync(localPath)).digest("hex")}`;
|
|
343
|
+
const basename = localPath.split("/").pop() ?? localPath;
|
|
344
|
+
const uri = input.to.endsWith("/") ? `${input.to}${basename}` : input.to;
|
|
345
|
+
await executor.s3.cp({ from: localPath, to: uri });
|
|
346
|
+
return { uri, digest };
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Default `publish-artifact` capability, backed by the real `CloudExecutor`. */
|
|
352
|
+
export const publishArtifactCapability: Capability<PublishArtifactInput, PublishArtifactOutput> =
|
|
353
|
+
createPublishArtifactCapability();
|
|
354
|
+
|
|
355
|
+
/** Alias for `publish-artifact` — same capability, the docs/epic use both names for the same verb. */
|
|
356
|
+
export const publishAssetCapability = publishArtifactCapability;
|