@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.
Files changed (48) hide show
  1. package/dist/components/android.js +2 -1
  2. package/dist/components/aws.d.ts +1 -1
  3. package/dist/components/aws.js +2 -1
  4. package/dist/components/electron.js +2 -1
  5. package/dist/components/email.js +2 -1
  6. package/dist/components/emulate/service.js +2 -1
  7. package/dist/components/expo.js +4 -3
  8. package/dist/components/k3s.js +5 -4
  9. package/dist/components/postgres.js +3 -2
  10. package/dist/components/supabase-pooler.js +2 -1
  11. package/dist/components/supabase.js +12 -11
  12. package/dist/components/temporal.js +3 -2
  13. package/dist/daemon.js +50 -17
  14. package/dist/harness/built-image.d.ts +12 -0
  15. package/dist/harness/built-image.js +13 -0
  16. package/dist/harness/builtin-images.d.ts +29 -0
  17. package/dist/harness/builtin-images.js +29 -0
  18. package/dist/harness/component-images.d.ts +7 -0
  19. package/dist/harness/component-images.js +12 -1
  20. package/dist/harness/registry-seed.d.ts +1 -1
  21. package/dist/harness/registry-seed.js +2 -1
  22. package/dist/harness/sticky-consumer.js +6 -0
  23. package/dist/harness/sticky-kubernetes.js +12 -0
  24. package/package.json +1 -1
  25. package/src/components/android.ts +2 -1
  26. package/src/components/aws.ts +2 -1
  27. package/src/components/electron.test.ts +2 -0
  28. package/src/components/electron.ts +2 -1
  29. package/src/components/email.ts +2 -1
  30. package/src/components/emulate/service.ts +2 -1
  31. package/src/components/expo.test.ts +2 -2
  32. package/src/components/expo.ts +5 -4
  33. package/src/components/image-pins.test.ts +44 -0
  34. package/src/components/k3s.ts +5 -4
  35. package/src/components/postgres.ts +3 -2
  36. package/src/components/supabase-pooler.ts +2 -1
  37. package/src/components/supabase.ts +12 -11
  38. package/src/components/temporal.ts +3 -2
  39. package/src/daemon.ts +48 -16
  40. package/src/harness/built-image-docker.test.ts +62 -0
  41. package/src/harness/built-image.ts +14 -0
  42. package/src/harness/builtin-images.ts +29 -0
  43. package/src/harness/component-images.test.ts +19 -1
  44. package/src/harness/component-images.ts +15 -1
  45. package/src/harness/registry-seed.ts +2 -1
  46. package/src/harness/sticky-consumer.ts +6 -0
  47. package/src/harness/sticky-kubernetes.test.ts +23 -0
  48. 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 . .
@@ -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("builds on 22 when the app declares nothing readable", () => {
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())).toContain("FROM node:22-bookworm-slim");
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", () => {
@@ -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
- nodeVersion: string;
424
+ nodeImage: string;
424
425
  plan: InstallPlan;
425
426
  publicEnv: string[];
426
427
  outputDir: string;
427
428
  }): string {
428
- const { nodeVersion, plan, publicEnv, outputDir } = input;
429
- const lines = [`FROM node:${nodeVersion}`, "WORKDIR /app", "ENV CI=1", ...plan.env];
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
- nodeVersion: node.tag,
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
+ });
@@ -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: ${seeded ? REGISTRY_IMAGE : "registry:2"}
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 version = opts.version ?? "18-alpine";
101
- const reference = opts.image ?? `postgres:${version}`;
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
- // Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
92
- // current master). Bump together; each is pulled through the host Hub mirror on
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 { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
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
- * A reference as containerd names it. buildx's `-t` on a remote builder
753
- * stores an unqualified name (`probe:bx`) that dockerd then cannot
754
- * resolve (measured, CONTAINER_STORE.md), so the in-VM build names its
755
- * output in full.
756
- */
757
- function qualifyImageRef(ref: string): string {
758
- const slash = ref.indexOf("/");
759
- const first = slash < 0 ? "" : ref.slice(0, slash);
760
- const isRegistry = first.includes(".") || first.includes(":") || first === "localhost";
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", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
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: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
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
+ });