@crewhaus/docker-images 0.1.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/package.json +41 -0
- package/src/index.test.ts +254 -0
- package/src/index.ts +256 -0
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/docker-images",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Per-target-shape Docker images (multi-stage, non-root, healthchecked) + crewhaus build-image CLI wrapper (Section 32)",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/errors": "0.0.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Max Meier",
|
|
20
|
+
"email": "max@studiomax.io",
|
|
21
|
+
"url": "https://studiomax.io"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
26
|
+
"directory": "packages/docker-images"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/docker-images#readme",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "restricted"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"NOTICE"
|
|
40
|
+
]
|
|
41
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type BuildRunner,
|
|
8
|
+
DockerImagesError,
|
|
9
|
+
TARGET_SHAPES,
|
|
10
|
+
buildImage,
|
|
11
|
+
dockerRoot,
|
|
12
|
+
dockerfilePath,
|
|
13
|
+
dockerfileSize,
|
|
14
|
+
fingerprintDockerfile,
|
|
15
|
+
isTargetShape,
|
|
16
|
+
listAvailableTargets,
|
|
17
|
+
loadDigests,
|
|
18
|
+
readDockerfile,
|
|
19
|
+
recordDigest,
|
|
20
|
+
} from "./index";
|
|
21
|
+
|
|
22
|
+
describe("TARGET_SHAPES", () => {
|
|
23
|
+
test("contains exactly the 12 shipped target shapes", () => {
|
|
24
|
+
expect(TARGET_SHAPES).toEqual([
|
|
25
|
+
"cli",
|
|
26
|
+
"workflow",
|
|
27
|
+
"channel",
|
|
28
|
+
"graph",
|
|
29
|
+
"managed",
|
|
30
|
+
"pipeline",
|
|
31
|
+
"crew",
|
|
32
|
+
"research",
|
|
33
|
+
"batch",
|
|
34
|
+
"voice",
|
|
35
|
+
"browser",
|
|
36
|
+
"eval",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("isTargetShape accepts every shape and rejects unknowns", () => {
|
|
41
|
+
for (const t of TARGET_SHAPES) {
|
|
42
|
+
expect(isTargetShape(t)).toBe(true);
|
|
43
|
+
}
|
|
44
|
+
expect(isTargetShape("realtime")).toBe(false);
|
|
45
|
+
expect(isTargetShape("")).toBe(false);
|
|
46
|
+
expect(isTargetShape(42)).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("Dockerfiles on disk", () => {
|
|
51
|
+
test("every shape has a Dockerfile under docker/<target>/Dockerfile", () => {
|
|
52
|
+
for (const t of TARGET_SHAPES) {
|
|
53
|
+
expect(dockerfileSize(t)).toBeGreaterThan(100);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("listAvailableTargets returns all 12 shapes", () => {
|
|
58
|
+
expect(listAvailableTargets()).toEqual(TARGET_SHAPES);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("dockerRoot points inside the package", () => {
|
|
62
|
+
expect(dockerRoot()).toMatch(/packages\/docker-images\/docker$/);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("Dockerfile structural contract (T2)", () => {
|
|
67
|
+
for (const target of TARGET_SHAPES) {
|
|
68
|
+
test(`${target} → multi-stage, non-root, healthchecked, has bun runtime`, () => {
|
|
69
|
+
const text = readDockerfile(target);
|
|
70
|
+
const fp = fingerprintDockerfile(text);
|
|
71
|
+
expect(fp.stages.length).toBeGreaterThanOrEqual(2);
|
|
72
|
+
expect(fp.hasHealthcheck).toBe(true);
|
|
73
|
+
expect(fp.nonRootUser).toBe("crewhaus");
|
|
74
|
+
expect(fp.workdir).toBe("/app");
|
|
75
|
+
expect(fp.envs).toContain("CREWHAUS_TARGET");
|
|
76
|
+
expect(fp.entrypoint?.includes("bun")).toBe(true);
|
|
77
|
+
// multi-stage: at least one named stage (deps/build/runtime)
|
|
78
|
+
expect(fp.stages.some((s) => s.stage === "deps" || s.stage === "build")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
test("daemon shapes (channel/managed/crew/voice/browser) expose a port and curl healthcheck", () => {
|
|
83
|
+
const daemonShapes = ["channel", "managed", "crew", "voice", "browser"] as const;
|
|
84
|
+
for (const t of daemonShapes) {
|
|
85
|
+
const text = readDockerfile(t);
|
|
86
|
+
const fp = fingerprintDockerfile(text);
|
|
87
|
+
expect(fp.exposedPorts.length).toBeGreaterThan(0);
|
|
88
|
+
expect(text).toContain("curl");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("voice image installs libopus", () => {
|
|
93
|
+
expect(readDockerfile("voice")).toContain("libopus");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("browser image installs chromium and Playwright env vars", () => {
|
|
97
|
+
const text = readDockerfile("browser");
|
|
98
|
+
expect(text).toContain("chromium");
|
|
99
|
+
expect(text).toContain("PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("buildImage()", () => {
|
|
104
|
+
test("rejects unknown targets", async () => {
|
|
105
|
+
await expect(buildImage({ target: "unknown" as never, tag: "smoke" })).rejects.toThrow(
|
|
106
|
+
DockerImagesError,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("rejects empty/whitespace tags", async () => {
|
|
111
|
+
await expect(buildImage({ target: "cli", tag: "" })).rejects.toThrow(/invalid tag/);
|
|
112
|
+
await expect(buildImage({ target: "cli", tag: "bad tag" })).rejects.toThrow(/invalid tag/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("happy path emits the documented argv shape", async () => {
|
|
116
|
+
const captured: string[][] = [];
|
|
117
|
+
const runner: BuildRunner = async (argv) => {
|
|
118
|
+
captured.push([...argv]);
|
|
119
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
120
|
+
};
|
|
121
|
+
const result = await buildImage({
|
|
122
|
+
target: "cli",
|
|
123
|
+
tag: "smoke",
|
|
124
|
+
runner,
|
|
125
|
+
});
|
|
126
|
+
expect(result.tag).toBe("smoke");
|
|
127
|
+
expect(result.target).toBe("cli");
|
|
128
|
+
expect(captured.length).toBe(1);
|
|
129
|
+
const argv = captured[0] ?? [];
|
|
130
|
+
expect(argv[0]).toBe("docker");
|
|
131
|
+
expect(argv).toContain("buildx");
|
|
132
|
+
expect(argv).toContain("build");
|
|
133
|
+
expect(argv).toContain("--tag");
|
|
134
|
+
expect(argv).toContain("crewhaus/cli:smoke");
|
|
135
|
+
expect(argv).toContain("--load");
|
|
136
|
+
expect(argv.includes("--push")).toBe(false);
|
|
137
|
+
expect(argv).toContain(dockerfilePath("cli"));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("--push instead of --load when push:true", async () => {
|
|
141
|
+
let argv: readonly string[] = [];
|
|
142
|
+
await buildImage({
|
|
143
|
+
target: "channel",
|
|
144
|
+
tag: "v1",
|
|
145
|
+
push: true,
|
|
146
|
+
runner: async (a) => {
|
|
147
|
+
argv = a;
|
|
148
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
expect(argv).toContain("--push");
|
|
152
|
+
expect(argv.includes("--load")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("non-zero exit propagates as DockerImagesError with stderr captured", async () => {
|
|
156
|
+
const runner: BuildRunner = async () => ({
|
|
157
|
+
exitCode: 1,
|
|
158
|
+
stdout: "",
|
|
159
|
+
stderr: "no docker daemon",
|
|
160
|
+
});
|
|
161
|
+
await expect(buildImage({ target: "cli", tag: "smoke", runner })).rejects.toThrow(
|
|
162
|
+
/no docker daemon/,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("platform passes through to argv", async () => {
|
|
167
|
+
let argv: readonly string[] = [];
|
|
168
|
+
await buildImage({
|
|
169
|
+
target: "graph",
|
|
170
|
+
tag: "multi",
|
|
171
|
+
platform: "linux/amd64,linux/arm64",
|
|
172
|
+
runner: async (a) => {
|
|
173
|
+
argv = a;
|
|
174
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
expect(argv).toContain("--platform");
|
|
178
|
+
expect(argv).toContain("linux/amd64,linux/arm64");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("digests lockfile", () => {
|
|
183
|
+
test("loadDigests returns _format_version: 1 when file missing", () => {
|
|
184
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
|
|
185
|
+
const path = join(dir, "missing.json");
|
|
186
|
+
expect(loadDigests(path)).toEqual({ _format_version: 1 });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("recordDigest writes and round-trips", () => {
|
|
190
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
|
|
191
|
+
const path = join(dir, "digests.json");
|
|
192
|
+
const sha = `sha256:${"a".repeat(64)}`;
|
|
193
|
+
recordDigest("cli", "smoke", sha, path);
|
|
194
|
+
const loaded = loadDigests(path) as { cli?: Record<string, { sha256: string }> };
|
|
195
|
+
expect(loaded.cli?.["smoke"]?.sha256).toBe(sha);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("recordDigest rejects malformed digests", () => {
|
|
199
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
|
|
200
|
+
const path = join(dir, "digests.json");
|
|
201
|
+
expect(() => recordDigest("cli", "tag", "not-a-digest", path)).toThrow(/invalid sha256/);
|
|
202
|
+
expect(() => recordDigest("cli", "tag", "sha256:short", path)).toThrow();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("loadDigests propagates JSON parse errors as DockerImagesError", () => {
|
|
206
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
|
|
207
|
+
const path = join(dir, "bad.json");
|
|
208
|
+
writeFileSync(path, "not json");
|
|
209
|
+
expect(() => loadDigests(path)).toThrow(DockerImagesError);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("recordDigest preserves prior entries across multiple records", () => {
|
|
213
|
+
const dir = mkdtempSync(join(tmpdir(), "crewhaus-digests-"));
|
|
214
|
+
const path = join(dir, "digests.json");
|
|
215
|
+
const shaA = `sha256:${"a".repeat(64)}`;
|
|
216
|
+
const shaB = `sha256:${"b".repeat(64)}`;
|
|
217
|
+
recordDigest("cli", "v1", shaA, path);
|
|
218
|
+
recordDigest("channel", "v1", shaB, path);
|
|
219
|
+
const final = JSON.parse(readFileSync(path, "utf8"));
|
|
220
|
+
expect(final.cli.v1.sha256).toBe(shaA);
|
|
221
|
+
expect(final.channel.v1.sha256).toBe(shaB);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
describe("fingerprintDockerfile (T1 unit)", () => {
|
|
226
|
+
test("parses a minimal multi-stage Dockerfile", () => {
|
|
227
|
+
const text = `FROM alpine AS base
|
|
228
|
+
FROM base AS runtime
|
|
229
|
+
WORKDIR /work
|
|
230
|
+
ENV FOO=bar BAZ=qux
|
|
231
|
+
USER nobody
|
|
232
|
+
EXPOSE 9000
|
|
233
|
+
HEALTHCHECK CMD echo
|
|
234
|
+
ENTRYPOINT ["bun", "x"]
|
|
235
|
+
CMD ["--help"]
|
|
236
|
+
`;
|
|
237
|
+
const fp = fingerprintDockerfile(text);
|
|
238
|
+
expect(fp.stages.map((s) => s.stage)).toEqual(["base", "runtime"]);
|
|
239
|
+
expect(fp.hasHealthcheck).toBe(true);
|
|
240
|
+
expect(fp.nonRootUser).toBe("nobody");
|
|
241
|
+
expect(fp.workdir).toBe("/work");
|
|
242
|
+
expect(fp.exposedPorts).toEqual([9000]);
|
|
243
|
+
expect([...fp.envs].sort()).toEqual(["BAZ", "FOO"]);
|
|
244
|
+
expect(fp.entrypoint).toContain("bun");
|
|
245
|
+
expect(fp.cmd).toContain("--help");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("absent fields surface as undefined / empty", () => {
|
|
249
|
+
const fp = fingerprintDockerfile("FROM alpine\n");
|
|
250
|
+
expect(fp.hasHealthcheck).toBe(false);
|
|
251
|
+
expect(fp.nonRootUser).toBeUndefined();
|
|
252
|
+
expect(fp.exposedPorts).toEqual([]);
|
|
253
|
+
});
|
|
254
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Section 32 — `@crewhaus/docker-images`
|
|
6
|
+
*
|
|
7
|
+
* Per-target-shape Dockerfiles (one per target shape: cli, workflow, channel,
|
|
8
|
+
* graph, managed, pipeline, crew, research, batch, voice, browser, eval) plus
|
|
9
|
+
* a `buildImage()` wrapper around `docker buildx build` that the
|
|
10
|
+
* `crewhaus build-image <target>` CLI subcommand (apps/cli/src/index.ts) calls.
|
|
11
|
+
*
|
|
12
|
+
* v0 ships:
|
|
13
|
+
* - 12 multi-stage Dockerfiles under `docker/<target>/Dockerfile`,
|
|
14
|
+
* each with non-root user, read-only root, HEALTHCHECK, and
|
|
15
|
+
* target-specific system deps baked in (chromium for browser,
|
|
16
|
+
* libopus for voice, curl+ca-certs for daemon shapes).
|
|
17
|
+
* - `buildImage()` shells to `docker buildx build` (gated on `docker
|
|
18
|
+
* version` succeeding); injectable `runner` for tests.
|
|
19
|
+
* - `loadDigests()` / `recordDigest()` over a `docker/digests.json`
|
|
20
|
+
* lockfile so reproducible builds work offline.
|
|
21
|
+
*/
|
|
22
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
23
|
+
|
|
24
|
+
export class DockerImagesError extends CrewhausError {
|
|
25
|
+
override readonly name = "DockerImagesError";
|
|
26
|
+
constructor(message: string, cause?: unknown) {
|
|
27
|
+
super("config", message, cause);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The 12 target shapes that ship a Dockerfile. Sourced from
|
|
33
|
+
* `packages/spec/src/index.ts` discriminator literals; we keep the
|
|
34
|
+
* canonical list locally because this package owns the "which shapes
|
|
35
|
+
* have a deployable image" question.
|
|
36
|
+
*/
|
|
37
|
+
export const TARGET_SHAPES = [
|
|
38
|
+
"cli",
|
|
39
|
+
"workflow",
|
|
40
|
+
"channel",
|
|
41
|
+
"graph",
|
|
42
|
+
"managed",
|
|
43
|
+
"pipeline",
|
|
44
|
+
"crew",
|
|
45
|
+
"research",
|
|
46
|
+
"batch",
|
|
47
|
+
"voice",
|
|
48
|
+
"browser",
|
|
49
|
+
"eval",
|
|
50
|
+
] as const;
|
|
51
|
+
|
|
52
|
+
export type TargetShape = (typeof TARGET_SHAPES)[number];
|
|
53
|
+
|
|
54
|
+
export function isTargetShape(value: unknown): value is TargetShape {
|
|
55
|
+
return typeof value === "string" && (TARGET_SHAPES as readonly string[]).includes(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
59
|
+
const DOCKER_ROOT = join(PACKAGE_ROOT, "docker");
|
|
60
|
+
const DIGESTS_PATH = join(DOCKER_ROOT, "digests.json");
|
|
61
|
+
|
|
62
|
+
export function dockerRoot(): string {
|
|
63
|
+
return DOCKER_ROOT;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function digestsPath(): string {
|
|
67
|
+
return DIGESTS_PATH;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function dockerfilePath(target: TargetShape): string {
|
|
71
|
+
return join(DOCKER_ROOT, target, "Dockerfile");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function readDockerfile(target: TargetShape): string {
|
|
75
|
+
const path = dockerfilePath(target);
|
|
76
|
+
if (!existsSync(path)) {
|
|
77
|
+
throw new DockerImagesError(`dockerfile missing for target ${target} (expected at ${path})`);
|
|
78
|
+
}
|
|
79
|
+
return readFileSync(path, "utf8");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function dockerfileSize(target: TargetShape): number {
|
|
83
|
+
return statSync(dockerfilePath(target)).size;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function listAvailableTargets(): readonly TargetShape[] {
|
|
87
|
+
return TARGET_SHAPES.filter((t) => existsSync(dockerfilePath(t)));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type BuildRunner = (
|
|
91
|
+
argv: readonly string[],
|
|
92
|
+
cwd: string,
|
|
93
|
+
) => Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
94
|
+
|
|
95
|
+
export type BuildImageOptions = {
|
|
96
|
+
readonly target: TargetShape;
|
|
97
|
+
readonly tag: string;
|
|
98
|
+
/** Optional build context path; defaults to the workspace root. */
|
|
99
|
+
readonly contextPath?: string;
|
|
100
|
+
/** Optional buildx platform string, e.g. `linux/amd64,linux/arm64`. */
|
|
101
|
+
readonly platform?: string;
|
|
102
|
+
/** Whether to push to a registry after build. Defaults to false. */
|
|
103
|
+
readonly push?: boolean;
|
|
104
|
+
/** Test injection point — defaults to a node:child_process spawn of `docker`. */
|
|
105
|
+
readonly runner?: BuildRunner;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export type BuildImageResult = {
|
|
109
|
+
readonly tag: string;
|
|
110
|
+
readonly target: TargetShape;
|
|
111
|
+
readonly buildArgv: readonly string[];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export async function buildImage(opts: BuildImageOptions): Promise<BuildImageResult> {
|
|
115
|
+
if (!isTargetShape(opts.target)) {
|
|
116
|
+
throw new DockerImagesError(`unknown target shape: ${String(opts.target)}`);
|
|
117
|
+
}
|
|
118
|
+
if (!opts.tag || /[\s\n\r]/.test(opts.tag)) {
|
|
119
|
+
throw new DockerImagesError(
|
|
120
|
+
`invalid tag: ${JSON.stringify(opts.tag)} — must be non-empty and contain no whitespace`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const dockerfile = dockerfilePath(opts.target);
|
|
124
|
+
if (!existsSync(dockerfile)) {
|
|
125
|
+
throw new DockerImagesError(
|
|
126
|
+
`dockerfile missing for target ${opts.target} (expected at ${dockerfile})`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const contextPath = opts.contextPath ?? resolve(PACKAGE_ROOT, "..", "..");
|
|
130
|
+
const argv: string[] = [
|
|
131
|
+
"docker",
|
|
132
|
+
"buildx",
|
|
133
|
+
"build",
|
|
134
|
+
"--file",
|
|
135
|
+
dockerfile,
|
|
136
|
+
"--tag",
|
|
137
|
+
`crewhaus/${opts.target}:${opts.tag}`,
|
|
138
|
+
];
|
|
139
|
+
if (opts.platform) {
|
|
140
|
+
argv.push("--platform", opts.platform);
|
|
141
|
+
}
|
|
142
|
+
argv.push(opts.push ? "--push" : "--load");
|
|
143
|
+
argv.push(contextPath);
|
|
144
|
+
|
|
145
|
+
const runner = opts.runner ?? defaultRunner;
|
|
146
|
+
const { exitCode, stderr } = await runner(argv, contextPath);
|
|
147
|
+
if (exitCode !== 0) {
|
|
148
|
+
throw new DockerImagesError(
|
|
149
|
+
`docker buildx build exited with code ${exitCode}: ${stderr.slice(0, 1024)}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return { tag: opts.tag, target: opts.target, buildArgv: argv };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const defaultRunner: BuildRunner = async (argv, cwd) => {
|
|
156
|
+
const { spawn } = await import("node:child_process");
|
|
157
|
+
return new Promise((resolve_) => {
|
|
158
|
+
const head = argv[0] ?? "docker";
|
|
159
|
+
const child = spawn(head, argv.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
160
|
+
const out: Buffer[] = [];
|
|
161
|
+
const err: Buffer[] = [];
|
|
162
|
+
child.stdout.on("data", (b) => out.push(b));
|
|
163
|
+
child.stderr.on("data", (b) => err.push(b));
|
|
164
|
+
child.on("error", (e) =>
|
|
165
|
+
resolve_({ exitCode: 1, stdout: "", stderr: String((e as Error).message) }),
|
|
166
|
+
);
|
|
167
|
+
child.on("close", (code) =>
|
|
168
|
+
resolve_({
|
|
169
|
+
exitCode: code ?? 1,
|
|
170
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
171
|
+
stderr: Buffer.concat(err).toString("utf8"),
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ─── digests lockfile ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export type DigestEntry = { readonly sha256: string; readonly builtAt: string };
|
|
180
|
+
export type DigestsFile = {
|
|
181
|
+
_format_version: number;
|
|
182
|
+
[target: string]: unknown;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
export function loadDigests(path: string = DIGESTS_PATH): DigestsFile {
|
|
186
|
+
if (!existsSync(path)) {
|
|
187
|
+
return { _format_version: 1 };
|
|
188
|
+
}
|
|
189
|
+
const raw = readFileSync(path, "utf8");
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(raw) as DigestsFile;
|
|
192
|
+
} catch (cause) {
|
|
193
|
+
throw new DockerImagesError(`failed to parse digests.json at ${path}`, cause);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function recordDigest(
|
|
198
|
+
target: TargetShape,
|
|
199
|
+
tag: string,
|
|
200
|
+
sha256: string,
|
|
201
|
+
path: string = DIGESTS_PATH,
|
|
202
|
+
): void {
|
|
203
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(sha256)) {
|
|
204
|
+
throw new DockerImagesError(`invalid sha256: ${sha256}`);
|
|
205
|
+
}
|
|
206
|
+
const current = loadDigests(path);
|
|
207
|
+
const targetEntry = (current[target] as Record<string, DigestEntry> | undefined) ?? {};
|
|
208
|
+
targetEntry[tag] = { sha256, builtAt: new Date().toISOString() };
|
|
209
|
+
current[target] = targetEntry;
|
|
210
|
+
current._format_version = 1;
|
|
211
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
212
|
+
writeFileSync(path, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o644 });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Dockerfile structural fingerprint (consumed by tests) ───────────────────
|
|
216
|
+
|
|
217
|
+
export type DockerfileFingerprint = {
|
|
218
|
+
readonly stages: ReadonlyArray<{ readonly image: string; readonly stage?: string }>;
|
|
219
|
+
readonly hasHealthcheck: boolean;
|
|
220
|
+
readonly nonRootUser?: string;
|
|
221
|
+
readonly workdir?: string;
|
|
222
|
+
readonly entrypoint?: string;
|
|
223
|
+
readonly cmd?: string;
|
|
224
|
+
readonly exposedPorts: readonly number[];
|
|
225
|
+
readonly envs: readonly string[];
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export function fingerprintDockerfile(text: string): DockerfileFingerprint {
|
|
229
|
+
const lines = text.split(/\r?\n/);
|
|
230
|
+
const fromStages = lines
|
|
231
|
+
.map((l) => /^FROM\s+(\S+)(?:\s+AS\s+(\S+))?/i.exec(l.trim()))
|
|
232
|
+
.filter((m): m is RegExpExecArray => Boolean(m))
|
|
233
|
+
.map((m) => ({ image: m[1] ?? "", stage: (m[2] ?? "").toLowerCase() || undefined }));
|
|
234
|
+
const exposedPorts = lines
|
|
235
|
+
.filter((l) => /^EXPOSE\s+\d+/i.test(l.trim()))
|
|
236
|
+
.map((l) => Number(l.trim().split(/\s+/)[1]));
|
|
237
|
+
return {
|
|
238
|
+
stages: fromStages,
|
|
239
|
+
hasHealthcheck: /^HEALTHCHECK\s+/im.test(text),
|
|
240
|
+
nonRootUser: /^USER\s+(\S+)/im.exec(text)?.[1],
|
|
241
|
+
workdir: /^WORKDIR\s+(\S+)/im.exec(text)?.[1],
|
|
242
|
+
entrypoint: /^ENTRYPOINT\s+(.+)$/im.exec(text)?.[1]?.trim(),
|
|
243
|
+
cmd: /^CMD\s+(.+)$/im.exec(text)?.[1]?.trim(),
|
|
244
|
+
exposedPorts,
|
|
245
|
+
envs: lines
|
|
246
|
+
.filter((l) => /^ENV\s+/i.test(l.trim()))
|
|
247
|
+
.flatMap((l) =>
|
|
248
|
+
l
|
|
249
|
+
.trim()
|
|
250
|
+
.replace(/^ENV\s+/i, "")
|
|
251
|
+
.split(/\s+/)
|
|
252
|
+
.map((kv) => (kv.split("=")[0] ?? "").trim())
|
|
253
|
+
.filter((k) => k.length > 0 && /^[A-Z_][A-Z0-9_]*$/.test(k)),
|
|
254
|
+
),
|
|
255
|
+
};
|
|
256
|
+
}
|