@specific.dev/spectest 0.131.11 → 0.131.13

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 (50) 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/replay-websocket.js +6 -1
  11. package/dist/components/supabase-pooler.js +2 -1
  12. package/dist/components/supabase.js +12 -11
  13. package/dist/components/temporal.js +3 -2
  14. package/dist/daemon.js +67 -51
  15. package/dist/harness/built-image.d.ts +3 -0
  16. package/dist/harness/built-image.js +5 -0
  17. package/dist/harness/builtin-images.d.ts +29 -0
  18. package/dist/harness/builtin-images.js +29 -0
  19. package/dist/harness/log-delta.d.ts +9 -0
  20. package/dist/harness/log-delta.js +10 -0
  21. package/dist/harness/registry-seed.d.ts +1 -1
  22. package/dist/harness/registry-seed.js +2 -1
  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/replay-websocket.ts +6 -1
  37. package/src/components/replayFake-websocket.test.ts +23 -0
  38. package/src/components/supabase-pooler.ts +2 -1
  39. package/src/components/supabase.ts +12 -11
  40. package/src/components/temporal.ts +3 -2
  41. package/src/daemon.ts +71 -57
  42. package/src/harness/built-image-docker.test.ts +62 -0
  43. package/src/harness/built-image.ts +6 -0
  44. package/src/harness/builtin-images.ts +29 -0
  45. package/src/harness/log-delta.test.ts +17 -0
  46. package/src/harness/log-delta.ts +15 -0
  47. package/src/harness/registry-seed.ts +2 -1
  48. package/src/harness/sticky-kubernetes.test.ts +23 -0
  49. package/src/harness/sticky-kubernetes.ts +12 -0
  50. package/src/runtime-service-logs.test.ts +74 -0
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { posix } from "node:path";
3
4
  import { serviceGroup } from "../index.js";
@@ -44,7 +45,7 @@ assert s.count(a) == 1
44
45
  p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
45
46
  const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
46
47
 
47
- export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
48
+ export const ANDROID_TOOLS_DOCKERFILE = `FROM ${BUILTIN_IMAGES["node:22-bookworm-slim"]}
48
49
  RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc python3 pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
49
50
  RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && python3 -c ${shellQuote(recorderPatch.replaceAll("\n", ";"))} && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
50
51
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  // `aws()` — the AWS cloud APIs, emulated inside the environment, answering
2
3
  // at their **real** endpoints.
3
4
  //
@@ -68,7 +69,7 @@ import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../index.js";
68
69
  import { pauseRecording, resumeRecording } from "../recorder.js";
69
70
 
70
71
  /** Backing image, pinned internally (not part of the documented surface). */
71
- const AWS_IMAGE = "ministackorg/ministack:1.4.8";
72
+ const AWS_IMAGE = BUILTIN_IMAGES["ministackorg/ministack:1.4.8"];
72
73
  /** Port the emulator serves every AWS API on. Internal: callers reach the
73
74
  * emulator through the AWS hostnames, never through this port. */
74
75
  const AWS_PORT = 4566;
@@ -24,6 +24,8 @@ describe('Electron component contract', () => {
24
24
  writeFileSync(join(appDir, 'package-lock.json'), '{}');
25
25
  const service = electron({ appDir, buildCommand: 'npm run compile', env: { API_URL: 'http://backend:4320' } });
26
26
  expect(service.image).toMatchObject({ context: appDir });
27
+ expect(service.image.content.split('\n')[0]).toMatch(/^FROM node:24-bookworm-slim@sha256:[a-f0-9]{64}$/);
28
+ expect(electron({ appDir, nodeVersion: '22-alpine' }).image.content.split('\n')[0]).toBe('FROM node:22-alpine');
27
29
  expect((service.image as { content: string }).content).toContain('RUN npm ci\n');
28
30
  expect((service.image as { content: string }).content).toContain('RUN npm run compile\n');
29
31
  expect(service.env?.API_URL).toBe('http://backend:4320');
@@ -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;
@@ -218,7 +218,12 @@ export function replayWebSocket(args: {
218
218
  const progress = record.inputMode === "anchors"
219
219
  ? ` occurrence ${record.inputOccurrences![position]}; observed ${occurrences.get(expected) ?? 0} occurrences; received ${receivedMessages} messages, ${receivedCandidates} candidates; last candidate ${lastCandidate ?? "<none>"}`
220
220
  : "";
221
- return diagnostic(`required input never arrived; expected ${expected}${progress}`);
221
+ const reordered = record.inputMode === "anchors"
222
+ && (occurrences.get(expected) ?? 0) >= record.inputOccurrences![position];
223
+ const detail = reordered
224
+ ? "required input arrived before preceding anchors matched"
225
+ : "required input never arrived";
226
+ return diagnostic(`${detail}; expected ${expected}${progress}`);
222
227
  };
223
228
  const armDeadline = () => {
224
229
  clearTimeout(deadline);
@@ -452,3 +452,26 @@ test("a reached response anchor catches up older output without adding its stale
452
452
  expect(c.sent).toEqual([...messages.map(m => m.data), "response"]);
453
453
  await c.close(); expect(h.state.websocketErrors).toEqual([]);
454
454
  });
455
+
456
+
457
+ test("an anchor observed before its predecessor is diagnosed as out of order", async () => {
458
+ const key: WebSocketKey<null> = { initial: null,
459
+ input(state, message) { return { state, key: message }; },
460
+ output(state) { return { state, anchorPreviousInput: true }; },
461
+ };
462
+ const hash = (value: string) => websocketMatcher(() => value)("x")!;
463
+ const h = await harness([{ request, inputMode: "anchors",
464
+ inputs: [hash("start"), hash("warmup"), hash("question")],
465
+ inputOccurrences: [1, 1, 1],
466
+ messages: [{ type: "text", data: "answer", afterInput: 3, delayMs: 0 }],
467
+ }], key);
468
+ const c = await h.connect();
469
+ await c.message("start");
470
+ await c.message("question");
471
+ await c.message("warmup");
472
+ await tick();
473
+ expect(c.sent).toEqual([]);
474
+ await c.close();
475
+ expect(h.state.websocketErrors.join()).toContain("input #3: required input arrived before preceding anchors matched");
476
+ expect(h.state.websocketErrors.join()).toContain("occurrence 1; observed 1 occurrences");
477
+ });
@@ -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,6 +4,7 @@ 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 { containerdImageOutput } from "./harness/built-image.js";
7
8
  import { componentBuildTasks, COMPONENT_RUNTIME } 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";
@@ -109,7 +110,7 @@ import {
109
110
  type GroupMeta,
110
111
  } from "./harness/catalogue.js";
111
112
  import { summarizeBuildKit, type BuildStep } from "./harness/buildkit-progress.js";
112
- import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
113
+ import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta, mergeServiceLogDeltas } from "./harness/log-delta.js";
113
114
  import {
114
115
  resolveHostPath as resolveVolumeHostPath,
115
116
  sanitizeSegment,
@@ -748,22 +749,6 @@ 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;
764
- }
765
-
766
-
767
752
  /** Host directory backing one volume mount. Rules (notably which volumes
768
753
  * survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
769
754
  function resolveHostPath(service: string, vol: VolumeMount): string {
@@ -1379,11 +1364,15 @@ async function runServiceBuild(
1379
1364
  // every cached parent before the first uncached step (11–79 s for
1380
1365
  // one `COPY` on Specific's dashboard) and a second full pass to
1381
1366
  // export after every miss.
1367
+ // Keep an immutable image reference as well as the service tag. The
1368
+ // containerd exporter replaces the tag directly, bypassing Docker's
1369
+ // dangling-image retention. Old versions must remain usable until our
1370
+ // image GC decides they are no longer needed (including by nested stores).
1382
1371
  buildArgs = [
1383
1372
  "buildx", "build",
1384
1373
  "--builder", LOCAL_BUILDER_NAME,
1385
1374
  "--progress=plain",
1386
- "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1375
+ "--output", containerdImageOutput(tag),
1387
1376
  ...argFlags,
1388
1377
  "-f", dfPath, contextDir,
1389
1378
  ];
@@ -1489,6 +1478,11 @@ async function runContainer(
1489
1478
  // container docker-native multi-label resolution for its `hostnames`.
1490
1479
  extraAliases: string[] = [],
1491
1480
  ): Promise<void> {
1481
+ // Preserve the previous incarnation before the name is reused.
1482
+ if (RUNTIME_SERVICES.has(svc.name)) await stopRuntimeService(svc.name);
1483
+ STOPPED_LOG_SERVICES.delete(svc.name);
1484
+ LOG_MARKERS.delete(`${svc.name}\0stdout`);
1485
+ LOG_MARKERS.delete(`${svc.name}\0stderr`);
1492
1486
  // Idempotent: clean up any leftover container with the same name.
1493
1487
  await docker(["rm", "-f", svc.name], 30_000);
1494
1488
 
@@ -2947,6 +2941,15 @@ async function stopRuntimeService(name: string): Promise<void> {
2947
2941
  const t0 = Date.now();
2948
2942
  const resv = reserveEvent();
2949
2943
  const svc = RUNTIME_SERVICES.get(name);
2944
+ // Freeze log production before reading, while Docker still owns the logs.
2945
+ // Keep force-stop semantics; removing first destroys the only copy.
2946
+ await docker(["kill", name], 30_000);
2947
+ if (!STOPPED_LOG_SERVICES.has(name)) {
2948
+ const delta = await captureServiceLogDelta(name, true);
2949
+ const previous = STOPPED_LOG_DELTAS.get(name);
2950
+ STOPPED_LOG_DELTAS.set(name, previous ? mergeServiceLogDeltas(previous, delta) : delta);
2951
+ STOPPED_LOG_SERVICES.add(name);
2952
+ }
2950
2953
  await docker(["rm", "-f", name], 30_000);
2951
2954
  RUNTIME_SERVICES.delete(name);
2952
2955
  for (const entry of svc?.tls ?? []) await unbindRuntimeTls(entry.hostname);
@@ -3482,6 +3485,10 @@ function parkCoverageBundle(
3482
3485
  * so it travels with the snapshot into every fork.
3483
3486
  */
3484
3487
  const LOG_MARKERS = new Map<string, { lines: number }>();
3488
+ // Fork with the harness, like markers. Deltas drain once per case; names remain
3489
+ // excluded from Docker reads until a replacement container starts.
3490
+ const STOPPED_LOG_DELTAS = new Map<string, ServiceLogDelta>();
3491
+ const STOPPED_LOG_SERVICES = new Set<string>();
3485
3492
 
3486
3493
  /** Per-(service, stream) delta byte cap. Over this we keep head+tail and
3487
3494
  * elide the middle — the head preserves the continuation from the parent,
@@ -3537,45 +3544,52 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
3537
3544
  const byName = new Map<string, NamedService>();
3538
3545
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
3539
3546
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
3540
- const services = [...byName.values()];
3541
- return Promise.all(
3542
- services.map(async (svc): Promise<ServiceLogDelta> => {
3543
- const [r, stop] = await Promise.all([
3544
- docker(["logs", "--timestamps", svc.name], 30_000),
3545
- containerStopState(svc.name),
3546
- ]);
3547
- if (r.code !== 0) {
3548
- // Container gone/renamed — surface the CLI error, leave markers put.
3549
- const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
3550
- return {
3551
- service: svc.name,
3552
- stdout: "",
3553
- stdoutTruncated: false,
3554
- stdoutReset: false,
3555
- stderr: err.value,
3556
- stderrTruncated: err.truncated,
3557
- stderrReset: false,
3558
- ...stop,
3559
- };
3560
- }
3561
- const outKey = `${svc.name}stdout`;
3562
- const errKey = `${svc.name}stderr`;
3563
- const out = streamDelta(r.stdout, LOG_MARKERS.get(outKey)?.lines ?? 0);
3564
- const err = streamDelta(r.stderr, LOG_MARKERS.get(errKey)?.lines ?? 0);
3565
- LOG_MARKERS.set(outKey, { lines: out.total });
3566
- LOG_MARKERS.set(errKey, { lines: err.total });
3567
- return {
3568
- service: svc.name,
3569
- stdout: out.delta,
3570
- stdoutTruncated: out.truncated,
3571
- stdoutReset: out.reset,
3572
- stderr: err.delta,
3573
- stderrTruncated: err.truncated,
3574
- stderrReset: err.reset,
3575
- ...stop,
3576
- };
3577
- }),
3578
- );
3547
+ const deltas = new Map(STOPPED_LOG_DELTAS);
3548
+ for (const delta of await Promise.all([...byName.keys()]
3549
+ .filter(name => !STOPPED_LOG_SERVICES.has(name)).map(name => captureServiceLogDelta(name)))) {
3550
+ const previous = deltas.get(delta.service);
3551
+ deltas.set(delta.service, previous ? mergeServiceLogDeltas(previous, delta) : delta);
3552
+ }
3553
+ STOPPED_LOG_DELTAS.clear();
3554
+ return [...deltas.values()];
3555
+ }
3556
+
3557
+ async function captureServiceLogDelta(name: string, final = false): Promise<ServiceLogDelta> {
3558
+ const [r, stop] = await Promise.all([
3559
+ docker(["logs", "--timestamps", name], 30_000),
3560
+ containerStopState(name),
3561
+ ]);
3562
+ if (r.code !== 0) {
3563
+ // Container gone/renamed — surface the CLI error, leave markers put.
3564
+ const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
3565
+ return {
3566
+ service: name,
3567
+ stdout: "",
3568
+ stdoutTruncated: false,
3569
+ stdoutReset: false,
3570
+ stderr: err.value,
3571
+ stderrTruncated: err.truncated,
3572
+ stderrReset: false,
3573
+ ...stop,
3574
+ };
3575
+ }
3576
+ const outKey = `${name}stdout`;
3577
+ const errKey = `${name}stderr`;
3578
+ const complete = (text: string) => final && text && !text.endsWith("\n") ? text + "\n" : text;
3579
+ const out = streamDelta(complete(r.stdout), LOG_MARKERS.get(outKey)?.lines ?? 0);
3580
+ const err = streamDelta(complete(r.stderr), LOG_MARKERS.get(errKey)?.lines ?? 0);
3581
+ LOG_MARKERS.set(outKey, { lines: out.total });
3582
+ LOG_MARKERS.set(errKey, { lines: err.total });
3583
+ return {
3584
+ service: name,
3585
+ stdout: out.delta,
3586
+ stdoutTruncated: out.truncated,
3587
+ stdoutReset: out.reset,
3588
+ stderr: err.delta,
3589
+ stderrTruncated: err.truncated,
3590
+ stderrReset: err.reset,
3591
+ ...stop,
3592
+ };
3579
3593
  }
3580
3594
 
3581
3595
  // ────────────────────────────────────────────────────────────────────────
@@ -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,12 @@ 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
+ export function containerdImageOutput(reference: string): string {
10
+ return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=true`;
11
+ }
12
+
7
13
  export interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
8
14
  interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
9
15
  const digest = (bytes: Uint8Array) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;