@specific.dev/spectest 0.119.5 → 0.119.7

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.
@@ -30,10 +30,7 @@ export interface K3sOptions {
30
30
  images?: readonly KubernetesImage[];
31
31
  /**
32
32
  * Image tag for the official `rancher/k3s` image. Default
33
- * `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
34
- * containerd (1.7) without the EROFS snapshotter, so its cluster keeps
35
- * its images on overlayfs and shares the environment's image cache only
36
- * as a directory that survives — see `k3s()`'s store section.
33
+ * `"v1.33.5-k3s1"`. The cluster keeps its image store on overlayfs.
37
34
  */
38
35
  version?: string;
39
36
  /**
@@ -365,14 +362,14 @@ export declare function k3s(opts?: K3sOptions): {
365
362
  source: string;
366
363
  target: string;
367
364
  readOnly?: undefined;
368
- } | {
369
- source: string;
370
- target: string;
371
- readOnly: boolean;
372
365
  } | {
373
366
  target: string;
374
367
  source?: undefined;
375
368
  readOnly?: undefined;
369
+ } | {
370
+ source: string;
371
+ target: string;
372
+ readOnly: boolean;
376
373
  })[];
377
374
  files: {
378
375
  path: string;
@@ -11,7 +11,6 @@ import { readFile, unlink } from "node:fs/promises";
11
11
  import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, PatchStrategy, ResponseContext, ServerConfiguration, createConfiguration, loadAllYaml, } from "@kubernetes/client-node";
12
12
  import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
13
13
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
14
- import { MKFS_EROFS_PATH, STORE_ADOPT_PATH, imageCachePathsSync, nestedStoreDir, } from "../harness/image-cache.js";
15
14
  import { stickyDiskPathSync } from "../harness/sticky-paths.js";
16
15
  import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
17
16
  import { prePullK3sImages } from "../harness/k3s-prepull.js";
@@ -22,15 +21,8 @@ import { recorderAnnotate, recorderRemove } from "../recorder.js";
22
21
  const K3S_REGISTRY_PORT = 5000;
23
22
  /** The cluster's own containerd root, inside the k3s container. */
24
23
  const CLUSTER_STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
25
- /** Where k3s (containerd 2.x layout) reads a containerd config template. */
26
- const CONTAINERD_TEMPLATE_PATH = "/var/lib/rancher/k3s/agent/etc/containerd/config-v3.toml.tmpl";
27
- /** The EROFS settings that template imports; the store's prepare daemon
28
- * imports the same file, so both daemons agree on the snapshotter. */
29
- const CONTAINERD_EROFS_CONFIG_PATH = "/etc/spectest/containerd-erofs.toml";
30
24
  /** The socket of the throwaway daemon that prepares the store. */
31
25
  const PREPARE_SOCKET = "/run/spectest-prepare.sock";
32
- /** containerd's CRI plugin lists only images carrying this label. */
33
- const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
34
26
  /**
35
27
  * Written before the server starts and removed by the ready probe.
36
28
  *
@@ -46,78 +38,6 @@ const BOOT_MARKER = ".spectest-boot-incomplete";
46
38
  * sticky-kubernetes adapter removes runtime state and retains images and
47
39
  * unpacked layers under the shared filesystem budget. Test forks do not
48
40
  * write back. The preparation wrapper below also handles delta rebuilds.
49
- *
50
- * Legacy image-cache integration (`CONTAINER_STORE.md` §12):
51
- *
52
- * The cluster's embedded containerd keeps its root on the **root disk**
53
- * of the environment's image cache, in a directory of its own
54
- * (`<root>/spectest-nested/<service>`, bind-mounted at
55
- * `CLUSTER_STORE_TARGET`), and runs the **EROFS snapshotter** over the
56
- * same read-only **layers disk** the guest's own containerd uses,
57
- * mounted into the container at the same path. So a layer is one file
58
- * on the host whichever runtime holds it, the cluster's pulls land in
59
- * the lineage's root clone like a `docker pull` does, and the merge
60
- * reads the cluster's indexes exactly as it reads dockerd's — at every
61
- * VM teardown, whether the pull happened at env boot or in the middle
62
- * of a test. Nothing distinguishes the two: a fork's root clone grew,
63
- * so it is harvested. The next cold build's cluster **adopts** what the
64
- * merge put on the layers disk under its own namespace (`k8s.io`) with
65
- * the same helper dockerd's store uses, before the cluster starts.
66
- *
67
- * Extraction is 48x the I/O of the download (`STICKY_DISKS.md` §7.2),
68
- * which is why a mirror never helped here and an extracted, shared
69
- * store is the whole prize. Measured on the one project on the old
70
- * per-environment store: 20.6 s of cluster bring-up per build.
71
- *
72
- * Three things the container needs for it, all mounted by `k3s()`:
73
- * golden's static `mkfs.erofs` (the EROFS differ needs it and no k3s
74
- * image ships one); the adopt helper (POSIX sh, run by busybox); and a
75
- * containerd config template that imports the EROFS settings — k3s
76
- * generates its config from `config-v3.toml.tmpl` when present, and an
77
- * `imports` line on top of the stock template is the one way to add
78
- * plugin sections without colliding with the ones it writes. The
79
- * EROFS snapshotter mounts every layer through a loop device, and a
80
- * privileged container's `/dev` is a static copy that never sees the
81
- * nodes the kernel allocates later, so the wrapper pre-creates 1024 of
82
- * them (measured: 0 s).
83
- *
84
- * What the store inherits with the cache is one build's crash state:
85
- * the root was captured from a running guest, and the teardown's
86
- * `docker rm -f` killed that cluster where it stood. **Deleting the dead
87
- * files is not enough, and the failure is not subtle.** containerd's
88
- * index still lists the previous build's containers, so the CRI plugin
89
- * offers them to a kubelet whose API server has never heard of them; the
90
- * kubelet spends its startup reconciling containers whose tasks are gone
91
- * (`Failed to create existing container … task not found`) and does not
92
- * register its own Node in time, and the CSI plugin — which waits on that
93
- * Node with a fixed budget — calls `Fatalf`. The kubelet dies, k3s exits
94
- * 2, and what the author sees is a rollout that timed out against a
95
- * hostname that no longer resolves. Measured: 2 of 2 delta restores,
96
- * against 2 of 2 clean without the store.
97
- *
98
- * So a **throwaway daemon** on the store, with the CRI plugin disabled,
99
- * does both jobs before k3s starts: it deletes the previous build's
100
- * containers and their tasks through containerd itself (the only thing
101
- * that can edit its index), and it adopts the inbox. Images, content
102
- * blobs and extracted snapshots — the whole cache — stay. It is the same
103
- * discipline `spectest-image-cache-up` applies to the guest's own store
104
- * one level up.
105
- *
106
- * Two failures cost a cache and never a build:
107
- *
108
- * 1. **A sweep that cannot run wipes the store.** A daemon that will not
109
- * start leaves behind exactly the containers this cluster fatals on,
110
- * so carrying on is the one option that is known bad.
111
- * 2. **A store that did not work is never inherited twice.** The marker
112
- * is written before the server starts and removed by the ready probe,
113
- * so it survives only when a boot never reached a live API server.
114
- * That is the backstop for the sweep being incomplete in some way we
115
- * have not seen: the cost is one cold build's pulls, and it repairs
116
- * itself.
117
- *
118
- * Without an image cache (the fake backend, a server that predates it)
119
- * the store is a per-environment volume as before, on overlayfs, with
120
- * the same sweep.
121
41
  */
122
42
  /** Plugins the prepare daemon does not need. Every plugin is startup
123
43
  * time, and the daemon sits on the critical path of the cluster's boot.
@@ -135,37 +55,9 @@ const SWEEP_DISABLED_PLUGINS = [
135
55
  "io.containerd.snapshotter.v1.fuse-overlayfs",
136
56
  ];
137
57
  const DISABLED_PLUGINS_TOML = `disabled_plugins = [${SWEEP_DISABLED_PLUGINS.map((p) => `"${p}"`).join(", ")}]`;
138
- /** The containerd config template: the stock one, importing our EROFS
139
- * settings. `imports` is a top-level key and has to come first. */
140
- const CONTAINERD_TEMPLATE = `imports = ["${CONTAINERD_EROFS_CONFIG_PATH}"]\n{{ template "base" . }}\n`;
141
- /**
142
- * containerd's EROFS settings, the same golden bakes for the guest's
143
- * own daemon (`local-vms-rootfs.Dockerfile`): the differ first, the
144
- * transfer service unpacking onto EROFS, and `set_immutable` off because
145
- * a committed layer's file is a symlink into a read-only disk, on which
146
- * the immutable ioctl would fail the commit.
147
- */
148
- function erofsConfigToml() {
149
- const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : process.arch;
150
- return [
151
- "version = 3",
152
- '[plugins."io.containerd.snapshotter.v1.erofs"]',
153
- " set_immutable = false",
154
- '[plugins."io.containerd.differ.v1.erofs"]',
155
- ' mkfs_options = ["-T0", "--mkfs-time", "--sort=none"]',
156
- '[plugins."io.containerd.service.v1.diff-service"]',
157
- ' default = ["erofs","walking"]',
158
- '[plugins."io.containerd.transfer.v1.local"]',
159
- ' [[plugins."io.containerd.transfer.v1.local".unpack_config]]',
160
- ` platform = "linux/${arch}"`,
161
- ' snapshotter = "erofs"',
162
- ' differ = "erofs"',
163
- "",
164
- ].join("\n");
165
- }
166
58
  /**
167
59
  * The store-preparation half of the command wrapper: sweep the crash
168
- * state, adopt the cache's inbox (image-cache mode), leave `SNAP` set to
60
+ * state and leave `SNAP` set to
169
61
  * the snapshotter the cluster starts on.
170
62
  *
171
63
  * Runs under busybox `sh`. Every `ctr` call is one invocation for the
@@ -174,25 +66,10 @@ function erofsConfigToml() {
174
66
  * per-container form spent seconds of the cluster's boot on process
175
67
  * starts alone (measured: ready 3.6 s -> 7.0 s).
176
68
  */
177
- function storePrepareSh(cache) {
178
- const daemonConfig = cache
179
- ? // containerd 2.x (v3 config, EROFS) when the image's containerd has
180
- // the snapshotter; a 1.7 image (k3s < 1.33) gets the v2 shape it
181
- // understands and stays on overlayfs.
182
- `if [ "$EROFS" = 1 ]; then printf '%s\\n' 'version = 3' 'imports = ["${CONTAINERD_EROFS_CONFIG_PATH}"]' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; ` +
183
- `else printf '%s\\n' 'version = 2' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; fi; `
184
- : `printf '%s\\n' 'version = 2' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; `;
185
- const detect = cache
186
- ? `EROFS=0; if containerd config default 2>/dev/null | grep -q 'snapshotter.v1.erofs'; then EROFS=1; fi; ` +
187
- `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; ` +
188
- `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; `
189
- : `EROFS=0; SNAP=overlayfs; `;
190
- const adopt = cache
191
- ? `adopt_store() { [ "$EROFS" = 1 ] && [ -f "$L/spectest-inbox/layers.txt" ] || return 0; ` +
192
- `${STORE_ADOPT_PATH} "$S" "$L" -n k8s.io -a "$A" -l ${CRI_MANAGED_LABEL} 2>&1 | tail -1; }; `
193
- : `adopt_store() { :; }; `;
194
- return (`S=${CLUSTER_STORE_TARGET}; M="$S/${BOOT_MARKER}"; A=${PREPARE_SOCKET}; L=${cache ? cache.layers : "/nonexistent"}; ` +
195
- detect +
69
+ function storePrepareSh() {
70
+ const daemonConfig = `printf '%s\\n' 'version = 2' '${DISABLED_PLUGINS_TOML}' > /run/spectest-prepare.toml; `;
71
+ return (`S=${CLUSTER_STORE_TARGET}; M="$S/${BOOT_MARKER}"; A=${PREPARE_SOCKET}; ` +
72
+ `SNAP=overlayfs; ` +
196
73
  `start_daemon() { ` +
197
74
  daemonConfig +
198
75
  `containerd -c /run/spectest-prepare.toml --root "$S" --state /run/spectest-prepare ` +
@@ -211,16 +88,15 @@ function storePrepareSh(cache) {
211
88
  // k3s ships they are ordinary containers and this is a no-op.
212
89
  `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
213
90
  `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
214
- adopt +
215
91
  `if [ -e "$M" ]; then ` +
216
92
  `echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
217
93
  `rm -rf "$S"/* "$S"/.[!.]* 2>/dev/null || true; fi; ` +
218
94
  `mkdir -p "$S"; ` +
219
95
  `had_state=0; [ -d "$S/io.containerd.metadata.v1.bolt" ] && had_state=1; ` +
220
96
  `T0=$(date +%s); ` +
221
- `if [ "$had_state" = 1 ] || [ "$EROFS" = 1 ]; then ` +
97
+ `if [ "$had_state" = 1 ]; then ` +
222
98
  `if start_daemon; then ` +
223
- `[ "$had_state" = 1 ] && sweep_store; adopt_store; stop_daemon; ` +
99
+ `[ "$had_state" = 1 ] && sweep_store; stop_daemon; ` +
224
100
  // Timed and printed always: this is inside the cluster's ready time,
225
101
  // so it is the first thing to suspect if that time moves.
226
102
  `echo "spectest: cluster image store at $S ready on $SNAP (prepared in $(($(date +%s)-T0))s)"; ` +
@@ -1445,12 +1321,6 @@ async function collectDiagnostics(client, namespace) {
1445
1321
  * so neither cold-cache nor warm starts re-pull. Any `opts.version`
1446
1322
  * works — there's no base-snapshot release to keep in sync with.
1447
1323
  *
1448
- * **Why v1.33.x:** k3s 1.33.5 is the first line embedding containerd
1449
- * 2.1 (2.1.4-k3s1), whose in-tree EROFS snapshotter is what lets the
1450
- * cluster share the environment's image cache (see the store section
1451
- * above); 1.32 ships containerd 1.7. kube-proxy's `nftables` proxy mode
1452
- * is GA since k8s 1.32 (beta in 1.31, alpha-gated in 1.30); dropping
1453
- * below 1.31 falls back to the iptables path.
1454
1324
  */
1455
1325
  const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
1456
1326
  export function k3s(opts = {}) {
@@ -1479,12 +1349,9 @@ export function k3s(opts = {}) {
1479
1349
  // the in-cluster registry). Seeded via `files` because k3s reads it
1480
1350
  // only at startup, before any setup hook could run.
1481
1351
  const registriesYaml = buildRegistriesYaml(registryEnabled, registryHost);
1482
- // The environment's image cache, when this VM carries one: the
1483
- // cluster's store goes on its root disk and its layers come off the
1484
- // shared layers disk (see the store section above).
1352
+ // Pre-pulls read content from the environment's containerd store.
1485
1353
  const sticky = stickyDiskPathSync();
1486
- const cache = sticky ? null : imageCachePathsSync();
1487
- const contentDirectory = `${sticky ? `${sticky}/containerd` : cache?.root ?? "/var/lib/containerd"}/${CONTENT_BLOBS}`;
1354
+ const contentDirectory = `${sticky ? `${sticky}/containerd` : "/var/lib/containerd"}/${CONTENT_BLOBS}`;
1488
1355
  const serverArgs = [
1489
1356
  "k3s",
1490
1357
  "server",
@@ -1566,9 +1433,8 @@ export function k3s(opts = {}) {
1566
1433
  // is affected.
1567
1434
  "mount --make-rshared / 2>/dev/null || " +
1568
1435
  "echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
1569
- storePrepareSh(cache) +
1570
- // The snapshotter is decided by the wrapper: EROFS on the image cache
1571
- // when the image's containerd has it, overlayfs otherwise.
1436
+ storePrepareSh() +
1437
+ // Sticky and environment-local stores use overlayfs.
1572
1438
  `exec ${serverArgs} --snapshotter=$SNAP`;
1573
1439
  // Plain /readyz probe. On a warm zot cache the cluster's images are
1574
1440
  // already local, so the first boot completes in seconds; the
@@ -1598,22 +1464,9 @@ export function k3s(opts = {}) {
1598
1464
  // in this list (it's documentation, not a firewall).
1599
1465
  ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
1600
1466
  // Sticky disks retain an independent overlayfs store per service/version.
1601
- // The legacy image cache (see the store section above) keeps its
1602
- // containerd root on the image cache's root disk, the shared layers
1603
- // disk read-only at its guest path, golden's mkfs.erofs and the adopt
1604
- // helper — or, with no cache, a per-environment volume. Two clusters
1605
- // in one environment get separate roots either way (the service
1606
- // token names the directory), because two containerd daemons cannot
1607
- // share a store: bolt takes an exclusive lock.
1608
1467
  volumes: [...(sticky
1609
1468
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1610
- : cache ? [
1611
- { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
1612
- { source: cache.layers, target: cache.layers, readOnly: true },
1613
- { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
1614
- { source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
1615
- ]
1616
- : [{ target: CLUSTER_STORE_TARGET }]),
1469
+ : [{ target: CLUSTER_STORE_TARGET }]),
1617
1470
  ...(publishBuilds.length ? [
1618
1471
  { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
1619
1472
  { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
@@ -1621,12 +1474,6 @@ export function k3s(opts = {}) {
1621
1474
  ],
1622
1475
  files: [
1623
1476
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1624
- ...(cache
1625
- ? [
1626
- { path: CONTAINERD_TEMPLATE_PATH, content: CONTAINERD_TEMPLATE },
1627
- { path: CONTAINERD_EROFS_CONFIG_PATH, content: erofsConfigToml() },
1628
- ]
1629
- : []),
1630
1477
  ],
1631
1478
  readyCheck: {
1632
1479
  type: "exec",
package/dist/daemon.js CHANGED
@@ -71,7 +71,6 @@ import { startHttp2Proxy } from "./harness/http2-proxy.js";
71
71
  import { startTlsTerminator } from "./harness/tls-terminator.js";
72
72
  import { runContainerArgs } from "./harness/container-run.js";
73
73
  import { isOnStickyDisk } from "./harness/sticky-paths.js";
74
- import { BUILDKIT_CACHE_DIR, BUILDKIT_CACHE_UNION, IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, mergeCacheIndex, } from "./harness/image-cache.js";
75
74
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
76
75
  import { badRequest, conflict, notFound, requireString, } from "./harness/methods.js";
77
76
  import { openTerminal } from "./terminal.js";
@@ -475,158 +474,19 @@ async function hasBuildx() {
475
474
  }
476
475
  return _buildxAvailable;
477
476
  }
478
- // Every VM carries its project's container store (CONTAINER_STORE.md):
479
- // the disk every pull and every build lands on, mounted by the control
480
- // plane before this harness starts. The manifest below says where; the
481
- // in-VM buildkitd keeps its exported cache there too, so a fresh VM finds
482
- // every layer it built before. Detected once; if the daemon will not
483
- // start, dockerd's own BuildKit builds instead.
484
477
  const LOCAL_BUILDER_NAME = "spectest-local";
485
- const LOCAL_BUILDKIT_ADDR = "tcp://127.0.0.1:1234";
486
- /** The bring-up script the cache base bakes (`base.rs::BUILDKITD_UP_SH`). */
487
- const BUILDKITD_UP_PATH = "/usr/local/bin/spectest-buildkitd-up";
488
- /** Where the control plane mounted this VM's image cache: containerd's
489
- * root (read-write, this VM's own) and the layers disk (read-only, shared
490
- * by every VM of a generation). `null` when the VM carries no cache. */
491
- async function imageCachePaths() {
492
- return imageCachePathsSync(IMAGE_CACHE_MANIFEST);
493
- }
494
- /**
495
- * The bring-up, once. A promise and not a flag: every build of a
496
- * bootstrap calls this at the same time now that builds go in together,
497
- * and a flag set to "not yet" while the first caller waits for buildkitd
498
- * sent every other build to dockerd's own builder — which cannot build
499
- * on the erofs store at all (`lease does not exist`), so Specific's
500
- * first three cold builds on SDK 0.76.0 failed inside ten seconds
501
- * (2026-09-07). Sharing the promise makes every caller wait for the one
502
- * bring-up and read its one answer.
503
- */
478
+ // Every concurrent image build waits for the same consumer readiness check.
504
479
  let _localBuilder;
505
- /**
506
- * Start buildkitd inside this VM with its state on the cache disk, and
507
- * register it as a buildx `remote` builder.
508
- *
509
- * Lazy on purpose: it runs on the first build a project actually does,
510
- * so a project with no dockerfile service never pays for a builder. And
511
- * best-effort: a daemon that will not start falls back to the shared host
512
- * one, which is a slower build and not a failed one.
513
- */
514
480
  function ensureLocalBuildkitd() {
515
- _localBuilder ??= bringUpLocalBuildkitd();
516
- return _localBuilder;
517
- }
518
- async function bringUpLocalBuildkitd() {
519
- if (existsSync("/run/spectest-sticky/consumer.json")) {
481
+ _localBuilder ??= (async () => {
482
+ if (!existsSync("/run/spectest-sticky/consumer.json"))
483
+ return false;
520
484
  const ready = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 30_000);
521
485
  if (ready.code !== 0)
522
486
  throw new Error(`sticky BuildKit unavailable: ${ready.stderr}`);
523
487
  return true;
524
- }
525
- const paths = await imageCachePaths();
526
- if (!paths)
527
- return false;
528
- const state = paths.root;
529
- if (!(await hasBuildx()))
530
- return false;
531
- // The bring-up itself is a script baked into the image cache's base
532
- // snapshot (`base.rs::BUILDKITD_UP_SH`), so production and the real-VM
533
- // test drive exactly the same daemon with exactly the same config. All
534
- // it takes from us is where the state lives — the registry routing is
535
- // its own (Docker Hub via `mirror.gcr.io`, deliberately not the host
536
- // zot instance).
537
- const started = await shx("/bin/bash", [BUILDKITD_UP_PATH, state], 900_000);
538
- if (started.code !== 0) {
539
- // eslint-disable-next-line no-console
540
- console.warn(`[build] in-VM buildkitd would not start; building with dockerd's own BuildKit:\n${(started.stderr || started.stdout).trim()}`);
541
- return false;
542
- }
543
- const create = await docker(["buildx", "create", "--name", LOCAL_BUILDER_NAME, "--driver", "remote", LOCAL_BUILDKIT_ADDR], 30_000);
544
- if (create.code !== 0 && !/existing instance|already exists/i.test(create.stderr)) {
545
- // eslint-disable-next-line no-console
546
- console.warn(`[build] could not register the in-VM builder:\n${create.stderr.trim()}`);
547
- return false;
548
- }
549
- // `inspect --bootstrap` dials the daemon, so it is both the readiness
550
- // wait and the proof it is really answering.
551
- const boot = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 120_000);
552
- if (boot.code !== 0) {
553
- // eslint-disable-next-line no-console
554
- console.warn(`[build] in-VM buildkitd never answered; building with dockerd's own BuildKit:\n${boot.stderr.trim()}`);
555
- return false;
556
- }
557
- // eslint-disable-next-line no-console
558
- console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
559
- // The cache directory union is for the project's OWN BuildKit (the
560
- // `/services` docs page tells it to mount `BUILDKIT_CACHE_UNION`).
561
- // The harness's builds no longer touch it: their cache is the
562
- // daemon's own state, which lives on the root disk and rides the
563
- // base root into the next cold build (CONTAINER_STORE.md §20), so a
564
- // hit is a snapshot that already exists — no tar blob to unpack
565
- // before the first uncached step, no export pass after a miss.
566
- await ensureBuildkitCacheUnion(paths);
567
- return true;
568
- }
569
- /**
570
- * Mount the BuildKit cache union (`BUILDKIT_CACHE_UNION`): overlayfs with
571
- * the layers disk's cache directory as the lower and the root disk's as
572
- * the upper, so one path serves `--cache-from` and `--cache-to`.
573
- *
574
- * Idempotent — a delta restore restarts the harness on a guest that
575
- * still has the mount. Returns null, and the caller keeps the two-directory
576
- * scheme, when the lower does not exist yet (a volume that has never
577
- * been merged has no cache directory on its layers disk) or the mount is
578
- * refused; the build is then slower, never failed. The work directory
579
- * has to share a filesystem with the upper, so it lives on the root disk
580
- * beside it; the merge reads neither it nor anything but the upper.
581
- */
582
- async function ensureBuildkitCacheUnion(paths) {
583
- const lower = `${paths.layers}/${BUILDKIT_CACHE_DIR}`;
584
- const upper = `${paths.root}/${BUILDKIT_CACHE_DIR}`;
585
- const work = `${paths.root}/spectest-buildkit-work`;
586
- if (!existsSync(lower)) {
587
- // eslint-disable-next-line no-console
588
- console.log(`[build] no merged BuildKit cache on the layers disk yet; exporting to the root directly`);
589
- return null;
590
- }
591
- // overlayfs shows the upper's `index.json` INSTEAD of the lower's, so
592
- // an index on the root (a lineage's exports, or one left behind with
593
- // its blobs gone) would hide every merged tag and, naming manifests
594
- // that are not there, make BuildKit skip the import altogether. Write
595
- // the union of the two into the upper first (`mergeCacheIndex`), and
596
- // say what each side held so a miss can be read off the boot log.
597
- try {
598
- const readIndex = (dir) => existsSync(`${dir}/index.json`) ? JSON.parse(readFileSync(`${dir}/index.json`, "utf8")) : null;
599
- const lowerIdx = readIndex(lower);
600
- const upperIdx = readIndex(upper);
601
- if (upperIdx && lowerIdx) {
602
- const merged = mergeCacheIndex(lowerIdx, upperIdx, (hex) => existsSync(`${upper}/blobs/sha256/${hex}`));
603
- await fs.mkdir(upper, { recursive: true });
604
- await fs.writeFile(`${upper}/index.json`, JSON.stringify(merged));
605
- await fs.writeFile(`${upper}/oci-layout`, JSON.stringify({ imageLayoutVersion: "1.0.0" }));
606
- // eslint-disable-next-line no-console
607
- console.log(`[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`);
608
- }
609
- else {
610
- // eslint-disable-next-line no-console
611
- console.log(`[build] BuildKit cache union: ${lowerIdx?.manifests.length ?? 0} tag(s) from the merged cache, none on this root`);
612
- }
613
- }
614
- catch (err) {
615
- // eslint-disable-next-line no-console
616
- console.warn(`[build] could not reconcile the BuildKit cache indexes (${err}); the union shows the root's`);
617
- }
618
- const r = await shx("/bin/sh", [
619
- "-c",
620
- `mkdir -p "${BUILDKIT_CACHE_UNION}" "${upper}" "${work}" && ` +
621
- `{ mountpoint -q "${BUILDKIT_CACHE_UNION}" || ` +
622
- `mount -t overlay overlay -o "lowerdir=${lower},upperdir=${upper},workdir=${work}" "${BUILDKIT_CACHE_UNION}"; }`,
623
- ], 60_000);
624
- if (r.code !== 0) {
625
- // eslint-disable-next-line no-console
626
- console.warn(`[build] could not mount the BuildKit cache union; exporting to the root directly:\n${(r.stderr || r.stdout).trim()}`);
627
- return null;
628
- }
629
- return BUILDKIT_CACHE_UNION;
488
+ })();
489
+ return _localBuilder;
630
490
  }
631
491
  /**
632
492
  * A reference as containerd names it. buildx's `-t` on a remote builder
@@ -704,7 +564,7 @@ async function ensureVolumes(svc) {
704
564
  // A directory on a cache disk is the same kind of thing: a nested
705
565
  // runtime's containerd root (`k3s()`), kept as a cache by the
706
566
  // lineage exactly as the container store one level up is.
707
- const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
567
+ const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host);
708
568
  if (vol.source?.startsWith("/") && !durable) {
709
569
  await recordAbsoluteVolumeDir(host);
710
570
  }
@@ -2755,7 +2615,7 @@ async function bootstrapInner(prepared, identity) {
2755
2615
  // Prep concurrency: everything at once. Registry pulls are
2756
2616
  // network-bound and cheap in RAM. Dockerfile builds all go to the one
2757
2617
  // in-VM buildkitd, whose `max-parallelism` (all but one vCPU,
2758
- // `base.rs::BUILDKITD_UP_SH`) caps the steps in flight across every
2618
+ // the sticky consumer) caps the steps in flight across every
2759
2619
  // build it holds, and whose steps run under a cgroup with a memory
2760
2620
  // ceiling — so a fully cached build finishes at once while a miss is
2761
2621
  // still running, and two installs that fan out together kill a step,
@@ -42,7 +42,7 @@ export declare function summarizeBuildKit(out: string): BuildStep[];
42
42
  * Between SDK 0.75.0 and 0.76.0 this gated the harness's cache export
43
43
  * (a `mode=max` export cost ~3 s per service with nothing to write,
44
44
  * measured 2026-09-06). The harness exports nothing since its cache is
45
- * the daemon's own state (CONTAINER_STORE.md §20); the reading stays,
45
+ * the daemon's own state (CONTAINER_STORE.md); the reading stays,
46
46
  * since it is the one machine-readable answer to "did this build run
47
47
  * anything".
48
48
  *
@@ -71,7 +71,7 @@ export function summarizeBuildKit(out) {
71
71
  * Between SDK 0.75.0 and 0.76.0 this gated the harness's cache export
72
72
  * (a `mode=max` export cost ~3 s per service with nothing to write,
73
73
  * measured 2026-09-06). The harness exports nothing since its cache is
74
- * the daemon's own state (CONTAINER_STORE.md §20); the reading stays,
74
+ * the daemon's own state (CONTAINER_STORE.md); the reading stays,
75
75
  * since it is the one machine-readable answer to "did this build run
76
76
  * anything".
77
77
  *
@@ -80,7 +80,7 @@ export interface ContainerRunInput {
80
80
  /** The bridge gateway, where the ingress listeners are. Null when it
81
81
  * isn't known yet, in which case no `--add-host` is emitted. */
82
82
  gatewayIp?: string | null;
83
- /** The host image-cache gateway, reachable as `spectest-host`. */
83
+ /** The host registry-cache gateway, reachable as `spectest-host`. */
84
84
  hostCacheGateway?: string | null;
85
85
  hostCacheName: string;
86
86
  /** The in-VM root CA, bind-mounted and appended to Node's roots. */
@@ -49,7 +49,7 @@ export declare function sanitizeSegment(p: string): string;
49
49
  * `files` does: a component cannot know the map key the user will give it,
50
50
  * and an **absolute** source gets no automatic per-service directory. A
51
51
  * component that needs one — a nested runtime keeping its store under
52
- * the image cache's `spectest-nested/<service>` (`image-cache.ts::nestedStoreDir`),
52
+ * the sticky disk's per-service Kubernetes directory,
53
53
  * where two of them sharing one directory would be two daemons on one
54
54
  * metadata store — writes the token into the path.
55
55
  *
@@ -45,7 +45,7 @@ export function sanitizeSegment(p) {
45
45
  * `files` does: a component cannot know the map key the user will give it,
46
46
  * and an **absolute** source gets no automatic per-service directory. A
47
47
  * component that needs one — a nested runtime keeping its store under
48
- * the image cache's `spectest-nested/<service>` (`image-cache.ts::nestedStoreDir`),
48
+ * the sticky disk's per-service Kubernetes directory,
49
49
  * where two of them sharing one directory would be two daemons on one
50
50
  * metadata store — writes the token into the path.
51
51
  *
package/dist/next.js CHANGED
@@ -4,36 +4,49 @@ import path from "node:path";
4
4
  * versions that do not apply outputFileTracingIncludes still emit these traces
5
5
  * before runAfterProductionCompile. No source files or generated code change. */
6
6
  async function includeTracedMaps(distDir) {
7
+ const traces = [];
8
+ const serverMaps = [];
7
9
  async function walk(dir) {
8
10
  for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
9
11
  const file = path.join(dir, entry.name);
10
12
  if (entry.isDirectory() && entry.name !== "cache" && entry.name !== "standalone")
11
13
  await walk(file);
12
- else if (entry.isFile() && entry.name.endsWith(".nft.json")) {
13
- const trace = JSON.parse(await fs.readFile(file, "utf8"));
14
- if (!Array.isArray(trace.files))
15
- throw new Error(`Invalid Next trace: ${file}`);
16
- const scripts = [entry.name.slice(0, -".nft.json".length), ...trace.files];
17
- const maps = [];
18
- for (const script of scripts) {
19
- if (typeof script !== "string" || !script.endsWith(".js"))
20
- continue;
21
- const map = script + ".map";
22
- try {
23
- if ((await fs.stat(path.resolve(dir, map))).isFile())
24
- maps.push(map);
25
- }
26
- catch (e) {
27
- if (e.code !== "ENOENT")
28
- throw e;
29
- }
30
- }
31
- trace.files = [...new Set([...trace.files, ...maps])];
32
- await fs.writeFile(file, JSON.stringify(trace));
14
+ else if (entry.isFile()) {
15
+ if (entry.name.endsWith(".nft.json"))
16
+ traces.push(file);
17
+ if (entry.name.endsWith(".js.map") && file.startsWith(path.join(distDir, "server") + path.sep))
18
+ serverMaps.push(file);
33
19
  }
34
20
  }
35
21
  }
36
22
  await walk(distDir);
23
+ for (const file of traces) {
24
+ const dir = path.dirname(file);
25
+ const trace = JSON.parse(await fs.readFile(file, "utf8"));
26
+ if (!Array.isArray(trace.files))
27
+ throw new Error(`Invalid Next trace: ${file}`);
28
+ const scripts = [path.basename(file).slice(0, -".nft.json".length), ...trace.files];
29
+ // Edge bundles are copied through middleware-manifest, not NFT traces.
30
+ // The server trace is always copied into standalone output; retain
31
+ // their maps there without changing the middleware's executable files.
32
+ const maps = file === path.join(distDir, "next-server.js.nft.json")
33
+ ? serverMaps.map(map => path.relative(dir, map)) : [];
34
+ for (const script of scripts) {
35
+ if (typeof script !== "string" || !script.endsWith(".js"))
36
+ continue;
37
+ const map = script + ".map";
38
+ try {
39
+ if ((await fs.stat(path.resolve(dir, map))).isFile())
40
+ maps.push(map);
41
+ }
42
+ catch (e) {
43
+ if (e.code !== "ENOENT")
44
+ throw e;
45
+ }
46
+ }
47
+ trace.files = [...new Set([...trace.files, ...maps])];
48
+ await fs.writeFile(file, JSON.stringify(trace));
49
+ }
37
50
  }
38
51
  /** Build-time companion for coverage.next(). Import this lightweight module in
39
52
  * next.config; it does not load the Spectest harness or require Next at runtime.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.119.5",
3
+ "version": "0.119.7",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",