@specific.dev/spectest 0.119.5 → 0.120.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,13 +40,6 @@ import type {
40
40
  SpectestContext,
41
41
  } from "../index.js";
42
42
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
43
- import {
44
- MKFS_EROFS_PATH,
45
- STORE_ADOPT_PATH,
46
- imageCachePathsSync,
47
- nestedStoreDir,
48
- type ImageCachePaths,
49
- } from "../harness/image-cache.js";
50
43
  import { stickyDiskPathSync } from "../harness/sticky-paths.js";
51
44
  import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
52
45
  import { prePullK3sImages } from "../harness/k3s-prepull.js";
@@ -88,10 +81,7 @@ export interface K3sOptions {
88
81
  images?: readonly KubernetesImage[];
89
82
  /**
90
83
  * Image tag for the official `rancher/k3s` image. Default
91
- * `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
92
- * containerd (1.7) without the EROFS snapshotter, so its cluster keeps
93
- * its images on overlayfs and shares the environment's image cache only
94
- * as a directory that survives — see `k3s()`'s store section.
84
+ * `"v1.33.5-k3s1"`. The cluster keeps its image store on overlayfs.
95
85
  */
96
86
  version?: string;
97
87
  /**
@@ -197,15 +187,8 @@ const K3S_REGISTRY_PORT = 5000;
197
187
 
198
188
  /** The cluster's own containerd root, inside the k3s container. */
199
189
  const CLUSTER_STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
200
- /** Where k3s (containerd 2.x layout) reads a containerd config template. */
201
- const CONTAINERD_TEMPLATE_PATH = "/var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.tmpl";
202
- /** The EROFS settings that template imports; the store's prepare daemon
203
- * imports the same file, so both daemons agree on the snapshotter. */
204
- const CONTAINERD_EROFS_CONFIG_PATH = "/etc/spectest/containerd-erofs.toml";
205
190
  /** The socket of the throwaway daemon that prepares the store. */
206
191
  const PREPARE_SOCKET = "/run/spectest-prepare.sock";
207
- /** containerd's CRI plugin lists only images carrying this label. */
208
- const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
209
192
 
210
193
  /**
211
194
  * Written before the server starts and removed by the ready probe.
@@ -223,78 +206,6 @@ const BOOT_MARKER = ".spectest-boot-incomplete";
223
206
  * sticky-kubernetes adapter removes runtime state and retains images and
224
207
  * unpacked layers under the shared filesystem budget. Test forks do not
225
208
  * write back. The preparation wrapper below also handles delta rebuilds.
226
- *
227
- * Legacy image-cache integration (`CONTAINER_STORE.md` §12):
228
- *
229
- * The cluster's embedded containerd keeps its root on the **root disk**
230
- * of the environment's image cache, in a directory of its own
231
- * (`<root>/spectest-nested/<service>`, bind-mounted at
232
- * `CLUSTER_STORE_TARGET`), and runs the **EROFS snapshotter** over the
233
- * same read-only **layers disk** the guest's own containerd uses,
234
- * mounted into the container at the same path. So a layer is one file
235
- * on the host whichever runtime holds it, the cluster's pulls land in
236
- * the lineage's root clone like a `docker pull` does, and the merge
237
- * reads the cluster's indexes exactly as it reads dockerd's — at every
238
- * VM teardown, whether the pull happened at env boot or in the middle
239
- * of a test. Nothing distinguishes the two: a fork's root clone grew,
240
- * so it is harvested. The next cold build's cluster **adopts** what the
241
- * merge put on the layers disk under its own namespace (`k8s.io`) with
242
- * the same helper dockerd's store uses, before the cluster starts.
243
- *
244
- * Extraction is 48x the I/O of the download (`STICKY_DISKS.md` §7.2),
245
- * which is why a mirror never helped here and an extracted, shared
246
- * store is the whole prize. Measured on the one project on the old
247
- * per-environment store: 20.6 s of cluster bring-up per build.
248
- *
249
- * Three things the container needs for it, all mounted by `k3s()`:
250
- * golden's static `mkfs.erofs` (the EROFS differ needs it and no k3s
251
- * image ships one); the adopt helper (POSIX sh, run by busybox); and a
252
- * containerd config template that imports the EROFS settings — k3s
253
- * generates its config from `config-v3.toml.tmpl` when present, and an
254
- * `imports` line on top of the stock template is the one way to add
255
- * plugin sections without colliding with the ones it writes. The
256
- * EROFS snapshotter mounts every layer through a loop device, and a
257
- * privileged container's `/dev` is a static copy that never sees the
258
- * nodes the kernel allocates later, so the wrapper pre-creates 1024 of
259
- * them (measured: 0 s).
260
- *
261
- * What the store inherits with the cache is one build's crash state:
262
- * the root was captured from a running guest, and the teardown's
263
- * `docker rm -f` killed that cluster where it stood. **Deleting the dead
264
- * files is not enough, and the failure is not subtle.** containerd's
265
- * index still lists the previous build's containers, so the CRI plugin
266
- * offers them to a kubelet whose API server has never heard of them; the
267
- * kubelet spends its startup reconciling containers whose tasks are gone
268
- * (`Failed to create existing container … task not found`) and does not
269
- * register its own Node in time, and the CSI plugin — which waits on that
270
- * Node with a fixed budget — calls `Fatalf`. The kubelet dies, k3s exits
271
- * 2, and what the author sees is a rollout that timed out against a
272
- * hostname that no longer resolves. Measured: 2 of 2 delta restores,
273
- * against 2 of 2 clean without the store.
274
- *
275
- * So a **throwaway daemon** on the store, with the CRI plugin disabled,
276
- * does both jobs before k3s starts: it deletes the previous build's
277
- * containers and their tasks through containerd itself (the only thing
278
- * that can edit its index), and it adopts the inbox. Images, content
279
- * blobs and extracted snapshots — the whole cache — stay. It is the same
280
- * discipline `spectest-image-cache-up` applies to the guest's own store
281
- * one level up.
282
- *
283
- * Two failures cost a cache and never a build:
284
- *
285
- * 1. **A sweep that cannot run wipes the store.** A daemon that will not
286
- * start leaves behind exactly the containers this cluster fatals on,
287
- * so carrying on is the one option that is known bad.
288
- * 2. **A store that did not work is never inherited twice.** The marker
289
- * is written before the server starts and removed by the ready probe,
290
- * so it survives only when a boot never reached a live API server.
291
- * That is the backstop for the sweep being incomplete in some way we
292
- * have not seen: the cost is one cold build's pulls, and it repairs
293
- * itself.
294
- *
295
- * Without an image cache (the fake backend, a server that predates it)
296
- * the store is a per-environment volume as before, on overlayfs, with
297
- * the same sweep.
298
209
  */
299
210
  /** Plugins the prepare daemon does not need. Every plugin is startup
300
211
  * time, and the daemon sits on the critical path of the cluster's boot.
@@ -314,39 +225,9 @@ const SWEEP_DISABLED_PLUGINS = [
314
225
 
315
226
  const DISABLED_PLUGINS_TOML = `disabled_plugins = [${SWEEP_DISABLED_PLUGINS.map((p) => `"${p}"`).join(", ")}]`;
316
227
 
317
- /** The containerd config template: the stock one, importing our EROFS
318
- * settings. `imports` is a top-level key and has to come first. */
319
- const CONTAINERD_TEMPLATE = `imports = ["${CONTAINERD_EROFS_CONFIG_PATH}"]\n{{ template "base" . }}\n`;
320
-
321
- /**
322
- * containerd's EROFS settings, the same golden bakes for the guest's
323
- * own daemon (`local-vms-rootfs.Dockerfile`): the differ first, the
324
- * transfer service unpacking onto EROFS, and `set_immutable` off because
325
- * a committed layer's file is a symlink into a read-only disk, on which
326
- * the immutable ioctl would fail the commit.
327
- */
328
- function erofsConfigToml(): string {
329
- const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : process.arch;
330
- return [
331
- "version = 3",
332
- '[plugins."io.containerd.snapshotter.v1.erofs"]',
333
- " set_immutable = false",
334
- '[plugins."io.containerd.differ.v1.erofs"]',
335
- ' mkfs_options = ["-T0", "--mkfs-time", "--sort=none"]',
336
- '[plugins."io.containerd.service.v1.diff-service"]',
337
- ' default = ["erofs","walking"]',
338
- '[plugins."io.containerd.transfer.v1.local"]',
339
- ' [[plugins."io.containerd.transfer.v1.local".unpack_config]]',
340
- ` platform = "linux/${arch}"`,
341
- ' snapshotter = "erofs"',
342
- ' differ = "erofs"',
343
- "",
344
- ].join("\n");
345
- }
346
-
347
228
  /**
348
229
  * The store-preparation half of the command wrapper: sweep the crash
349
- * state, adopt the cache's inbox (image-cache mode), leave `SNAP` set to
230
+ * state and leave `SNAP` set to
350
231
  * the snapshotter the cluster starts on.
351
232
  *
352
233
  * Runs under busybox `sh`. Every `ctr` call is one invocation for the
@@ -355,26 +236,11 @@ function erofsConfigToml(): string {
355
236
  * per-container form spent seconds of the cluster's boot on process
356
237
  * starts alone (measured: ready 3.6 s -> 7.0 s).
357
238
  */
358
- function storePrepareSh(cache: ImageCachePaths | null): string {
359
- const daemonConfig = cache
360
- ? // containerd 2.x (v3 config, EROFS) when the image's containerd has
361
- // the snapshotter; a 1.7 image (k3s < 1.33) gets the v2 shape it
362
- // understands and stays on overlayfs.
363
- `if [ "$EROFS" = 1 ]; then printf '%s\\n' 'version = 3' 'imports = ["${CONTAINERD_EROFS_CONFIG_PATH}"]' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; ` +
364
- `else printf '%s\\n' 'version = 2' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; fi; `
365
- : `printf '%s\\n' 'version = 2' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; `;
366
- const detect = cache
367
- ? `EROFS=0; if containerd config default 2>/dev/null | grep -q 'snapshotter.v1.erofs'; then EROFS=1; fi; ` +
368
- `if [ "$EROFS" = 1 ]; then SNAP=erofs; i=0; while [ $i -lt 1024 ]; do [ -e /dev/loop$i ] || mknod /dev/loop$i b 7 $i 2>/dev/null; i=$((i+1)); done; ` +
369
- `else SNAP=overlayfs; echo 'spectest: this k3s image has no EROFS snapshotter; the cluster keeps its images on overlayfs and shares the image cache as a directory only' >&2; fi; `
370
- : `EROFS=0; SNAP=overlayfs; `;
371
- const adopt = cache
372
- ? `adopt_store() { [ "$EROFS" = 1 ] && [ -f "$L/spectest-inbox/layers.txt" ] || return 0; ` +
373
- `${STORE_ADOPT_PATH} "$S" "$L" -n k8s.io -a "$A" -l ${CRI_MANAGED_LABEL} 2>&1 | tail -1; }; `
374
- : `adopt_store() { :; }; `;
239
+ function storePrepareSh(): string {
240
+ const daemonConfig = `printf '%s\\n' 'version = 2' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; `;
375
241
  return (
376
- `S=${CLUSTER_STORE_TARGET}; M="$S/${BOOT_MARKER}"; A=${PREPARE_SOCKET}; L=${cache ? cache.layers : "/nonexistent"}; ` +
377
- detect +
242
+ `S=${CLUSTER_STORE_TARGET}; M="$S/${BOOT_MARKER}"; A=${PREPARE_SOCKET}; ` +
243
+ `SNAP=overlayfs; ` +
378
244
  `start_daemon() { ` +
379
245
  daemonConfig +
380
246
  `containerd -c /run/spectest-prepare.toml --root "$S" --state /run/spectest-prepare ` +
@@ -393,16 +259,15 @@ function storePrepareSh(cache: ImageCachePaths | null): string {
393
259
  // k3s ships they are ordinary containers and this is a no-op.
394
260
  `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
395
261
  `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
396
- adopt +
397
262
  `if [ -e "$M" ]; then ` +
398
263
  `echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
399
264
  `rm -rf "$S"/* "$S"/.[!.]* 2>/dev/null || true; fi; ` +
400
265
  `mkdir -p "$S"; ` +
401
266
  `had_state=0; [ -d "$S/io.containerd.metadata.v1.bolt" ] && had_state=1; ` +
402
267
  `T0=$(date +%s); ` +
403
- `if [ "$had_state" = 1 ] || [ "$EROFS" = 1 ]; then ` +
268
+ `if [ "$had_state" = 1 ]; then ` +
404
269
  `if start_daemon; then ` +
405
- `[ "$had_state" = 1 ] && sweep_store; adopt_store; stop_daemon; ` +
270
+ `[ "$had_state" = 1 ] && sweep_store; stop_daemon; ` +
406
271
  // Timed and printed always: this is inside the cluster's ready time,
407
272
  // so it is the first thing to suspect if that time moves.
408
273
  `echo "spectest: cluster image store at $S ready on $SNAP (prepared in $(($(date +%s)-T0))s)"; ` +
@@ -2084,12 +1949,6 @@ async function collectDiagnostics(
2084
1949
  * so neither cold-cache nor warm starts re-pull. Any `opts.version`
2085
1950
  * works — there's no base-snapshot release to keep in sync with.
2086
1951
  *
2087
- * **Why v1.33.x:** k3s 1.33.5 is the first line embedding containerd
2088
- * 2.1 (2.1.4-k3s1), whose in-tree EROFS snapshotter is what lets the
2089
- * cluster share the environment's image cache (see the store section
2090
- * above); 1.32 ships containerd 1.7. kube-proxy's `nftables` proxy mode
2091
- * is GA since k8s 1.32 (beta in 1.31, alpha-gated in 1.30); dropping
2092
- * below 1.31 falls back to the iptables path.
2093
1952
  */
2094
1953
  const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
2095
1954
 
@@ -2119,12 +1978,9 @@ export function k3s(opts: K3sOptions = {}) {
2119
1978
  // the in-cluster registry). Seeded via `files` because k3s reads it
2120
1979
  // only at startup, before any setup hook could run.
2121
1980
  const registriesYaml = buildRegistriesYaml(registryEnabled, registryHost);
2122
- // The environment's image cache, when this VM carries one: the
2123
- // cluster's store goes on its root disk and its layers come off the
2124
- // shared layers disk (see the store section above).
1981
+ // Pre-pulls read content from the environment's containerd store.
2125
1982
  const sticky = stickyDiskPathSync();
2126
- const cache = sticky ? null : imageCachePathsSync();
2127
- const contentDirectory = `${sticky ? `${sticky}/containerd` : cache?.root ?? "/var/lib/containerd"}/${CONTENT_BLOBS}`;
1983
+ const contentDirectory = `${sticky ? `${sticky}/containerd` : "/var/lib/containerd"}/${CONTENT_BLOBS}`;
2128
1984
  const serverArgs = [
2129
1985
  "k3s",
2130
1986
  "server",
@@ -2207,9 +2063,8 @@ export function k3s(opts: K3sOptions = {}) {
2207
2063
  // is affected.
2208
2064
  "mount --make-rshared / 2>/dev/null || " +
2209
2065
  "echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
2210
- storePrepareSh(cache) +
2211
- // The snapshotter is decided by the wrapper: EROFS on the image cache
2212
- // when the image's containerd has it, overlayfs otherwise.
2066
+ storePrepareSh() +
2067
+ // Sticky and environment-local stores use overlayfs.
2213
2068
  `exec ${serverArgs} --snapshotter=$SNAP`;
2214
2069
  // Plain /readyz probe. On a warm zot cache the cluster's images are
2215
2070
  // already local, so the first boot completes in seconds; the
@@ -2239,21 +2094,8 @@ export function k3s(opts: K3sOptions = {}) {
2239
2094
  // in this list (it's documentation, not a firewall).
2240
2095
  ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
2241
2096
  // Sticky disks retain an independent overlayfs store per service/version.
2242
- // The legacy image cache (see the store section above) keeps its
2243
- // containerd root on the image cache's root disk, the shared layers
2244
- // disk read-only at its guest path, golden's mkfs.erofs and the adopt
2245
- // helper — or, with no cache, a per-environment volume. Two clusters
2246
- // in one environment get separate roots either way (the service
2247
- // token names the directory), because two containerd daemons cannot
2248
- // share a store: bolt takes an exclusive lock.
2249
2097
  volumes: [...(sticky
2250
2098
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2251
- : cache ? [
2252
- { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
2253
- { source: cache.layers, target: cache.layers, readOnly: true },
2254
- { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
2255
- { source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
2256
- ]
2257
2099
  : [{ target: CLUSTER_STORE_TARGET }]),
2258
2100
  ...(publishBuilds.length ? [
2259
2101
  { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
@@ -2262,12 +2104,6 @@ export function k3s(opts: K3sOptions = {}) {
2262
2104
  ],
2263
2105
  files: [
2264
2106
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
2265
- ...(cache
2266
- ? [
2267
- { path: CONTAINERD_TEMPLATE_PATH, content: CONTAINERD_TEMPLATE },
2268
- { path: CONTAINERD_EROFS_CONFIG_PATH, content: erofsConfigToml() },
2269
- ]
2270
- : []),
2271
2107
  ],
2272
2108
  readyCheck: {
2273
2109
  type: "exec" as const,
@@ -1,7 +1,6 @@
1
1
  import { describe, test, expect } from "bun:test";
2
2
  import { next, NEXT_COVERAGE_HELPER, NEXT_COVERAGE_HELPER_PATH, type NextBuildMetadata } from "./coverage-next.js";
3
3
  import { applyCoverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, node, browser, type CoverageSourceContext } from "./coverage.js";
4
- import { withCoverage } from "./next.js";
5
4
  import { applyCoverageDelta, encodeCoverageBundle } from "./harness/coverage.js";
6
5
  import { gunzipSync } from "node:zlib";
7
6
  import { promises as fs } from "node:fs";
@@ -64,49 +63,6 @@ describe("Next adapter", () => {
64
63
  expect(()=>attachCoverageSourceOptions({reports:[{name:"browser.lcov"}]},duplicate)).toThrow(/multiple adapters/);
65
64
  expect(JSON.parse(JSON.stringify(applyCoverageAdapters("web",{image,coverage:{adapters}}))).coverage.adapters[1].sourceOptions.sourceRoot).toBe("client");
66
65
  });
67
- test("build helper preserves user settings and includes standalone maps", () => {
68
- const c=withCoverage({output:"standalone",distDir:"build",experimental:{cpus:2},outputFileTracingIncludes:{"/*":["./assets/*"],"/api/*":["./data/*"]}}) as any;
69
- expect(c.experimental).toEqual({cpus:2,serverSourceMaps:true});
70
- expect(c.productionBrowserSourceMaps).toBe(true);
71
- expect(c.outputFileTracingIncludes).toEqual({"/*":["./assets/*"],"/api/*":["./data/*"],"/**":["./build/server/**/*.map"]});
72
- });
73
- test("Webpack helper preserves hooks and disables dependency maps only", () => {
74
- let called = false;
75
- const config = withCoverage({ webpack(c: any) { called = true; return {...c, marker: true}; } });
76
- const compiled = config.webpack({plugins: []});
77
- expect(called).toBe(true);
78
- expect(compiled.marker).toBe(true);
79
- let loaderHook: (loader: any, module: any) => void;
80
- compiled.plugins[0].apply({
81
- hooks: {compilation: {tap(_name: string, f: Function) { f({}); }}},
82
- webpack: {NormalModule: {getCompilationHooks() { return {
83
- loader: {tap(_name: string, f: typeof loaderHook) { loaderHook = f; }},
84
- }; }}},
85
- });
86
- for (const resource of ["/app/app/page.tsx", "/workspace/shared/index.ts", ""]) {
87
- const loader = {sourceMap: true};
88
- loaderHook!(loader, {resource});
89
- expect(loader.sourceMap).toBe(true);
90
- }
91
- const dependency = {sourceMap: true};
92
- loaderHook!(dependency, {resource: "/app/node_modules/next/dist/client/router.js"});
93
- expect(dependency.sourceMap).toBe(false);
94
- });
95
- test("build hook retains traced maps and composes an existing compiler hook", async () => {
96
- const dir=await fs.mkdtemp(path.join(os.tmpdir(),"next-trace-"));
97
- try {
98
- let called=false;
99
- const config=withCoverage({compiler:{runAfterProductionCompile:async()=>{
100
- called=true;
101
- await fs.writeFile(path.join(dir,"page.js.map"),"{}");
102
- await fs.writeFile(path.join(dir,"chunk.js.map"),"{}");
103
- await fs.writeFile(path.join(dir,"page.js.nft.json"),JSON.stringify({version:1,files:["chunk.js","missing.js"]}));
104
- }}});
105
- await (config.compiler.runAfterProductionCompile as any)({projectDir:dir,distDir:dir});
106
- expect(called).toBe(true);
107
- expect(JSON.parse(await fs.readFile(path.join(dir,"page.js.nft.json"),"utf8")).files).toEqual(["chunk.js","missing.js","page.js.map","chunk.js.map"]);
108
- } finally {await fs.rm(dir,{recursive:true,force:true});}
109
- });
110
66
  test("runtime probe distinguishes generated launcher from authored custom server and checks maps", async () => {
111
67
  const dir=await fs.mkdtemp(path.join(os.tmpdir(),"next-cov-"));
112
68
  const helper=path.join(dir,"helper.cjs");
@@ -52,7 +52,7 @@ if (require.main === module) {
52
52
  }
53
53
  walk(path.join(dist, "server"), true);
54
54
  walk(path.join(dist, "static"), false);
55
- if (!serverMaps || !browserMaps) throw new Error("Next coverage needs server and browser .js.map files in the runtime image. Enable withCoverage from @specific.dev/spectest/next in the test build and retain .next/server and .next/static maps when copying standalone output.");
55
+ if (!serverMaps || !browserMaps) throw new Error("Next coverage needs server and browser .js.map files in the runtime image. Enable productionBrowserSourceMaps and experimental.serverSourceMaps in Next config, and copy .next/server and .next/static maps into the runtime image, including Edge maps.");
56
56
  process.stdout.write(JSON.stringify({generated:launcher(m), serverMaps, browserMaps}));
57
57
  } catch (e) { console.error("Next coverage: " + e.message); process.exitCode = 1; }
58
58
  }
@@ -75,7 +75,8 @@ const GENERATED_SOURCES = [
75
75
  ];
76
76
 
77
77
  /** Server + browser coverage for a built Next app. Build-time maps are required;
78
- * use withCoverage from @specific.dev/spectest/next in the Next configuration. */
78
+ * enable native Next source maps and retain them in the runtime image.
79
+ * Configure this adapter only in Spectest; the application needs no SDK dependency. */
79
80
  export function next(options: NextCoverageOptions = {}): CoverageAdapter {
80
81
  const sourceRoot = options.sourceRoot === undefined ? "." : options.sourceRoot;
81
82
  if (typeof sourceRoot !== "string" || !sourceRoot || /[\\:\0\r\n]/.test(sourceRoot) || (sourceRoot !== "." && sourceRoot.split("/").some(p => !p || p === "." || p === ".."))) throw new Error("coverage.next sourceRoot must be repository-relative (use \".\" for the repository root)");
package/src/daemon.ts CHANGED
@@ -169,16 +169,6 @@ import { startHttp2Proxy } from "./harness/http2-proxy.js";
169
169
  import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
170
170
  import { runContainerArgs } from "./harness/container-run.js";
171
171
  import { isOnStickyDisk } from "./harness/sticky-paths.js";
172
- import {
173
- BUILDKIT_CACHE_DIR,
174
- BUILDKIT_CACHE_UNION,
175
- IMAGE_CACHE_MANIFEST,
176
- imageCachePathsSync,
177
- isOnImageCache,
178
- mergeCacheIndex,
179
- type ImageCachePaths,
180
- type OciIndex,
181
- } from "./harness/image-cache.js";
182
172
  import {
183
173
  assertAbsolute,
184
174
  certificateHostnames,
@@ -741,171 +731,20 @@ async function hasBuildx(): Promise<boolean> {
741
731
  return _buildxAvailable;
742
732
  }
743
733
 
744
- // Every VM carries its project's container store (CONTAINER_STORE.md):
745
- // the disk every pull and every build lands on, mounted by the control
746
- // plane before this harness starts. The manifest below says where; the
747
- // in-VM buildkitd keeps its exported cache there too, so a fresh VM finds
748
- // every layer it built before. Detected once; if the daemon will not
749
- // start, dockerd's own BuildKit builds instead.
750
734
  const LOCAL_BUILDER_NAME = "spectest-local";
751
- const LOCAL_BUILDKIT_ADDR = "tcp://127.0.0.1:1234";
752
- /** The bring-up script the cache base bakes (`base.rs::BUILDKITD_UP_SH`). */
753
- const BUILDKITD_UP_PATH = "/usr/local/bin/spectest-buildkitd-up";
754
-
755
- /** Where the control plane mounted this VM's image cache: containerd's
756
- * root (read-write, this VM's own) and the layers disk (read-only, shared
757
- * by every VM of a generation). `null` when the VM carries no cache. */
758
- async function imageCachePaths(): Promise<ImageCachePaths | null> {
759
- return imageCachePathsSync(IMAGE_CACHE_MANIFEST);
760
- }
761
735
 
762
- /**
763
- * The bring-up, once. A promise and not a flag: every build of a
764
- * bootstrap calls this at the same time now that builds go in together,
765
- * and a flag set to "not yet" while the first caller waits for buildkitd
766
- * sent every other build to dockerd's own builder — which cannot build
767
- * on the erofs store at all (`lease does not exist`), so Specific's
768
- * first three cold builds on SDK 0.76.0 failed inside ten seconds
769
- * (2026-09-07). Sharing the promise makes every caller wait for the one
770
- * bring-up and read its one answer.
771
- */
736
+ // Every concurrent image build waits for the same consumer readiness check.
772
737
  let _localBuilder: Promise<boolean> | undefined;
773
- /**
774
- * Start buildkitd inside this VM with its state on the cache disk, and
775
- * register it as a buildx `remote` builder.
776
- *
777
- * Lazy on purpose: it runs on the first build a project actually does,
778
- * so a project with no dockerfile service never pays for a builder. And
779
- * best-effort: a daemon that will not start falls back to the shared host
780
- * one, which is a slower build and not a failed one.
781
- */
782
738
  function ensureLocalBuildkitd(): Promise<boolean> {
783
- _localBuilder ??= bringUpLocalBuildkitd();
784
- return _localBuilder;
785
- }
786
-
787
- async function bringUpLocalBuildkitd(): Promise<boolean> {
788
- if (existsSync("/run/spectest-sticky/consumer.json")) {
739
+ _localBuilder ??= (async () => {
740
+ if (!existsSync("/run/spectest-sticky/consumer.json")) return false;
789
741
  const ready = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 30_000);
790
742
  if (ready.code !== 0) throw new Error(`sticky BuildKit unavailable: ${ready.stderr}`);
791
743
  return true;
792
- }
793
- const paths = await imageCachePaths();
794
- if (!paths) return false;
795
- const state = paths.root;
796
- if (!(await hasBuildx())) return false;
797
-
798
- // The bring-up itself is a script baked into the image cache's base
799
- // snapshot (`base.rs::BUILDKITD_UP_SH`), so production and the real-VM
800
- // test drive exactly the same daemon with exactly the same config. All
801
- // it takes from us is where the state lives — the registry routing is
802
- // its own (Docker Hub via `mirror.gcr.io`, deliberately not the host
803
- // zot instance).
804
- const started = await shx("/bin/bash", [BUILDKITD_UP_PATH, state], 900_000);
805
- if (started.code !== 0) {
806
- // eslint-disable-next-line no-console
807
- console.warn(
808
- `[build] in-VM buildkitd would not start; building with dockerd's own BuildKit:\n${(started.stderr || started.stdout).trim()}`,
809
- );
810
- return false;
811
- }
812
- const create = await docker(
813
- ["buildx", "create", "--name", LOCAL_BUILDER_NAME, "--driver", "remote", LOCAL_BUILDKIT_ADDR],
814
- 30_000,
815
- );
816
- if (create.code !== 0 && !/existing instance|already exists/i.test(create.stderr)) {
817
- // eslint-disable-next-line no-console
818
- console.warn(`[build] could not register the in-VM builder:\n${create.stderr.trim()}`);
819
- return false;
820
- }
821
- // `inspect --bootstrap` dials the daemon, so it is both the readiness
822
- // wait and the proof it is really answering.
823
- const boot = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 120_000);
824
- if (boot.code !== 0) {
825
- // eslint-disable-next-line no-console
826
- console.warn(`[build] in-VM buildkitd never answered; building with dockerd's own BuildKit:\n${boot.stderr.trim()}`);
827
- return false;
828
- }
829
- // eslint-disable-next-line no-console
830
- console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
831
- // The cache directory union is for the project's OWN BuildKit (the
832
- // `/services` docs page tells it to mount `BUILDKIT_CACHE_UNION`).
833
- // The harness's builds no longer touch it: their cache is the
834
- // daemon's own state, which lives on the root disk and rides the
835
- // base root into the next cold build (CONTAINER_STORE.md §20), so a
836
- // hit is a snapshot that already exists — no tar blob to unpack
837
- // before the first uncached step, no export pass after a miss.
838
- await ensureBuildkitCacheUnion(paths);
839
- return true;
744
+ })();
745
+ return _localBuilder;
840
746
  }
841
747
 
842
- /**
843
- * Mount the BuildKit cache union (`BUILDKIT_CACHE_UNION`): overlayfs with
844
- * the layers disk's cache directory as the lower and the root disk's as
845
- * the upper, so one path serves `--cache-from` and `--cache-to`.
846
- *
847
- * Idempotent — a delta restore restarts the harness on a guest that
848
- * still has the mount. Returns null, and the caller keeps the two-directory
849
- * scheme, when the lower does not exist yet (a volume that has never
850
- * been merged has no cache directory on its layers disk) or the mount is
851
- * refused; the build is then slower, never failed. The work directory
852
- * has to share a filesystem with the upper, so it lives on the root disk
853
- * beside it; the merge reads neither it nor anything but the upper.
854
- */
855
- async function ensureBuildkitCacheUnion(paths: ImageCachePaths): Promise<string | null> {
856
- const lower = `${paths.layers}/${BUILDKIT_CACHE_DIR}`;
857
- const upper = `${paths.root}/${BUILDKIT_CACHE_DIR}`;
858
- const work = `${paths.root}/spectest-buildkit-work`;
859
- if (!existsSync(lower)) {
860
- // eslint-disable-next-line no-console
861
- console.log(`[build] no merged BuildKit cache on the layers disk yet; exporting to the root directly`);
862
- return null;
863
- }
864
- // overlayfs shows the upper's `index.json` INSTEAD of the lower's, so
865
- // an index on the root (a lineage's exports, or one left behind with
866
- // its blobs gone) would hide every merged tag and, naming manifests
867
- // that are not there, make BuildKit skip the import altogether. Write
868
- // the union of the two into the upper first (`mergeCacheIndex`), and
869
- // say what each side held so a miss can be read off the boot log.
870
- try {
871
- const readIndex = (dir: string): OciIndex | null =>
872
- existsSync(`${dir}/index.json`) ? (JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) as OciIndex) : null;
873
- const lowerIdx = readIndex(lower);
874
- const upperIdx = readIndex(upper);
875
- if (upperIdx && lowerIdx) {
876
- const merged = mergeCacheIndex(lowerIdx, upperIdx, (hex) => existsSync(`${upper}/blobs/sha256/${hex}`));
877
- await fs.mkdir(upper, { recursive: true });
878
- await fs.writeFile(`${upper}/index.json`, JSON.stringify(merged));
879
- await fs.writeFile(`${upper}/oci-layout`, JSON.stringify({ imageLayoutVersion: "1.0.0" }));
880
- // eslint-disable-next-line no-console
881
- console.log(
882
- `[build] BuildKit cache union: ${lowerIdx.manifests.length} tag(s) from the merged cache, ${upperIdx.manifests.length} on this root, ${merged.manifests.length} after the merge`,
883
- );
884
- } else {
885
- // eslint-disable-next-line no-console
886
- console.log(`[build] BuildKit cache union: ${lowerIdx?.manifests.length ?? 0} tag(s) from the merged cache, none on this root`);
887
- }
888
- } catch (err) {
889
- // eslint-disable-next-line no-console
890
- console.warn(`[build] could not reconcile the BuildKit cache indexes (${err}); the union shows the root's`);
891
- }
892
- const r = await shx(
893
- "/bin/sh",
894
- [
895
- "-c",
896
- `mkdir -p "${BUILDKIT_CACHE_UNION}" "${upper}" "${work}" && ` +
897
- `{ mountpoint -q "${BUILDKIT_CACHE_UNION}" || ` +
898
- `mount -t overlay overlay -o "lowerdir=${lower},upperdir=${upper},workdir=${work}" "${BUILDKIT_CACHE_UNION}"; }`,
899
- ],
900
- 60_000,
901
- );
902
- if (r.code !== 0) {
903
- // eslint-disable-next-line no-console
904
- console.warn(`[build] could not mount the BuildKit cache union; exporting to the root directly:\n${(r.stderr || r.stdout).trim()}`);
905
- return null;
906
- }
907
- return BUILDKIT_CACHE_UNION;
908
- }
909
748
  /**
910
749
  * A reference as containerd names it. buildx's `-t` on a remote builder
911
750
  * stores an unqualified name (`probe:bx`) that dockerd then cannot
@@ -986,7 +825,7 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
986
825
  // A directory on a cache disk is the same kind of thing: a nested
987
826
  // runtime's containerd root (`k3s()`), kept as a cache by the
988
827
  // lineage exactly as the container store one level up is.
989
- const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
828
+ const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host);
990
829
  if (vol.source?.startsWith("/") && !durable) {
991
830
  await recordAbsoluteVolumeDir(host);
992
831
  }
@@ -3278,7 +3117,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3278
3117
  // Prep concurrency: everything at once. Registry pulls are
3279
3118
  // network-bound and cheap in RAM. Dockerfile builds all go to the one
3280
3119
  // in-VM buildkitd, whose `max-parallelism` (all but one vCPU,
3281
- // `base.rs::BUILDKITD_UP_SH`) caps the steps in flight across every
3120
+ // the sticky consumer) caps the steps in flight across every
3282
3121
  // build it holds, and whose steps run under a cgroup with a memory
3283
3122
  // ceiling — so a fully cached build finishes at once while a miss is
3284
3123
  // still running, and two installs that fan out together kill a step,
@@ -80,7 +80,7 @@ export function summarizeBuildKit(out: string): BuildStep[] {
80
80
  * Between SDK 0.75.0 and 0.76.0 this gated the harness's cache export
81
81
  * (a `mode=max` export cost ~3 s per service with nothing to write,
82
82
  * measured 2026-09-06). The harness exports nothing since its cache is
83
- * the daemon's own state (CONTAINER_STORE.md §20); the reading stays,
83
+ * the daemon's own state (CONTAINER_STORE.md); the reading stays,
84
84
  * since it is the one machine-readable answer to "did this build run
85
85
  * anything".
86
86
  *
@@ -82,7 +82,7 @@ export interface ContainerRunInput {
82
82
  /** The bridge gateway, where the ingress listeners are. Null when it
83
83
  * isn't known yet, in which case no `--add-host` is emitted. */
84
84
  gatewayIp?: string | null;
85
- /** The host image-cache gateway, reachable as `spectest-host`. */
85
+ /** The host registry-cache gateway, reachable as `spectest-host`. */
86
86
  hostCacheGateway?: string | null;
87
87
  hostCacheName: string;
88
88
  /** The in-VM root CA, bind-mounted and appended to Node's roots. */
@@ -63,7 +63,7 @@ export function sanitizeSegment(p: string): string {
63
63
  * `files` does: a component cannot know the map key the user will give it,
64
64
  * and an **absolute** source gets no automatic per-service directory. A
65
65
  * component that needs one — a nested runtime keeping its store under
66
- * the image cache's `spectest-nested/<service>` (`image-cache.ts::nestedStoreDir`),
66
+ * the sticky disk's per-service Kubernetes directory,
67
67
  * where two of them sharing one directory would be two daemons on one
68
68
  * metadata store — writes the token into the path.
69
69
  *