@specific.dev/spectest 0.131.12 → 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.
- 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 +6 -17
- package/dist/harness/built-image.d.ts +3 -0
- package/dist/harness/built-image.js +5 -0
- package/dist/harness/builtin-images.d.ts +29 -0
- package/dist/harness/builtin-images.js +29 -0
- package/dist/harness/registry-seed.d.ts +1 -1
- package/dist/harness/registry-seed.js +2 -1
- 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 +6 -17
- package/src/harness/built-image-docker.test.ts +62 -0
- package/src/harness/built-image.ts +6 -0
- package/src/harness/builtin-images.ts +29 -0
- package/src/harness/registry-seed.ts +2 -1
- 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 { readFileSync } from "node:fs";
|
|
2
3
|
import { posix } from "node:path";
|
|
3
4
|
import { serviceGroup } from "../index.js";
|
|
@@ -21,7 +22,7 @@ a = 'scrcpy_print_version(void) {'
|
|
|
21
22
|
assert s.count(a) == 1
|
|
22
23
|
p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
|
|
23
24
|
const shellQuote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
24
|
-
export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
|
|
25
|
+
export const ANDROID_TOOLS_DOCKERFILE = `FROM ${BUILTIN_IMAGES["node:22-bookworm-slim"]}
|
|
25
26
|
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/*
|
|
26
27
|
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*
|
|
27
28
|
ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
|
package/dist/components/aws.d.ts
CHANGED
|
@@ -105,7 +105,7 @@ export declare function aws(opts?: AwsOptions): {
|
|
|
105
105
|
inputs?: string[] | undefined;
|
|
106
106
|
image: {
|
|
107
107
|
type: "registry";
|
|
108
|
-
reference:
|
|
108
|
+
reference: "ministackorg/ministack:1.4.8@sha256:eb0a00c897ec91589eb78a77f0afaf13a6d7be99abfeca2d1d719a7ec76d3141";
|
|
109
109
|
};
|
|
110
110
|
env: {
|
|
111
111
|
AWS_DEFAULT_REGION: string;
|
package/dist/components/aws.js
CHANGED
|
@@ -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
|
//
|
|
@@ -64,7 +65,7 @@ import { deflateRawSync } from "node:zlib";
|
|
|
64
65
|
import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../index.js";
|
|
65
66
|
import { pauseRecording, resumeRecording } from "../recorder.js";
|
|
66
67
|
/** Backing image, pinned internally (not part of the documented surface). */
|
|
67
|
-
const AWS_IMAGE = "ministackorg/ministack:1.4.8";
|
|
68
|
+
const AWS_IMAGE = BUILTIN_IMAGES["ministackorg/ministack:1.4.8"];
|
|
68
69
|
/** Port the emulator serves every AWS API on. Internal: callers reach the
|
|
69
70
|
* emulator through the AWS hostnames, never through this port. */
|
|
70
71
|
const AWS_PORT = 4566;
|
|
@@ -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 { resolveProjectPath } from "../project-files.js";
|
|
@@ -104,7 +105,7 @@ export function electron(options = {}) {
|
|
|
104
105
|
type: "dockerfile",
|
|
105
106
|
context,
|
|
106
107
|
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
107
|
-
content: `FROM node:${node}
|
|
108
|
+
content: `FROM ${options.nodeVersion === undefined ? BUILTIN_IMAGES["node:24-bookworm-slim"] : `node:${node}`}
|
|
108
109
|
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/*
|
|
109
110
|
WORKDIR /app
|
|
110
111
|
COPY . .
|
package/dist/components/email.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { rawFetch } from "../harness/raw-fetch.js";
|
|
2
3
|
import { recordEmail, reserveEvent, truncateUtf8, } from "../recorder.js";
|
|
3
4
|
import { wrap } from "../inspect.js";
|
|
4
|
-
const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
|
|
5
|
+
const DEFAULT_IMAGE = BUILTIN_IMAGES["axllent/mailpit:v1.30"];
|
|
5
6
|
const SMTP_PORT = 1025;
|
|
6
7
|
const API_PORT = 8025;
|
|
7
8
|
/**
|
|
@@ -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
|
//
|
|
@@ -49,7 +50,7 @@ import path from "node:path";
|
|
|
49
50
|
import { fileURLToPath } from "node:url";
|
|
50
51
|
import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../../index.js";
|
|
51
52
|
/** Base image, pinned. Bun runs the emulators' ESM directly. */
|
|
52
|
-
const IMAGE_BASE = "oven/bun:1.3.14-alpine";
|
|
53
|
+
const IMAGE_BASE = BUILTIN_IMAGES["oven/bun:1.3.14-alpine"];
|
|
53
54
|
/** Emulator package version, pinned rather than floated. */
|
|
54
55
|
const EMULATE_VERSION = "0.9.0";
|
|
55
56
|
/** The port the provider is served on. Callers reach it through the
|
package/dist/components/expo.js
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
|
//
|
|
@@ -299,8 +300,8 @@ function publicEnvLines(env) {
|
|
|
299
300
|
* BuildKit, which every builder in a VM is.
|
|
300
301
|
*/
|
|
301
302
|
function generateDockerfile(input) {
|
|
302
|
-
const {
|
|
303
|
-
const lines = [`FROM
|
|
303
|
+
const { nodeImage, plan, publicEnv, outputDir } = input;
|
|
304
|
+
const lines = [`FROM ${nodeImage}`, "WORKDIR /app", "ENV CI=1", ...plan.env];
|
|
304
305
|
for (const step of plan.bootstrap)
|
|
305
306
|
lines.push(`RUN ${step}`);
|
|
306
307
|
lines.push(`COPY ${plan.manifests.join(" ")} ./`);
|
|
@@ -389,7 +390,7 @@ export function expo(opts = {}) {
|
|
|
389
390
|
image = {
|
|
390
391
|
type: "dockerfile",
|
|
391
392
|
content: generateDockerfile({
|
|
392
|
-
|
|
393
|
+
nodeImage: node.source === "the default" ? BUILTIN_IMAGES["node:22-bookworm-slim"] : `node:${node.tag}`,
|
|
393
394
|
plan,
|
|
394
395
|
publicEnv: publicEnvLines(opts.env ?? {}),
|
|
395
396
|
outputDir,
|
package/dist/components/k3s.js
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";
|
|
@@ -492,7 +493,7 @@ function withTagging(api) {
|
|
|
492
493
|
* `registries.yaml`, then captured into the warm-template snapshot so
|
|
493
494
|
* warm starts never pull.
|
|
494
495
|
*/
|
|
495
|
-
const TRAEFIK_IMAGE = "rancher/mirrored-library-traefik:3.3.2";
|
|
496
|
+
const TRAEFIK_IMAGE = BUILTIN_IMAGES["rancher/mirrored-library-traefik:3.3.2"];
|
|
496
497
|
/**
|
|
497
498
|
* Names of the in-cluster resources that carry the CA-signed default
|
|
498
499
|
* ingress certificate (created in `setupK3sCluster` when TLS is enabled).
|
|
@@ -730,7 +731,7 @@ spec:
|
|
|
730
731
|
- operator: Exists
|
|
731
732
|
containers:
|
|
732
733
|
- name: registry
|
|
733
|
-
image: ${
|
|
734
|
+
image: ${REGISTRY_IMAGE}
|
|
734
735
|
imagePullPolicy: IfNotPresent
|
|
735
736
|
env:
|
|
736
737
|
- name: REGISTRY_HTTP_ADDR
|
|
@@ -1476,7 +1477,7 @@ function createK3sCluster(opts, count) {
|
|
|
1476
1477
|
// checked all Nodes and finished loading images, not at API readiness.
|
|
1477
1478
|
const readyCmd = "kubectl get --raw=/readyz >/dev/null 2>&1";
|
|
1478
1479
|
const def = {
|
|
1479
|
-
image: { type: "registry", reference: `rancher/k3s:${version}` },
|
|
1480
|
+
image: { type: "registry", reference: opts.version === undefined ? BUILTIN_IMAGES["rancher/k3s:v1.33.5-k3s1"] : `rancher/k3s:${version}` },
|
|
1480
1481
|
imageBuilds: server ? images.builds : [],
|
|
1481
1482
|
tls: server && registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
|
|
1482
1483
|
command: cmd,
|
|
@@ -1588,7 +1589,7 @@ function createK3sCluster(opts, count) {
|
|
|
1588
1589
|
for (;;) {
|
|
1589
1590
|
try {
|
|
1590
1591
|
const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
|
|
1591
|
-
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
|
|
1592
|
+
metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: BUILTIN_IMAGES["registry.k8s.io/pause:3.10"] }] },
|
|
1592
1593
|
} })).unwrap();
|
|
1593
1594
|
if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
|
|
1594
1595
|
break;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { SQL } from "../sql.js";
|
|
2
3
|
import { postgresTls } from "./postgres-tls.js";
|
|
3
4
|
/**
|
|
@@ -26,8 +27,8 @@ import { postgresTls } from "./postgres-tls.js";
|
|
|
26
27
|
* ```
|
|
27
28
|
*/
|
|
28
29
|
export function postgres(opts) {
|
|
29
|
-
const
|
|
30
|
-
|
|
30
|
+
const reference = opts.image ?? (opts.version === undefined
|
|
31
|
+
? BUILTIN_IMAGES["postgres:18-alpine"] : `postgres:${opts.version}`);
|
|
31
32
|
const port = opts.port ?? 5432;
|
|
32
33
|
const persistent = opts.persistent ?? true;
|
|
33
34
|
const tls = opts.tls !== false ? postgresTls() : undefined;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
export const DEFAULT_POOLER_CONFIG = {
|
|
2
3
|
enabled: false,
|
|
3
4
|
port: 54329,
|
|
@@ -64,7 +65,7 @@ export function supabasePoolerService(opts) {
|
|
|
64
65
|
const { config, dbHost, tenantId, password, jwtSecret, tls, url } = opts;
|
|
65
66
|
const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`;
|
|
66
67
|
return {
|
|
67
|
-
image: { type: "registry", reference: "supabase/supavisor:2.9.12" },
|
|
68
|
+
image: { type: "registry", reference: BUILTIN_IMAGES["supabase/supavisor:2.9.12"] },
|
|
68
69
|
// eval starts the OTP application too. Give that temporary instance
|
|
69
70
|
// ephemeral SQL ports so readiness only sees the final server process.
|
|
70
71
|
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.
|
|
@@ -68,22 +69,22 @@ import { supabasePoolerService } from "./supabase-pooler.js";
|
|
|
68
69
|
import { SUPABASE_COVERAGE_COLLECTOR, supabaseFunctionsCoverage } from "./supabase-coverage.js";
|
|
69
70
|
import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
|
|
70
71
|
// ──────────────────────────────────────────────────────────────────────────
|
|
71
|
-
//
|
|
72
|
-
//
|
|
72
|
+
// Digest-pinned upstream images. Update BUILTIN_IMAGES together for compatible
|
|
73
|
+
// Supabase releases; each is pulled through the host Hub mirror on
|
|
73
74
|
// first cold boot and captured into the warm-template snapshot thereafter.
|
|
74
75
|
// ──────────────────────────────────────────────────────────────────────────
|
|
75
76
|
const IMAGES = {
|
|
76
|
-
db: "supabase/postgres:17.6.1.136",
|
|
77
|
-
kong: "kong/kong:3.9.1",
|
|
78
|
-
auth: "supabase/gotrue:v2.189.0",
|
|
79
|
-
rest: "postgrest/postgrest:v14.12",
|
|
80
|
-
realtime: "supabase/realtime:v2.102.3",
|
|
81
|
-
storage: "supabase/storage-api:v1.60.4",
|
|
82
|
-
imgproxy: "darthsim/imgproxy:v3.30.1",
|
|
83
|
-
functions: "supabase/edge-runtime:v1.74.0",
|
|
77
|
+
db: BUILTIN_IMAGES["supabase/postgres:17.6.1.136"],
|
|
78
|
+
kong: BUILTIN_IMAGES["kong/kong:3.9.1"],
|
|
79
|
+
auth: BUILTIN_IMAGES["supabase/gotrue:v2.189.0"],
|
|
80
|
+
rest: BUILTIN_IMAGES["postgrest/postgrest:v14.12"],
|
|
81
|
+
realtime: BUILTIN_IMAGES["supabase/realtime:v2.102.3"],
|
|
82
|
+
storage: BUILTIN_IMAGES["supabase/storage-api:v1.60.4"],
|
|
83
|
+
imgproxy: BUILTIN_IMAGES["darthsim/imgproxy:v3.30.1"],
|
|
84
|
+
functions: BUILTIN_IMAGES["supabase/edge-runtime:v1.74.0"],
|
|
84
85
|
// Serves custom mail templates to GoTrue over HTTP (see the `templates`
|
|
85
86
|
// part). Tiny, and only pulled by a stack that configures a template.
|
|
86
|
-
templates: "busybox:1.37",
|
|
87
|
+
templates: BUILTIN_IMAGES["busybox:1.37"],
|
|
87
88
|
};
|
|
88
89
|
/** Override tables without mutating the parsed project config. */
|
|
89
90
|
function mergeConfig(base, overrides) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
|
|
1
2
|
import { serviceGroup } from "../index.js";
|
|
2
3
|
import { rawFetch } from "../harness/raw-fetch.js";
|
|
3
4
|
import { readRaw } from "../inspect.js";
|
|
@@ -164,14 +165,14 @@ export function temporal(opts = {}) {
|
|
|
164
165
|
primary, expectKey: name,
|
|
165
166
|
services: () => {
|
|
166
167
|
const server = {
|
|
167
|
-
image: { type: "registry", reference: opts.image ?? "temporalio/temporal:1.9.1" }, args,
|
|
168
|
+
image: { type: "registry", reference: opts.image ?? BUILTIN_IMAGES["temporalio/temporal:1.9.1"] }, args,
|
|
168
169
|
ports: [port, httpPort, ...(opts.ui ? [8233] : [])],
|
|
169
170
|
readyCheck: { type: "http", port: httpPort, path: `/api/v1/namespaces/${encodeURIComponent(namespace)}`, timeoutSecs: 30 },
|
|
170
171
|
};
|
|
171
172
|
if (!opts.tls)
|
|
172
173
|
return { server };
|
|
173
174
|
return { server, tls: {
|
|
174
|
-
image: { type: "registry", reference: "nginx:1.29.6-alpine" },
|
|
175
|
+
image: { type: "registry", reference: BUILTIN_IMAGES["nginx:1.29.6-alpine"] },
|
|
175
176
|
ports: [port], hostnames: [opts.tls.hostname], dependsOn: [serverName],
|
|
176
177
|
certificates: [{ hostnames: [opts.tls.hostname], certPath: "/tls/server.crt", keyPath: "/tls/server.key" }],
|
|
177
178
|
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/dist/daemon.js
CHANGED
|
@@ -4,6 +4,7 @@ import { collectSecretRefs } from "./fake-upstream.js";
|
|
|
4
4
|
import { phoneScope, phoneRecordingChunk, releasePhoneRecording } 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 } from "./harness/service-progress.js";
|
|
@@ -491,22 +492,6 @@ function ensureLocalBuildkitd() {
|
|
|
491
492
|
})();
|
|
492
493
|
return _localBuilder;
|
|
493
494
|
}
|
|
494
|
-
/**
|
|
495
|
-
* A reference as containerd names it. buildx's `-t` on a remote builder
|
|
496
|
-
* stores an unqualified name (`probe:bx`) that dockerd then cannot
|
|
497
|
-
* resolve (measured, CONTAINER_STORE.md), so the in-VM build names its
|
|
498
|
-
* output in full.
|
|
499
|
-
*/
|
|
500
|
-
function qualifyImageRef(ref) {
|
|
501
|
-
const slash = ref.indexOf("/");
|
|
502
|
-
const first = slash < 0 ? "" : ref.slice(0, slash);
|
|
503
|
-
const isRegistry = first.includes(".") || first.includes(":") || first === "localhost";
|
|
504
|
-
if (slash < 0)
|
|
505
|
-
return `docker.io/library/${ref}`;
|
|
506
|
-
if (!isRegistry)
|
|
507
|
-
return `docker.io/${ref}`;
|
|
508
|
-
return ref;
|
|
509
|
-
}
|
|
510
495
|
/** Host directory backing one volume mount. Rules (notably which volumes
|
|
511
496
|
* survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
|
|
512
497
|
function resolveHostPath(service, vol) {
|
|
@@ -1053,11 +1038,15 @@ async function runServiceBuild(name, image, tag) {
|
|
|
1053
1038
|
// every cached parent before the first uncached step (11–79 s for
|
|
1054
1039
|
// one `COPY` on Specific's dashboard) and a second full pass to
|
|
1055
1040
|
// export after every miss.
|
|
1041
|
+
// Keep an immutable image reference as well as the service tag. The
|
|
1042
|
+
// containerd exporter replaces the tag directly, bypassing Docker's
|
|
1043
|
+
// dangling-image retention. Old versions must remain usable until our
|
|
1044
|
+
// image GC decides they are no longer needed (including by nested stores).
|
|
1056
1045
|
buildArgs = [
|
|
1057
1046
|
"buildx", "build",
|
|
1058
1047
|
"--builder", LOCAL_BUILDER_NAME,
|
|
1059
1048
|
"--progress=plain",
|
|
1060
|
-
"--output",
|
|
1049
|
+
"--output", containerdImageOutput(tag),
|
|
1061
1050
|
...argFlags,
|
|
1062
1051
|
"-f", dfPath, contextDir,
|
|
1063
1052
|
];
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Containerd exports replace mutable tags without Docker's dangling-image
|
|
2
|
+
* retention. Keep each result addressable until the SDK's image GC evicts it. */
|
|
3
|
+
export declare function containerdImageOutput(reference: string): string;
|
|
1
4
|
export interface Descriptor {
|
|
2
5
|
digest: string;
|
|
3
6
|
size: number;
|
|
@@ -3,6 +3,11 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { canonicalImageReference } from "./component-images.js";
|
|
6
|
+
/** Containerd exports replace mutable tags without Docker's dangling-image
|
|
7
|
+
* retention. Keep each result addressable until the SDK's image GC evicts it. */
|
|
8
|
+
export function containerdImageOutput(reference) {
|
|
9
|
+
return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=true`;
|
|
10
|
+
}
|
|
6
11
|
const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
7
12
|
const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
8
13
|
const run = promisify(execFile);
|
|
@@ -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 declare const BUILTIN_IMAGES: {
|
|
7
|
+
readonly "postgres:18-alpine": "postgres:18-alpine@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5";
|
|
8
|
+
readonly "rancher/k3s:v1.33.5-k3s1": "rancher/k3s:v1.33.5-k3s1@sha256:fd4740667b7033055c27d424d0d2d660bf66cedbdb225d68e0eab6dd48aa0fd2";
|
|
9
|
+
readonly "rancher/mirrored-library-traefik:3.3.2": "rancher/mirrored-library-traefik:3.3.2@sha256:a03ee2f858f77975fa943f6797da5446b7ac9a83beef6bbdae2afd8feaf67b76";
|
|
10
|
+
readonly "registry:2.8.3": "registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373";
|
|
11
|
+
readonly "registry.k8s.io/pause:3.10": "registry.k8s.io/pause:3.10@sha256:ee6521f290b2168b6e0935a181d4cff9be1ac3f505666ef0e3c98fae8199917a";
|
|
12
|
+
readonly "node:22-bookworm-slim": "node:22-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da";
|
|
13
|
+
readonly "node:24-bookworm-slim": "node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c";
|
|
14
|
+
readonly "oven/bun:1.3.14-alpine": "oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0";
|
|
15
|
+
readonly "axllent/mailpit:v1.30": "axllent/mailpit:v1.30@sha256:d5ecbb067db3705fa953d79e1b7f81ef84038df67aba6c52825d8c02a1ea748a";
|
|
16
|
+
readonly "ministackorg/ministack:1.4.8": "ministackorg/ministack:1.4.8@sha256:eb0a00c897ec91589eb78a77f0afaf13a6d7be99abfeca2d1d719a7ec76d3141";
|
|
17
|
+
readonly "temporalio/temporal:1.9.1": "temporalio/temporal:1.9.1@sha256:ad4c82c97bd12b417d1ea942610dbcd511afb250c4d5ed26c694009533df447e";
|
|
18
|
+
readonly "nginx:1.29.6-alpine": "nginx:1.29.6-alpine@sha256:f46cb72c7df02710e693e863a983ac42f6a9579058a59a35f1ae36c9958e4ce0";
|
|
19
|
+
readonly "supabase/supavisor:2.9.12": "supabase/supavisor:2.9.12@sha256:464b93a60ba8c96bc06dad7f241562d6613b2a994922307717b05f432ae415c2";
|
|
20
|
+
readonly "supabase/postgres:17.6.1.136": "supabase/postgres:17.6.1.136@sha256:f371b5f3f2ac0a05703f33d6e6134515fb2498cab708fb948a0aeb7481467c00";
|
|
21
|
+
readonly "kong/kong:3.9.1": "kong/kong:3.9.1@sha256:6addf50e6bd8d578314cb9ce4f2d2d1e3781d2edecef59f707e00c6e05d384f5";
|
|
22
|
+
readonly "supabase/gotrue:v2.189.0": "supabase/gotrue:v2.189.0@sha256:385184459f57569c54c25209f51f3b2be99ddd7c4ce9e3555b5d3eea8447b7cf";
|
|
23
|
+
readonly "postgrest/postgrest:v14.12": "postgrest/postgrest:v14.12@sha256:54000f24847d01a2c2302e0041cf0618b875c57fb48507d743cfa9aaa50bf43c";
|
|
24
|
+
readonly "supabase/realtime:v2.102.3": "supabase/realtime:v2.102.3@sha256:aa1c92c0cf326007563641730ec9da9c60478caa6853887775365fa2c097a471";
|
|
25
|
+
readonly "supabase/storage-api:v1.60.4": "supabase/storage-api:v1.60.4@sha256:c8eb9858eafec891a97c27125470aaad54703c3f4eb4d55ca7f1bf6c6411febf";
|
|
26
|
+
readonly "darthsim/imgproxy:v3.30.1": "darthsim/imgproxy:v3.30.1@sha256:3b709e4a0e5e8e0e959b556b7031229202b4b8e7e7d955c517ea7abed68ee34d";
|
|
27
|
+
readonly "supabase/edge-runtime:v1.74.0": "supabase/edge-runtime:v1.74.0@sha256:2781daf92394db91f7e94129cc3d04ec474ad16a8fe64b3fbeef6e7d557ab120";
|
|
28
|
+
readonly "busybox:1.37": "busybox:1.37@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec";
|
|
29
|
+
};
|
|
@@ -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
|
+
};
|
|
@@ -3,7 +3,7 @@ import { type PreparedComponentImage } from "./component-images.js";
|
|
|
3
3
|
export declare const REGISTRY_DATA = "/var/lib/spectest-registry";
|
|
4
4
|
export declare const REGISTRY_CONTENT = "/spectest-registry-content";
|
|
5
5
|
export declare const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
|
|
6
|
-
export declare const REGISTRY_IMAGE
|
|
6
|
+
export declare const REGISTRY_IMAGE: "registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373";
|
|
7
7
|
export declare const registryDirectory: (service: string) => string;
|
|
8
8
|
/** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
|
|
9
9
|
* before starting the registry so it cannot cache partial publication.
|
|
@@ -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. */
|
|
@@ -8,7 +9,7 @@ import { canonicalImageReference } from "./component-images.js";
|
|
|
8
9
|
export const REGISTRY_DATA = "/var/lib/spectest-registry";
|
|
9
10
|
export const REGISTRY_CONTENT = "/spectest-registry-content";
|
|
10
11
|
export const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
|
|
11
|
-
export const REGISTRY_IMAGE = "registry:2.8.3";
|
|
12
|
+
export const REGISTRY_IMAGE = BUILTIN_IMAGES["registry:2.8.3"];
|
|
12
13
|
export const registryDirectory = (service) => `/var/lib/spectest-registries/${service}`;
|
|
13
14
|
/** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
|
|
14
15
|
* before starting the registry so it cannot cache partial publication.
|
|
@@ -111,6 +111,10 @@ export async function openKubernetesCacheMaintenance(run, root = ROOT) {
|
|
|
111
111
|
throw error;
|
|
112
112
|
});
|
|
113
113
|
try {
|
|
114
|
+
// A successful inventory distinguishes orphaned legacy stores from Docker
|
|
115
|
+
// outages. Only this isolated maintenance copy may discard a store.
|
|
116
|
+
const available = new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
|
|
117
|
+
.split(/\s+/).filter(Boolean));
|
|
114
118
|
for (const service of entries) {
|
|
115
119
|
if (!service.isDirectory() || !SERVICE_NAME.test(service.name))
|
|
116
120
|
continue;
|
|
@@ -122,6 +126,14 @@ export async function openKubernetesCacheMaintenance(run, root = ROOT) {
|
|
|
122
126
|
if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
|
|
123
127
|
throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
|
|
124
128
|
}
|
|
129
|
+
if (!available.has(descriptor.image)) {
|
|
130
|
+
// Older exporters replaced their sole image reference on rebuild.
|
|
131
|
+
// An obsolete version's store can outlive that image. It is disposable
|
|
132
|
+
// cache, and must not prevent publishing the healthy stores.
|
|
133
|
+
console.warn(`Discarding orphaned Kubernetes cache ${directory}: missing runtime image ${descriptor.image}`);
|
|
134
|
+
await run("rm", ["-rf", directory]);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
125
137
|
const container = `spectest-k8s-cache-${randomUUID()}`;
|
|
126
138
|
await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
|
|
127
139
|
"--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
|
package/package.json
CHANGED
|
@@ -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
|
package/src/components/aws.ts
CHANGED
|
@@ -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 . .
|
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,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";
|
|
@@ -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",
|
|
1375
|
+
"--output", containerdImageOutput(tag),
|
|
1387
1376
|
...argFlags,
|
|
1388
1377
|
"-f", dfPath, contextDir,
|
|
1389
1378
|
];
|
|
@@ -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")}`;
|
|
@@ -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,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
|
|
@@ -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
|
+
});
|
|
@@ -119,6 +119,10 @@ export async function openKubernetesCacheMaintenance(run: CacheCommand, root = R
|
|
|
119
119
|
throw error;
|
|
120
120
|
});
|
|
121
121
|
try {
|
|
122
|
+
// A successful inventory distinguishes orphaned legacy stores from Docker
|
|
123
|
+
// outages. Only this isolated maintenance copy may discard a store.
|
|
124
|
+
const available = new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
|
|
125
|
+
.split(/\s+/).filter(Boolean));
|
|
122
126
|
for (const service of entries) {
|
|
123
127
|
if (!service.isDirectory() || !SERVICE_NAME.test(service.name)) continue;
|
|
124
128
|
for (const layout of await readdir(`${root}/${service.name}`, { withFileTypes: true })) {
|
|
@@ -128,6 +132,14 @@ export async function openKubernetesCacheMaintenance(run: CacheCommand, root = R
|
|
|
128
132
|
if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
|
|
129
133
|
throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
|
|
130
134
|
}
|
|
135
|
+
if (!available.has(descriptor.image)) {
|
|
136
|
+
// Older exporters replaced their sole image reference on rebuild.
|
|
137
|
+
// An obsolete version's store can outlive that image. It is disposable
|
|
138
|
+
// cache, and must not prevent publishing the healthy stores.
|
|
139
|
+
console.warn(`Discarding orphaned Kubernetes cache ${directory}: missing runtime image ${descriptor.image}`);
|
|
140
|
+
await run("rm", ["-rf", directory]);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
131
143
|
const container = `spectest-k8s-cache-${randomUUID()}`;
|
|
132
144
|
await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
|
|
133
145
|
"--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
|