@specific.dev/spectest 0.131.12 → 0.131.14
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/android.js +2 -1
- package/dist/components/aws.d.ts +1 -1
- package/dist/components/aws.js +2 -1
- package/dist/components/electron.js +2 -1
- package/dist/components/email.js +2 -1
- package/dist/components/emulate/service.js +2 -1
- package/dist/components/expo.js +4 -3
- package/dist/components/k3s.js +5 -4
- package/dist/components/postgres.js +3 -2
- package/dist/components/supabase-pooler.js +2 -1
- package/dist/components/supabase.js +12 -11
- package/dist/components/temporal.js +3 -2
- package/dist/daemon.js +50 -17
- package/dist/harness/built-image.d.ts +12 -0
- package/dist/harness/built-image.js +13 -0
- package/dist/harness/builtin-images.d.ts +29 -0
- package/dist/harness/builtin-images.js +29 -0
- package/dist/harness/component-images.d.ts +7 -0
- package/dist/harness/component-images.js +12 -1
- package/dist/harness/registry-seed.d.ts +1 -1
- package/dist/harness/registry-seed.js +2 -1
- package/dist/harness/sticky-consumer.js +6 -0
- package/dist/harness/sticky-kubernetes.js +12 -0
- package/package.json +1 -1
- package/src/components/android.ts +2 -1
- package/src/components/aws.ts +2 -1
- package/src/components/electron.test.ts +2 -0
- package/src/components/electron.ts +2 -1
- package/src/components/email.ts +2 -1
- package/src/components/emulate/service.ts +2 -1
- package/src/components/expo.test.ts +2 -2
- package/src/components/expo.ts +5 -4
- package/src/components/image-pins.test.ts +44 -0
- package/src/components/k3s.ts +5 -4
- package/src/components/postgres.ts +3 -2
- package/src/components/supabase-pooler.ts +2 -1
- package/src/components/supabase.ts +12 -11
- package/src/components/temporal.ts +3 -2
- package/src/daemon.ts +48 -16
- package/src/harness/built-image-docker.test.ts +62 -0
- package/src/harness/built-image.ts +14 -0
- package/src/harness/builtin-images.ts +29 -0
- package/src/harness/component-images.test.ts +19 -1
- package/src/harness/component-images.ts +15 -1
- package/src/harness/registry-seed.ts +2 -1
- package/src/harness/sticky-consumer.ts +6 -0
- package/src/harness/sticky-kubernetes.test.ts +23 -0
- package/src/harness/sticky-kubernetes.ts +12 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { posix } from "node:path";
|
|
3
4
|
import type { ServiceDefinition } from "../index.js";
|
|
@@ -131,7 +132,7 @@ export function electron(options: ElectronOptions = {}) {
|
|
|
131
132
|
type: "dockerfile",
|
|
132
133
|
context,
|
|
133
134
|
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
134
|
-
content: `FROM node:${node}
|
|
135
|
+
content: `FROM ${options.nodeVersion === undefined ? BUILTIN_IMAGES["node:24-bookworm-slim"] : `node:${node}`}
|
|
135
136
|
RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
136
137
|
WORKDIR /app
|
|
137
138
|
COPY . .
|
package/src/components/email.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { rawFetch } from "../harness/raw-fetch.js";
|
|
2
3
|
// `email()` — a real SMTP server as a spectest service. The app under test
|
|
3
4
|
// points its SMTP transport at `<key>:1025` and sends mail exactly as it
|
|
@@ -29,7 +30,7 @@ import {
|
|
|
29
30
|
import { wrap } from "../inspect.js";
|
|
30
31
|
import type { Wrapped } from "../inspect.js";
|
|
31
32
|
|
|
32
|
-
const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
|
|
33
|
+
const DEFAULT_IMAGE = BUILTIN_IMAGES["axllent/mailpit:v1.30"];
|
|
33
34
|
const SMTP_PORT = 1025;
|
|
34
35
|
const API_PORT = 8025;
|
|
35
36
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../../harness/builtin-images.js";
|
|
1
2
|
// Shared runtime behind the third-party provider components — `google()`,
|
|
2
3
|
// `github()`, `apple()`, `microsoft()`, `okta()`.
|
|
3
4
|
//
|
|
@@ -53,7 +54,7 @@ import type { ServiceDefinition } from "../../index.js";
|
|
|
53
54
|
import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../../index.js";
|
|
54
55
|
|
|
55
56
|
/** Base image, pinned. Bun runs the emulators' ESM directly. */
|
|
56
|
-
const IMAGE_BASE = "oven/bun:1.3.14-alpine";
|
|
57
|
+
const IMAGE_BASE = BUILTIN_IMAGES["oven/bun:1.3.14-alpine"];
|
|
57
58
|
/** Emulator package version, pinned rather than floated. */
|
|
58
59
|
const EMULATE_VERSION = "0.9.0";
|
|
59
60
|
/** The port the provider is served on. Callers reach it through the
|
|
@@ -175,9 +175,9 @@ describe("expo() Node version", () => {
|
|
|
175
175
|
expect(dockerfile(expo())).toContain("FROM node:20-bookworm-slim");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
test("
|
|
178
|
+
test("pins the default Node image when the app declares nothing readable", () => {
|
|
179
179
|
app({ "package.json": pkg(), ".nvmrc": "lts/iron\n" });
|
|
180
|
-
expect(dockerfile(expo())).
|
|
180
|
+
expect(dockerfile(expo()).split("\n")[0]).toMatch(/^FROM node:22-bookworm-slim@sha256:[a-f0-9]{64}$/);
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
test("the option wins", () => {
|
package/src/components/expo.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
// `expo()` — a ready-to-use service for an Expo / React Native app rendered
|
|
2
3
|
// to the web, built for `ctx.mobile(...)`.
|
|
3
4
|
//
|
|
@@ -420,13 +421,13 @@ function publicEnvLines(env: Record<string, string>): string[] {
|
|
|
420
421
|
* BuildKit, which every builder in a VM is.
|
|
421
422
|
*/
|
|
422
423
|
function generateDockerfile(input: {
|
|
423
|
-
|
|
424
|
+
nodeImage: string;
|
|
424
425
|
plan: InstallPlan;
|
|
425
426
|
publicEnv: string[];
|
|
426
427
|
outputDir: string;
|
|
427
428
|
}): string {
|
|
428
|
-
const {
|
|
429
|
-
const lines = [`FROM
|
|
429
|
+
const { nodeImage, plan, publicEnv, outputDir } = input;
|
|
430
|
+
const lines = [`FROM ${nodeImage}`, "WORKDIR /app", "ENV CI=1", ...plan.env];
|
|
430
431
|
for (const step of plan.bootstrap) lines.push(`RUN ${step}`);
|
|
431
432
|
lines.push(`COPY ${plan.manifests.join(" ")} ./`);
|
|
432
433
|
lines.push(`RUN --mount=type=cache,target=${plan.cacheDir},sharing=locked ${plan.install}`);
|
|
@@ -522,7 +523,7 @@ export function expo(opts: ExpoOptions = {}) {
|
|
|
522
523
|
image = {
|
|
523
524
|
type: "dockerfile",
|
|
524
525
|
content: generateDockerfile({
|
|
525
|
-
|
|
526
|
+
nodeImage: node.source === "the default" ? BUILTIN_IMAGES["node:22-bookworm-slim"] : `node:${node.tag}`,
|
|
526
527
|
plan,
|
|
527
528
|
publicEnv: publicEnvLines(opts.env ?? {}),
|
|
528
529
|
outputDir,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { defineEnvironment } from "../index.js";
|
|
3
|
+
import { postgres, email, aws, k3s, supabase, temporal, google } from "./index.js";
|
|
4
|
+
import { ANDROID_TOOLS_DOCKERFILE } from "./android.js";
|
|
5
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
6
|
+
import { REGISTRY_IMAGE } from "../harness/registry-seed.js";
|
|
7
|
+
|
|
8
|
+
const credentials = { database: "app", user: "app", password: "app" };
|
|
9
|
+
const digest = /@sha256:[a-f0-9]{64}$/;
|
|
10
|
+
|
|
11
|
+
test("SDK-owned component images and generated base images are immutable", () => {
|
|
12
|
+
const environment = defineEnvironment({ name: "pins", services: {
|
|
13
|
+
db: postgres(credentials), mail: email(), cloud: aws(), cluster: k3s({ nodes: 3 }),
|
|
14
|
+
supabase: supabase().group, temporal: temporal({ tls: { hostname: "temporal.test" } }).group,
|
|
15
|
+
identity: google(),
|
|
16
|
+
} });
|
|
17
|
+
for (const [name, service] of Object.entries(environment.config.services)) {
|
|
18
|
+
const image = service.image;
|
|
19
|
+
if (image.type === "registry") expect(image.reference, name).toMatch(digest);
|
|
20
|
+
else if ("content" in image) {
|
|
21
|
+
for (const line of image.content!.split("\n").filter(line => /^FROM /i.test(line))) {
|
|
22
|
+
expect(line.split(/\s+/)[1], name).toMatch(digest);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
expect(ANDROID_TOOLS_DOCKERFILE.split("\n")[0]!.slice(5)).toMatch(digest);
|
|
27
|
+
expect(REGISTRY_IMAGE).toMatch(digest);
|
|
28
|
+
for (const [tag, reference] of Object.entries(BUILTIN_IMAGES)) {
|
|
29
|
+
expect(reference.startsWith(`${tag}@`)).toBe(true);
|
|
30
|
+
expect(reference).toMatch(digest);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("explicit user image and version overrides remain unchanged", () => {
|
|
35
|
+
expect(postgres({ ...credentials, version: "17-alpine" }).image).toMatchObject({ reference: "postgres:17-alpine" });
|
|
36
|
+
expect(postgres({ ...credentials, version: "17", image: "custom/db:latest" }).image).toMatchObject({ reference: "custom/db:latest" });
|
|
37
|
+
expect(email({ image: "custom/mail:latest" }).image).toMatchObject({ reference: "custom/mail:latest" });
|
|
38
|
+
const env = defineEnvironment({ name: "overrides", services: {
|
|
39
|
+
cluster: k3s({ version: "v1.33.6-k3s1" }),
|
|
40
|
+
temporal: temporal({ image: "custom/temporal:latest" }).group,
|
|
41
|
+
} });
|
|
42
|
+
expect(env.config.services.cluster.image).toMatchObject({ reference: "rancher/k3s:v1.33.6-k3s1" });
|
|
43
|
+
expect(env.config.services.temporal.image).toMatchObject({ reference: "custom/temporal:latest" });
|
|
44
|
+
});
|
package/src/components/k3s.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { isIP } from "node:net";
|
|
2
3
|
import { waitForK3sNodes } from "./k3s-nodes.js";
|
|
3
4
|
import { JOIN_DIRECTORY, prepareK3sJoin } from "./k3s-join.js";
|
|
@@ -996,7 +997,7 @@ function withTagging<T extends object>(api: T): Tagged<T> {
|
|
|
996
997
|
* `registries.yaml`, then captured into the warm-template snapshot so
|
|
997
998
|
* warm starts never pull.
|
|
998
999
|
*/
|
|
999
|
-
const TRAEFIK_IMAGE = "rancher/mirrored-library-traefik:3.3.2";
|
|
1000
|
+
const TRAEFIK_IMAGE = BUILTIN_IMAGES["rancher/mirrored-library-traefik:3.3.2"];
|
|
1000
1001
|
|
|
1001
1002
|
/**
|
|
1002
1003
|
* Names of the in-cluster resources that carry the CA-signed default
|
|
@@ -1247,7 +1248,7 @@ spec:
|
|
|
1247
1248
|
- operator: Exists
|
|
1248
1249
|
containers:
|
|
1249
1250
|
- name: registry
|
|
1250
|
-
image: ${
|
|
1251
|
+
image: ${REGISTRY_IMAGE}
|
|
1251
1252
|
imagePullPolicy: IfNotPresent
|
|
1252
1253
|
env:
|
|
1253
1254
|
- name: REGISTRY_HTTP_ADDR
|
|
@@ -2111,7 +2112,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
|
|
|
2111
2112
|
// checked all Nodes and finished loading images, not at API readiness.
|
|
2112
2113
|
const readyCmd = "kubectl get --raw=/readyz >/dev/null 2>&1";
|
|
2113
2114
|
const def = {
|
|
2114
|
-
image: { type: "registry", reference: `rancher/k3s:${version}` },
|
|
2115
|
+
image: { type: "registry", reference: opts.version === undefined ? BUILTIN_IMAGES["rancher/k3s:v1.33.5-k3s1"] : `rancher/k3s:${version}` },
|
|
2115
2116
|
imageBuilds: server ? images.builds : [],
|
|
2116
2117
|
tls: server && registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
|
|
2117
2118
|
command: cmd,
|
|
@@ -2215,7 +2216,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
|
|
|
2215
2216
|
for (;;) {
|
|
2216
2217
|
try {
|
|
2217
2218
|
const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
|
|
2218
|
-
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
|
|
2219
|
+
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: BUILTIN_IMAGES["registry.k8s.io/pause:3.10"] }] },
|
|
2219
2220
|
} })).unwrap();
|
|
2220
2221
|
if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
|
|
2221
2222
|
if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import type { ServiceDefinition, ServiceSetupContext } from "../index.js";
|
|
2
3
|
import { SQL, type SqlClient } from "../sql.js";
|
|
3
4
|
import { postgresTls } from "./postgres-tls.js";
|
|
@@ -97,8 +98,8 @@ export interface PostgresHelpers {
|
|
|
97
98
|
* ```
|
|
98
99
|
*/
|
|
99
100
|
export function postgres(opts: PostgresOptions) {
|
|
100
|
-
const
|
|
101
|
-
|
|
101
|
+
const reference = opts.image ?? (opts.version === undefined
|
|
102
|
+
? BUILTIN_IMAGES["postgres:18-alpine"] : `postgres:${opts.version}`);
|
|
102
103
|
const port = opts.port ?? 5432;
|
|
103
104
|
const persistent = opts.persistent ?? true;
|
|
104
105
|
const tls = opts.tls !== false ? postgresTls() : undefined;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import type { ServiceDefinition } from "../index.js";
|
|
2
3
|
|
|
3
4
|
/** Supabase CLI's [db.pooler] settings. Ports are local to the pooler container. */
|
|
@@ -83,7 +84,7 @@ export function supabasePoolerService(opts: {
|
|
|
83
84
|
const { config, dbHost, tenantId, password, jwtSecret, tls, url } = opts;
|
|
84
85
|
const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`;
|
|
85
86
|
return {
|
|
86
|
-
image: { type: "registry", reference: "supabase/supavisor:2.9.12" },
|
|
87
|
+
image: { type: "registry", reference: BUILTIN_IMAGES["supabase/supavisor:2.9.12"] },
|
|
87
88
|
// eval starts the OTP application too. Give that temporary instance
|
|
88
89
|
// ephemeral SQL ports so readiness only sees the final server process.
|
|
89
90
|
command: '/app/bin/migrate && PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval "$(cat /etc/pooler/pooler.exs)" && exec /app/bin/server',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { rawFetch } from "../harness/raw-fetch.js";
|
|
2
3
|
// A ready-to-use, self-hosted **Supabase** stack, expressed as a set of
|
|
3
4
|
// spectest services (one container each) wired together on spectest-net.
|
|
@@ -88,22 +89,22 @@ import {
|
|
|
88
89
|
export type { MailTemplateId, MailTemplateSpec } from "./supabase-config.js";
|
|
89
90
|
|
|
90
91
|
// ──────────────────────────────────────────────────────────────────────────
|
|
91
|
-
//
|
|
92
|
-
//
|
|
92
|
+
// Digest-pinned upstream images. Update BUILTIN_IMAGES together for compatible
|
|
93
|
+
// Supabase releases; each is pulled through the host Hub mirror on
|
|
93
94
|
// first cold boot and captured into the warm-template snapshot thereafter.
|
|
94
95
|
// ──────────────────────────────────────────────────────────────────────────
|
|
95
96
|
const IMAGES = {
|
|
96
|
-
db: "supabase/postgres:17.6.1.136",
|
|
97
|
-
kong: "kong/kong:3.9.1",
|
|
98
|
-
auth: "supabase/gotrue:v2.189.0",
|
|
99
|
-
rest: "postgrest/postgrest:v14.12",
|
|
100
|
-
realtime: "supabase/realtime:v2.102.3",
|
|
101
|
-
storage: "supabase/storage-api:v1.60.4",
|
|
102
|
-
imgproxy: "darthsim/imgproxy:v3.30.1",
|
|
103
|
-
functions: "supabase/edge-runtime:v1.74.0",
|
|
97
|
+
db: BUILTIN_IMAGES["supabase/postgres:17.6.1.136"],
|
|
98
|
+
kong: BUILTIN_IMAGES["kong/kong:3.9.1"],
|
|
99
|
+
auth: BUILTIN_IMAGES["supabase/gotrue:v2.189.0"],
|
|
100
|
+
rest: BUILTIN_IMAGES["postgrest/postgrest:v14.12"],
|
|
101
|
+
realtime: BUILTIN_IMAGES["supabase/realtime:v2.102.3"],
|
|
102
|
+
storage: BUILTIN_IMAGES["supabase/storage-api:v1.60.4"],
|
|
103
|
+
imgproxy: BUILTIN_IMAGES["darthsim/imgproxy:v3.30.1"],
|
|
104
|
+
functions: BUILTIN_IMAGES["supabase/edge-runtime:v1.74.0"],
|
|
104
105
|
// Serves custom mail templates to GoTrue over HTTP (see the `templates`
|
|
105
106
|
// part). Tiny, and only pulled by a stack that configures a template.
|
|
106
|
-
templates: "busybox:1.37",
|
|
107
|
+
templates: BUILTIN_IMAGES["busybox:1.37"],
|
|
107
108
|
} as const;
|
|
108
109
|
|
|
109
110
|
/** Override tables without mutating the parsed project config. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { serviceGroup, type ServiceDefinition, type ServicesMap } from "../index.js";
|
|
2
3
|
import { rawFetch } from "../harness/raw-fetch.js";
|
|
3
4
|
import { readRaw } from "../inspect.js";
|
|
@@ -167,13 +168,13 @@ export function temporal(opts: TemporalOptions = {}) {
|
|
|
167
168
|
primary, expectKey: name,
|
|
168
169
|
services: (): ServicesMap => {
|
|
169
170
|
const server: ServiceDefinition = {
|
|
170
|
-
image: { type: "registry", reference: opts.image ?? "temporalio/temporal:1.9.1" }, args,
|
|
171
|
+
image: { type: "registry", reference: opts.image ?? BUILTIN_IMAGES["temporalio/temporal:1.9.1"] }, args,
|
|
171
172
|
ports: [port, httpPort, ...(opts.ui ? [8233] : [])],
|
|
172
173
|
readyCheck: { type: "http", port: httpPort, path: `/api/v1/namespaces/${encodeURIComponent(namespace)}`, timeoutSecs: 30 },
|
|
173
174
|
};
|
|
174
175
|
if (!opts.tls) return { server };
|
|
175
176
|
return { server, tls: {
|
|
176
|
-
image: { type: "registry", reference: "nginx:1.29.6-alpine" },
|
|
177
|
+
image: { type: "registry", reference: BUILTIN_IMAGES["nginx:1.29.6-alpine"] },
|
|
177
178
|
ports: [port], hostnames: [opts.tls.hostname], dependsOn: [serverName],
|
|
178
179
|
certificates: [{ hostnames: [opts.tls.hostname], certPath: "/tls/server.crt", keyPath: "/tls/server.key" }],
|
|
179
180
|
files: [{ path: "/etc/nginx/nginx.conf", content: `worker_processes 1;\nevents { worker_connections 1024; }\nstream { server { listen ${port} ssl; ssl_certificate /tls/server.crt; ssl_certificate_key /tls/server.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_alpn h2; proxy_pass ${serverName}:${port}; } }\n` }],
|
package/src/daemon.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { collectSecretRefs } from "./fake-upstream.js";
|
|
|
4
4
|
import { phoneScope, phoneRecordingChunk, releasePhoneRecording, type PhoneRecording } from "./phone.js";
|
|
5
5
|
import { runWithBackgroundFailures } from "./harness/background-failures.js";
|
|
6
6
|
import { generateHostCert as mintHostCert } from "./harness/certificate.js";
|
|
7
|
-
import {
|
|
7
|
+
import { containerdImageOutput } from "./harness/built-image.js";
|
|
8
|
+
import { componentBuildTasks, COMPONENT_RUNTIME, isComponentImageTask, isDigestReference } from "./harness/component-images.js";
|
|
8
9
|
import { waitForAll } from "./harness/concurrency.js";
|
|
9
10
|
import { updateServiceProgress, serviceProgressSnapshot, type ServiceProgress } from "./harness/service-progress.js";
|
|
10
11
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
@@ -748,22 +749,18 @@ function ensureLocalBuildkitd(): Promise<boolean> {
|
|
|
748
749
|
return _localBuilder;
|
|
749
750
|
}
|
|
750
751
|
|
|
751
|
-
/**
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
if (slash < 0) return `docker.io/library/${ref}`;
|
|
762
|
-
if (!isRegistry) return `docker.io/${ref}`;
|
|
763
|
-
return ref;
|
|
752
|
+
/** `true` when a digest-pinned reference is already complete in the store. */
|
|
753
|
+
async function imagePresentByDigest(ref: string): Promise<boolean> {
|
|
754
|
+
if (!isDigestReference(ref)) return false;
|
|
755
|
+
// `inspect` succeeds only when every layer of the image for this
|
|
756
|
+
// platform is in the store; a partial or missing image falls through to
|
|
757
|
+
// the ordinary pull, which fetches what is absent. dockerd's containerd
|
|
758
|
+
// store also unpacks on demand at container creation, so a record whose
|
|
759
|
+
// snapshots were reclaimed is repaired there rather than failing.
|
|
760
|
+
const present = await docker(["image", "inspect", "--format", "{{.Id}}", ref], 30_000);
|
|
761
|
+
return present.code === 0;
|
|
764
762
|
}
|
|
765
763
|
|
|
766
|
-
|
|
767
764
|
/** Host directory backing one volume mount. Rules (notably which volumes
|
|
768
765
|
* survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
|
|
769
766
|
function resolveHostPath(service: string, vol: VolumeMount): string {
|
|
@@ -1229,6 +1226,18 @@ async function prepareServiceImageInner(
|
|
|
1229
1226
|
// tags (`foo:latest`) at whatever the previous generation pulled, for
|
|
1230
1227
|
// as long as the delta chain lives — a silent semantic divergence
|
|
1231
1228
|
// from the cold build a delta restore must be equivalent to.
|
|
1229
|
+
//
|
|
1230
|
+
// The one reference that cannot float is a digest. `name@sha256:…`
|
|
1231
|
+
// names exactly one image for as long as it exists, so a copy already
|
|
1232
|
+
// in the store is the image the registry would return, and the
|
|
1233
|
+
// round-trip buys nothing: skip it. Component images pinned by digest
|
|
1234
|
+
// and user references pinned by digest take this path; a tag never does.
|
|
1235
|
+
if (await imagePresentByDigest(ref)) {
|
|
1236
|
+
progressService(svc.name, { status: "pulling", detail: `present by digest` });
|
|
1237
|
+
const tagged = await docker(["tag", ref, tag], 30_000);
|
|
1238
|
+
if (tagged.code !== 0) throw new Error(`docker tag ${ref} ${tag} failed: ${tagged.stderr.trim()}`);
|
|
1239
|
+
return { tag };
|
|
1240
|
+
}
|
|
1232
1241
|
progressService(svc.name, { status: "pulling", detail: `pulling ${ref}` });
|
|
1233
1242
|
let layers = 0;
|
|
1234
1243
|
const pull = await shxStream("docker", ["pull", ref], 900_000, undefined, (line) => {
|
|
@@ -1379,11 +1388,34 @@ async function runServiceBuild(
|
|
|
1379
1388
|
// every cached parent before the first uncached step (11–79 s for
|
|
1380
1389
|
// one `COPY` on Specific's dashboard) and a second full pass to
|
|
1381
1390
|
// export after every miss.
|
|
1391
|
+
// Keep an immutable image reference as well as the service tag. The
|
|
1392
|
+
// containerd exporter replaces the tag directly, bypassing Docker's
|
|
1393
|
+
// dangling-image retention. Old versions must remain usable until our
|
|
1394
|
+
// image GC decides they are no longer needed (including by nested stores).
|
|
1395
|
+
//
|
|
1396
|
+
// `compression=uncompressed` (`containerdImageOutput`): the image never leaves this store, so
|
|
1397
|
+
// gzip on export only to gunzip on unpack is pure cost — on Listen,
|
|
1398
|
+
// 64 s compressing a 2.7 GB layer and 56 s decompressing it again,
|
|
1399
|
+
// single-threaded, in the same guest that already held the layer as
|
|
1400
|
+
// a build snapshot (CONTAINER_STORE.md, "Image delivery inside the
|
|
1401
|
+
// guest"). With the default `force-compression=false` this applies
|
|
1402
|
+
// to layers this build creates; a pulled base image's gzip blobs are
|
|
1403
|
+
// reused as they are, so no existing layer is re-encoded or
|
|
1404
|
+
// re-unpacked. The uncompressed blob is a second on-disk copy of the
|
|
1405
|
+
// layer; the sticky headroom logic already budgets for blobs.
|
|
1406
|
+
//
|
|
1407
|
+
// `unpack`: dockerd needs the snapshot chain to start a container,
|
|
1408
|
+
// so a service image is unpacked here rather than at `docker run`.
|
|
1409
|
+
// A component image task (a `k3s()` build) is consumed only through
|
|
1410
|
+
// the content store — `k3s-image-loader.ts` serves its blobs and the
|
|
1411
|
+
// cluster's containerd unpacks them into its own store — so unpacking
|
|
1412
|
+
// it in dockerd's store would be a third copy nobody reads.
|
|
1413
|
+
const unpack = !isComponentImageTask(name);
|
|
1382
1414
|
buildArgs = [
|
|
1383
1415
|
"buildx", "build",
|
|
1384
1416
|
"--builder", LOCAL_BUILDER_NAME,
|
|
1385
1417
|
"--progress=plain",
|
|
1386
|
-
"--output",
|
|
1418
|
+
"--output", containerdImageOutput(tag, { unpack }),
|
|
1387
1419
|
...argFlags,
|
|
1388
1420
|
"-f", dfPath, contextDir,
|
|
1389
1421
|
];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPECTEST_TEST_STICKY_DOCKER=1 bun test sdk/src/harness/built-image-docker.test.ts
|
|
2
|
+
// Requires local Docker's containerd store at /run/containerd/containerd.sock.
|
|
3
|
+
import { expect, test } from "bun:test";
|
|
4
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { containerdImageOutput } from "./built-image";
|
|
8
|
+
|
|
9
|
+
async function run(args: string[]) {
|
|
10
|
+
const process = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
11
|
+
const [code, stdout, stderr] = await Promise.all([process.exited,
|
|
12
|
+
new Response(process.stdout).text(), new Response(process.stderr).text()]);
|
|
13
|
+
if (code) throw new Error(`${args.slice(0, 4).join(" ")}: ${stderr}`);
|
|
14
|
+
return stdout.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test.skipIf(!process.env.SPECTEST_TEST_STICKY_DOCKER)("containerd rebuild retains old images until explicit image GC", async () => {
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "sticky-export-"));
|
|
19
|
+
const builder = `sticky-export-${crypto.randomUUID()}`;
|
|
20
|
+
const consumer = `${builder}-consumer`;
|
|
21
|
+
const tag = `docker.io/spectest/${builder}:latest`;
|
|
22
|
+
const images = new Set<string>();
|
|
23
|
+
try {
|
|
24
|
+
await writeFile(`${root}/Dockerfile`, 'FROM scratch\nCOPY payload /payload\nCMD ["/payload"]\n');
|
|
25
|
+
await run(["docker", "run", "-d", "--privileged", "--name", builder,
|
|
26
|
+
"-v", "/run/containerd/containerd.sock:/run/containerd/containerd.sock",
|
|
27
|
+
"-v", "/var/lib/containerd:/var/lib/containerd", "-v", `${root}:/context:ro`,
|
|
28
|
+
"moby/buildkit:v0.32.2", "--oci-worker=false", "--containerd-worker=true",
|
|
29
|
+
"--containerd-worker-namespace=moby", "--containerd-worker-gc=false"]);
|
|
30
|
+
for (let attempt = 0; ; attempt++) {
|
|
31
|
+
try { await run(["docker", "exec", builder, "buildctl", "debug", "workers"]); break; }
|
|
32
|
+
catch (error) { if (attempt === 40) throw error; await Bun.sleep(250); }
|
|
33
|
+
}
|
|
34
|
+
async function build(payload: string, output = containerdImageOutput(tag)) {
|
|
35
|
+
await writeFile(`${root}/payload`, `${builder}-${payload}`);
|
|
36
|
+
await run(["docker", "exec", builder, "buildctl", "build", "--frontend", "dockerfile.v0",
|
|
37
|
+
"--local", "context=/context", "--local", "dockerfile=/context", "--output", output]);
|
|
38
|
+
const id = await run(["docker", "image", "inspect", "--format", "{{.Id}}", tag]);
|
|
39
|
+
images.add(id);
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
const first = await build("first");
|
|
43
|
+
const second = await build("second");
|
|
44
|
+
expect(second).not.toBe(first);
|
|
45
|
+
expect(await run(["docker", "image", "inspect", "--format", "{{.Id}}", first])).toBe(first);
|
|
46
|
+
await run(["docker", "create", "--name", consumer, first]);
|
|
47
|
+
await run(["docker", "rm", consumer]);
|
|
48
|
+
// The normal retention collector removes by image ID, including aliases.
|
|
49
|
+
await run(["docker", "image", "rm", "-f", first]);
|
|
50
|
+
await expect(run(["docker", "image", "inspect", first])).rejects.toThrow("No such image");
|
|
51
|
+
expect(await run(["docker", "image", "inspect", "--format", "{{.Id}}", tag])).toBe(second);
|
|
52
|
+
// Negative control: the old export mode reproduces Tangent's failure.
|
|
53
|
+
const unpinned = await build("unpinned", `type=image,name=${tag},unpack=true`);
|
|
54
|
+
await build("replacement");
|
|
55
|
+
await expect(run(["docker", "image", "inspect", unpinned])).rejects.toThrow("No such image");
|
|
56
|
+
} finally {
|
|
57
|
+
await run(["docker", "rm", "-f", consumer]).catch(() => {});
|
|
58
|
+
await run(["docker", "rm", "-fv", builder]).catch(() => {});
|
|
59
|
+
for (const id of images) await run(["docker", "image", "rm", "-f", id]).catch(() => {});
|
|
60
|
+
await rm(root, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
}, 120_000);
|
|
@@ -4,6 +4,20 @@ import { createHash } from "node:crypto";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { canonicalImageReference } from "./component-images.js";
|
|
6
6
|
|
|
7
|
+
/** Containerd exports replace mutable tags without Docker's dangling-image
|
|
8
|
+
* retention. Keep each result addressable until the SDK's image GC evicts it.
|
|
9
|
+
*
|
|
10
|
+
* Layers this build creates are stored uncompressed: the image never leaves
|
|
11
|
+
* the VM's store, so compressing on export only to decompress on unpack was
|
|
12
|
+
* pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
|
|
13
|
+
* off, so a pulled base image's blobs are reused as they are. `unpack` is
|
|
14
|
+
* false for images only a nested store reads (CONTAINER_STORE.md, "Image
|
|
15
|
+
* delivery inside the guest"). */
|
|
16
|
+
export function containerdImageOutput(reference: string, options: { unpack?: boolean } = {}): string {
|
|
17
|
+
const unpack = options.unpack ?? true;
|
|
18
|
+
return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=${unpack},compression=uncompressed`;
|
|
19
|
+
}
|
|
20
|
+
|
|
7
21
|
export interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
8
22
|
interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
|
|
9
23
|
const digest = (bytes: Uint8Array) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** SDK-owned upstream inputs, resolved from registry manifests on 2026-11-02.
|
|
2
|
+
* Pin the multi-platform index, never the workstation's platform manifest.
|
|
3
|
+
* Update deliberately with an SDK release; user image/version overrides bypass
|
|
4
|
+
* these defaults. Keep generated Dockerfiles and helper pods here as well.
|
|
5
|
+
*/
|
|
6
|
+
export const BUILTIN_IMAGES = {
|
|
7
|
+
"postgres:18-alpine": "postgres:18-alpine@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5",
|
|
8
|
+
"rancher/k3s:v1.33.5-k3s1": "rancher/k3s:v1.33.5-k3s1@sha256:fd4740667b7033055c27d424d0d2d660bf66cedbdb225d68e0eab6dd48aa0fd2",
|
|
9
|
+
"rancher/mirrored-library-traefik:3.3.2": "rancher/mirrored-library-traefik:3.3.2@sha256:a03ee2f858f77975fa943f6797da5446b7ac9a83beef6bbdae2afd8feaf67b76",
|
|
10
|
+
"registry:2.8.3": "registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
|
|
11
|
+
"registry.k8s.io/pause:3.10": "registry.k8s.io/pause:3.10@sha256:ee6521f290b2168b6e0935a181d4cff9be1ac3f505666ef0e3c98fae8199917a",
|
|
12
|
+
"node:22-bookworm-slim": "node:22-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da",
|
|
13
|
+
"node:24-bookworm-slim": "node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c",
|
|
14
|
+
"oven/bun:1.3.14-alpine": "oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0",
|
|
15
|
+
"axllent/mailpit:v1.30": "axllent/mailpit:v1.30@sha256:d5ecbb067db3705fa953d79e1b7f81ef84038df67aba6c52825d8c02a1ea748a",
|
|
16
|
+
"ministackorg/ministack:1.4.8": "ministackorg/ministack:1.4.8@sha256:eb0a00c897ec91589eb78a77f0afaf13a6d7be99abfeca2d1d719a7ec76d3141",
|
|
17
|
+
"temporalio/temporal:1.9.1": "temporalio/temporal:1.9.1@sha256:ad4c82c97bd12b417d1ea942610dbcd511afb250c4d5ed26c694009533df447e",
|
|
18
|
+
"nginx:1.29.6-alpine": "nginx:1.29.6-alpine@sha256:f46cb72c7df02710e693e863a983ac42f6a9579058a59a35f1ae36c9958e4ce0",
|
|
19
|
+
"supabase/supavisor:2.9.12": "supabase/supavisor:2.9.12@sha256:464b93a60ba8c96bc06dad7f241562d6613b2a994922307717b05f432ae415c2",
|
|
20
|
+
"supabase/postgres:17.6.1.136": "supabase/postgres:17.6.1.136@sha256:f371b5f3f2ac0a05703f33d6e6134515fb2498cab708fb948a0aeb7481467c00",
|
|
21
|
+
"kong/kong:3.9.1": "kong/kong:3.9.1@sha256:6addf50e6bd8d578314cb9ce4f2d2d1e3781d2edecef59f707e00c6e05d384f5",
|
|
22
|
+
"supabase/gotrue:v2.189.0": "supabase/gotrue:v2.189.0@sha256:385184459f57569c54c25209f51f3b2be99ddd7c4ce9e3555b5d3eea8447b7cf",
|
|
23
|
+
"postgrest/postgrest:v14.12": "postgrest/postgrest:v14.12@sha256:54000f24847d01a2c2302e0041cf0618b875c57fb48507d743cfa9aaa50bf43c",
|
|
24
|
+
"supabase/realtime:v2.102.3": "supabase/realtime:v2.102.3@sha256:aa1c92c0cf326007563641730ec9da9c60478caa6853887775365fa2c097a471",
|
|
25
|
+
"supabase/storage-api:v1.60.4": "supabase/storage-api:v1.60.4@sha256:c8eb9858eafec891a97c27125470aaad54703c3f4eb4d55ca7f1bf6c6411febf",
|
|
26
|
+
"darthsim/imgproxy:v3.30.1": "darthsim/imgproxy:v3.30.1@sha256:3b709e4a0e5e8e0e959b556b7031229202b4b8e7e7d955c517ea7abed68ee34d",
|
|
27
|
+
"supabase/edge-runtime:v1.74.0": "supabase/edge-runtime:v1.74.0@sha256:2781daf92394db91f7e94129cc3d04ec474ad16a8fe64b3fbeef6e7d557ab120",
|
|
28
|
+
"busybox:1.37": "busybox:1.37@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec",
|
|
29
|
+
} as const;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { canonicalImageReference, componentBuildTasks, kubernetesImages } from "./component-images";
|
|
2
|
+
import { canonicalImageReference, componentBuildTasks, isComponentImageTask, isDigestReference, kubernetesImages } from "./component-images";
|
|
3
3
|
import { validateServiceImage, type BuildImage } from "../index";
|
|
4
4
|
|
|
5
5
|
test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
|
|
@@ -42,3 +42,21 @@ test("build task identity survives list reordering but is scoped to the owning c
|
|
|
42
42
|
expect(() => validateServiceImage("app", { content: "FROM scratch", buildArgs: { "bad-key": "x" } })).toThrow("ARG");
|
|
43
43
|
expect(() => validateServiceImage("app", { content: "FROM scratch", exclude: [1] as never })).toThrow("exclude");
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
test("component image tasks are recognised by name and services are not", () => {
|
|
47
|
+
const [task] = componentBuildTasks([{ name: "cluster", image: { type: "registry", reference: "rancher/k3s:v1.33.1-k3s1" },
|
|
48
|
+
imageBuilds: [{ reference: "registry.internal/app:dev", image: { type: "dockerfile", content: "FROM scratch" } }] } as any]);
|
|
49
|
+
expect(isComponentImageTask(task!.name)).toBe(true);
|
|
50
|
+
expect(isComponentImageTask("cluster")).toBe(false);
|
|
51
|
+
expect(isComponentImageTask("spectest-image")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("only a full sha256 digest reference is immutable", () => {
|
|
55
|
+
const digest = "a".repeat(64);
|
|
56
|
+
expect(isDigestReference(`postgres@sha256:${digest}`)).toBe(true);
|
|
57
|
+
expect(isDigestReference(`ghcr.io/org/app:1.2@sha256:${digest}`)).toBe(true);
|
|
58
|
+
expect(isDigestReference("postgres:16.4")).toBe(false);
|
|
59
|
+
expect(isDigestReference("postgres:latest")).toBe(false);
|
|
60
|
+
expect(isDigestReference(`postgres@sha256:${digest.slice(1)}`)).toBe(false);
|
|
61
|
+
expect(isDigestReference(`postgres@sha512:${digest}`)).toBe(false);
|
|
62
|
+
});
|
|
@@ -26,6 +26,11 @@ export function canonicalImageReference(reference: string): string {
|
|
|
26
26
|
return result;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** A `name@sha256:…` reference names one immutable image; a tag can move. */
|
|
30
|
+
export function isDigestReference(reference: string): boolean {
|
|
31
|
+
return /@sha256:[a-f0-9]{64}$/.test(reference);
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: string[]; builds: ComponentImageBuild[] } {
|
|
30
35
|
if (!Array.isArray(images)) throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
|
|
31
36
|
const pulls: string[] = [];
|
|
@@ -53,10 +58,19 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
|
|
|
53
58
|
return { pulls, builds };
|
|
54
59
|
}
|
|
55
60
|
|
|
61
|
+
/** Prefix of every component image task name; `isComponentImageTask` is the test. */
|
|
62
|
+
export const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
|
|
63
|
+
|
|
64
|
+
/** A component image task builds for a component's own runtime (today: a
|
|
65
|
+
* `k3s()` cluster's containerd), never for a container dockerd starts. */
|
|
66
|
+
export function isComponentImageTask(name: string): boolean {
|
|
67
|
+
return name.startsWith(COMPONENT_IMAGE_TASK_PREFIX);
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
/** Stable internal task names, separate from the service graph. */
|
|
57
71
|
export function componentBuildTasks<T extends ServiceConfig & { name: string }>(services: readonly T[]) {
|
|
58
72
|
return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
|
|
59
|
-
name:
|
|
73
|
+
name: `${COMPONENT_IMAGE_TASK_PREFIX}${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
|
|
60
74
|
owner: svc.name,
|
|
61
75
|
reference: build.reference,
|
|
62
76
|
image: build.image,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "./builtin-images.js";
|
|
1
2
|
/** Distribution v2 publication. Blob data stays in containerd, mounted
|
|
2
3
|
* read-only in the registry. Repository metadata and uploads MUST live on
|
|
3
4
|
* the environment rootfs, outside persistent image/build caches. */
|
|
@@ -9,7 +10,7 @@ import { canonicalImageReference, type PreparedComponentImage } from "./componen
|
|
|
9
10
|
export const REGISTRY_DATA = "/var/lib/spectest-registry";
|
|
10
11
|
export const REGISTRY_CONTENT = "/spectest-registry-content";
|
|
11
12
|
export const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
|
|
12
|
-
export const REGISTRY_IMAGE = "registry:2.8.3";
|
|
13
|
+
export const REGISTRY_IMAGE = BUILTIN_IMAGES["registry:2.8.3"];
|
|
13
14
|
export const registryDirectory = (service: string) => `/var/lib/spectest-registries/${service}`;
|
|
14
15
|
|
|
15
16
|
/** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
|
|
@@ -104,6 +104,12 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
104
104
|
dockerConfig["data-root"] = `${config.path}/docker`;
|
|
105
105
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
106
106
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
107
|
+
// Pull up to ten layers at once instead of dockerd's default three. The
|
|
108
|
+
// containerd image store honours this since Docker 29.7.0; extraction is
|
|
109
|
+
// still one layer at a time, so this only moves the download side off
|
|
110
|
+
// the critical path of many-layer images. Set here as well as in the
|
|
111
|
+
// golden's daemon.json so an SDK release carries it without a rebuild.
|
|
112
|
+
dockerConfig["max-concurrent-downloads"] = 10;
|
|
107
113
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
108
114
|
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
|
|
109
115
|
const start = async (unit: string, program: string, args: string[]) => {
|
|
@@ -18,6 +18,7 @@ async function store(root: string, service: string, age = 0) {
|
|
|
18
18
|
function runner(calls: string[][]): CacheCommand {
|
|
19
19
|
return async (program, args) => {
|
|
20
20
|
calls.push([program, ...args]);
|
|
21
|
+
if (program === "docker" && args.join(" ") === "image ls -aq --no-trunc") return image;
|
|
21
22
|
if (program === "docker" && args[0] === "inspect") return JSON.stringify({ ExitCode: 0, OOMKilled: false });
|
|
22
23
|
return "";
|
|
23
24
|
};
|
|
@@ -86,3 +87,25 @@ test("unclean nested containerd shutdown rejects sealing instead of publishing i
|
|
|
86
87
|
await expect(cache.close()).rejects.toThrow("Kubernetes cache shutdown failed");
|
|
87
88
|
expect(calls.some(c => c[0] === "rm")).toBe(false);
|
|
88
89
|
});
|
|
90
|
+
|
|
91
|
+
test("orphaned legacy stores are discarded while healthy stores are maintained", async () => {
|
|
92
|
+
const root = await temporary();
|
|
93
|
+
const orphan = await store(root, "old");
|
|
94
|
+
await writeFile(`${orphan}/store.json`, JSON.stringify({ schema: 1, image: `sha256:${"b".repeat(64)}`, updatedAt: Date.now() }));
|
|
95
|
+
await store(root, "healthy");
|
|
96
|
+
const calls: string[][] = [];
|
|
97
|
+
const cache = await openKubernetesCacheMaintenance(runner(calls), root);
|
|
98
|
+
expect(calls).toContainEqual(["rm", "-rf", orphan]);
|
|
99
|
+
expect(calls.filter(c => c[1] === "run")).toHaveLength(1);
|
|
100
|
+
await cache.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("inventory failure never discards stores", async () => {
|
|
104
|
+
const root = await temporary(); await store(root, "cluster");
|
|
105
|
+
const calls: string[][] = [];
|
|
106
|
+
await expect(openKubernetesCacheMaintenance(async (program, args) => {
|
|
107
|
+
calls.push([program, ...args]);
|
|
108
|
+
throw new Error("Docker unavailable");
|
|
109
|
+
}, root)).rejects.toThrow("Docker unavailable");
|
|
110
|
+
expect(calls.some(c => c[0] === "rm")).toBe(false);
|
|
111
|
+
});
|