@specific.dev/spectest 0.70.0 → 0.71.1

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/src/daemon.ts CHANGED
@@ -92,6 +92,7 @@ import {
92
92
  import { pollUntilReady } from "./harness/ready-poll.js";
93
93
  import { runWrapperRules } from "./harness/wrapper-rules.js";
94
94
  import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
95
+ import { cpus } from "node:os";
95
96
  import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
96
97
  import {
97
98
  isTextualContentType,
@@ -240,30 +241,49 @@ const DEFAULT_TEST_TIMEOUT_MS = 60_000;
240
241
  const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
241
242
 
242
243
  // Stable hostname every service container resolves to the host (the
243
- // `spectest-br0` gateway) — so apps that build or pull images at runtime
244
- // can point a builder at `spectest-host:5000` (the zot Docker Hub mirror)
245
- // or `spectest-host:1234` (the shared buildkitd) without hard-coding the
246
- // gateway IP. Injected into each container's /etc/hosts in runContainer.
244
+ // `spectest-br0` gateway) — so an app that runs its OWN BuildKit inside a
245
+ // test can point its cache export at the host's build-cache registry,
246
+ // `spectest-host:5007`, without hard-coding the gateway IP. Nothing else
247
+ // lives behind it any more: images are pulled and built inside the VM
248
+ // against the container store (CONTAINER_STORE.md). Injected into each
249
+ // container's /etc/hosts in runContainer.
247
250
  const SPECTEST_HOST_NAME = "spectest-host";
248
251
 
249
- // The host image-cache gateway, discovered once from the same
250
- // `registry-mirrors` entry the in-VM dockerd already uses (baked into the
251
- // local provider's golden /etc/docker/daemon.json). `null` when there's
252
- // no host cache, so nothing is injected.
252
+ // The host gateway, read once from the guest's own default route
253
+ // (/proc/net/route: destination 0, gateway as a little-endian hex word).
254
+ // `null` when the guest has no default route, in which case nothing is
255
+ // injected.
253
256
  let _hostCacheGateway: string | null | undefined;
254
257
  function hostCacheGateway(): string | null {
255
258
  if (_hostCacheGateway !== undefined) return _hostCacheGateway;
259
+ _hostCacheGateway = null;
256
260
  try {
257
- const cfg = JSON.parse(
258
- readFileSync("/etc/docker/daemon.json", "utf8"),
259
- ) as { "registry-mirrors"?: string[] };
260
- const first = cfg["registry-mirrors"]?.[0];
261
- _hostCacheGateway = first ? new URL(first).hostname || null : null;
261
+ for (const line of readFileSync("/proc/net/route", "utf8").split("\n").slice(1)) {
262
+ const f = line.trim().split(/\s+/);
263
+ if (f.length < 3 || f[1] !== "00000000") continue;
264
+ const hex = f[2];
265
+ const octets = [6, 4, 2, 0].map((i) => parseInt(hex.slice(i, i + 2), 16));
266
+ if (octets.every((o) => Number.isFinite(o))) {
267
+ _hostCacheGateway = octets.join(".");
268
+ break;
269
+ }
270
+ }
262
271
  } catch {
263
272
  _hostCacheGateway = null;
264
273
  }
265
274
  return _hostCacheGateway;
266
275
  }
276
+ /** vCPUs this guest has, for BuildKit's `max-parallelism` — the number
277
+ * Blacksmith sets too, and the one that matters now that the build
278
+ * competes for the guest's own cores rather than the host's. */
279
+ function cpuCount(): number {
280
+ try {
281
+ return Math.max(1, cpus().length);
282
+ } catch {
283
+ return 4;
284
+ }
285
+ }
286
+
267
287
  // WORKSPACE (/workspace) and APP_DIR (/opt/spectest/app) both live in
268
288
  // project-files.ts, next to the rule that decides which copy of a project
269
289
  // file is the current one.
@@ -688,46 +708,110 @@ async function hasBuildx(): Promise<boolean> {
688
708
  return _buildxAvailable;
689
709
  }
690
710
 
691
- // A single buildkitd runs on the host (see scripts/install-buildkitd.sh),
692
- // reachable from every VM at the bridge gateway. Building against it as a
693
- // `remote` buildx builder gives a persistent, shared layer/mount cache that
694
- // survives forks and warm-template misses — a fresh VM no longer rebuilds
695
- // from scratch. The build runs on the host (runc-isolated); `--load` pulls
696
- // the finished image back into the in-VM dockerd. Detected once; if the
697
- // builder can't be created or buildkitd is unreachable we fall back to the
698
- // in-VM builder, so a missing/dead buildkitd just means slower builds.
699
- const REMOTE_BUILDER_ADDR = process.env.SPECTEST_BUILDKIT_ADDR ?? "tcp://10.42.0.1:1234";
700
- const REMOTE_BUILDER_NAME = "spectest-remote";
701
- /** Parent of the per-build buildx config dirs (see isolatedBuildxConfig).
702
- * On tmpfs: each holds a builder stub and an 8-byte node id. */
703
- let _remoteBuilder: boolean | undefined;
704
- async function ensureRemoteBuilder(): Promise<boolean> {
705
- if (_remoteBuilder !== undefined) return _remoteBuilder;
706
- if (!(await hasBuildx())) {
707
- _remoteBuilder = false;
711
+ // Every VM carries its project's container store (CONTAINER_STORE.md):
712
+ // the disk every pull and every build lands on, mounted by the control
713
+ // plane before this harness starts. The manifest below says where; the
714
+ // in-VM buildkitd keeps its exported cache there too, so a fresh VM finds
715
+ // every layer it built before. Detected once; if the daemon will not
716
+ // start, dockerd's own BuildKit builds instead.
717
+ const IMAGE_CACHE_MANIFEST = "/run/spectest-image-cache.json";
718
+ const LOCAL_BUILDER_NAME = "spectest-local";
719
+ const LOCAL_BUILDKIT_ADDR = "tcp://127.0.0.1:1234";
720
+ /** The bring-up script the cache base bakes (`base.rs::BUILDKITD_UP_SH`). */
721
+ const BUILDKITD_UP_PATH = "/usr/local/bin/spectest-buildkitd-up";
722
+
723
+ /** Where the control plane mounted this VM's image cache: containerd's
724
+ * root (read-write, this VM's own) and the layers disk (read-only, shared
725
+ * by every VM of a generation). `null` when the VM carries no cache. */
726
+ async function imageCachePaths(): Promise<{ root: string; layers: string } | null> {
727
+ try {
728
+ const raw = await fs.readFile(IMAGE_CACHE_MANIFEST, "utf8");
729
+ const parsed = JSON.parse(raw) as { disks?: { role?: string; path?: string }[] };
730
+ const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
731
+ const layers = (parsed.disks ?? []).find((d) => d.role === "layers" && d.path)?.path;
732
+ return root && layers ? { root, layers } : null;
733
+ } catch {
734
+ return null;
735
+ }
736
+ }
737
+
738
+ let _localBuilder: boolean | undefined;
739
+ /**
740
+ * Start buildkitd inside this VM with its state on the cache disk, and
741
+ * register it as a buildx `remote` builder.
742
+ *
743
+ * Lazy on purpose: it runs on the first build a project actually does,
744
+ * so a project with no dockerfile service never pays for a builder. And
745
+ * best-effort: a daemon that will not start falls back to the shared host
746
+ * one, which is a slower build and not a failed one.
747
+ */
748
+ async function ensureLocalBuildkitd(): Promise<boolean> {
749
+ if (_localBuilder !== undefined) return _localBuilder;
750
+ _localBuilder = false;
751
+ const paths = await imageCachePaths();
752
+ if (!paths) return false;
753
+ const state = paths.root;
754
+ if (!(await hasBuildx())) return false;
755
+
756
+ // The bring-up itself is a script baked into the image cache's base
757
+ // snapshot (`base.rs::BUILDKITD_UP_SH`), so production and the real-VM
758
+ // test drive exactly the same daemon with exactly the same config. All
759
+ // it takes from us is where the state lives — the registry routing is
760
+ // its own (Docker Hub via `mirror.gcr.io`, deliberately not the host
761
+ // zot instance).
762
+ const started = await shx("/bin/bash", [BUILDKITD_UP_PATH, state], 900_000);
763
+ if (started.code !== 0) {
764
+ // eslint-disable-next-line no-console
765
+ console.warn(
766
+ `[build] in-VM buildkitd would not start; building with dockerd's own BuildKit:\n${(started.stderr || started.stdout).trim()}`,
767
+ );
708
768
  return false;
709
769
  }
710
- // Idempotent: a repeat create with the same name errors ("existing
711
- // instance"), which we treat as already-present.
712
770
  const create = await docker(
713
- ["buildx", "create", "--name", REMOTE_BUILDER_NAME, "--driver", "remote", REMOTE_BUILDER_ADDR],
771
+ ["buildx", "create", "--name", LOCAL_BUILDER_NAME, "--driver", "remote", LOCAL_BUILDKIT_ADDR],
714
772
  30_000,
715
773
  );
716
774
  if (create.code !== 0 && !/existing instance|already exists/i.test(create.stderr)) {
717
- _remoteBuilder = false;
775
+ // eslint-disable-next-line no-console
776
+ console.warn(`[build] could not register the in-VM builder:\n${create.stderr.trim()}`);
718
777
  return false;
719
778
  }
720
- // `inspect --bootstrap` actually dials buildkitd, so it's our reachability
721
- // probe. If buildkitd is down this fails and we fall back.
722
- const boot = await docker(["buildx", "inspect", "--bootstrap", REMOTE_BUILDER_NAME], 60_000);
723
- _remoteBuilder = boot.code === 0;
724
- if (!_remoteBuilder) {
779
+ // `inspect --bootstrap` dials the daemon, so it is both the readiness
780
+ // wait and the proof it is really answering.
781
+ const boot = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 120_000);
782
+ if (boot.code !== 0) {
725
783
  // eslint-disable-next-line no-console
726
- console.warn(
727
- `[build] remote buildkitd at ${REMOTE_BUILDER_ADDR} unreachable; using in-VM builder:\n${boot.stderr.trim()}`,
728
- );
784
+ console.warn(`[build] in-VM buildkitd never answered; building with dockerd's own BuildKit:\n${boot.stderr.trim()}`);
785
+ return false;
729
786
  }
730
- return _remoteBuilder;
787
+ // eslint-disable-next-line no-console
788
+ console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
789
+ // Imports come from the merged cache on the read-only layers disk and
790
+ // from this lineage's own exports on the root; exports go to the root,
791
+ // where the merge picks them up (image_cache/merge.rs).
792
+ _localCacheDir = `${state}/spectest-buildkit-cache`;
793
+ _localCacheImports = [`${paths.layers}/spectest-buildkit-cache`, `${state}/spectest-buildkit-cache`];
794
+ _localBuilder = true;
795
+ return true;
796
+ }
797
+ /** The exported-cache directory on the cache disk, once the in-VM builder is up. */
798
+ let _localCacheDir: string | null = null;
799
+ /** The cache directories a build imports from: the merged one on the
800
+ * layers disk, then this lineage's own exports. */
801
+ let _localCacheImports: string[] = [];
802
+ /**
803
+ * A reference as containerd names it. buildx's `-t` on a remote builder
804
+ * stores an unqualified name (`probe:bx`) that dockerd then cannot
805
+ * resolve (measured, CONTAINER_STORE.md), so the in-VM build names its
806
+ * output in full.
807
+ */
808
+ function qualifyImageRef(ref: string): string {
809
+ const slash = ref.indexOf("/");
810
+ const first = slash < 0 ? "" : ref.slice(0, slash);
811
+ const isRegistry = first.includes(".") || first.includes(":") || first === "localhost";
812
+ if (slash < 0) return `docker.io/library/${ref}`;
813
+ if (!isRegistry) return `docker.io/${ref}`;
814
+ return ref;
731
815
  }
732
816
 
733
817
  async function ensureNetwork(): Promise<void> {
@@ -788,7 +872,12 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
788
872
  // created here nor recorded for the delta-restore wipe.
789
873
  if (!(await isExistingNonDirectory(host))) {
790
874
  await fs.mkdir(host, { recursive: true });
791
- if (vol.source?.startsWith("/") && !host.startsWith("/var/cache/spectest/")) {
875
+ // …and neither is the pre-disks cache tree, kept for a server still
876
+ // serving older SDKs. Leaving it out of the manifest is what
877
+ // protects a project running this SDK against a server whose
878
+ // teardown guard predates it.
879
+ const durable = host.startsWith("/var/cache/spectest/");
880
+ if (vol.source?.startsWith("/") && !durable) {
792
881
  await recordAbsoluteVolumeDir(host);
793
882
  }
794
883
  }
@@ -1348,24 +1437,52 @@ async function runServiceBuild(
1348
1437
  // predates per-Dockerfile ignores — and is written ONLY when the
1349
1438
  // project ships none of its own (see readProjectDockerignore).
1350
1439
  await fs.writeFile(`${dfPath}.dockerignore`, serviceDockerignore(image.exclude));
1351
- const useRemote = await ensureRemoteBuilder();
1352
- // Both the remote builder and a local buildx are BuildKit, so both emit
1353
- // per-step timing on stderr under `--progress=plain` (parsed below). Only
1354
- // the legacy in-VM builder takes no progress flag.
1355
- const useBuildKit = useRemote || (await hasBuildx());
1440
+ // The in-VM buildkitd on the container store first: the build runs
1441
+ // inside the guest's own isolation boundary, against this project's
1442
+ // own layer cache, and the finished image is already in the store
1443
+ // dockerd reads. dockerd's built-in BuildKit is the fallback (a guest
1444
+ // with no store, or a daemon that would not start).
1445
+ const useLocal = await ensureLocalBuildkitd();
1446
+ // Both the in-VM daemon and dockerd's buildx are BuildKit, so both
1447
+ // emit per-step timing on stderr under `--progress=plain` (parsed
1448
+ // below). Only the legacy builder takes no progress flag.
1449
+ const useBuildKit = useLocal || (await hasBuildx());
1356
1450
  const buildEnv: Record<string, string> = {};
1357
1451
  let buildArgs: string[];
1358
1452
  // The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
1359
1453
  // so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
1360
1454
  const argFlags = buildArgFlags(image.buildArgs);
1361
- if (useRemote) {
1362
- // Build on the host-side shared buildkitd (persistent cross-VM cache);
1363
- // `--load` brings the finished image back into the in-VM dockerd so
1364
- // runContainer can `docker run` it. The build context (WORKSPACE, minus
1365
- // .dockerignore) streams to buildkitd over the bridge.
1455
+ if (useLocal && _localCacheDir) {
1456
+ // The in-VM builder is BuildKit's containerd worker on this VM's
1457
+ // own image store (CONTAINER_STORE.md): the output is an image
1458
+ // record in dockerd's namespace, unpacked, so there is no `--load`
1459
+ // and nothing crosses a socket. The cache directory on the image cache
1460
+ // disk is what outlives the VM; `mode=max` keeps every
1461
+ // intermediate layer, uncompressed so an import never inflates,
1462
+ // and one tag per service so exports do not replace each other.
1463
+ const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
1464
+ // LANDMINE: the local cache importer reads the `latest` entry of
1465
+ // the directory's index unless told otherwise, and the exporter
1466
+ // below writes this service's entry under `tag=<service>`. An
1467
+ // import without the same tag misses every time and every
1468
+ // `RUN` re-executes on a seeded cache (seen on the first deploy,
1469
+ // 2026-09-05: the disk carried the blobs, the build used none).
1470
+ buildArgs = [
1471
+ "buildx", "build",
1472
+ "--builder", LOCAL_BUILDER_NAME,
1473
+ "--progress=plain",
1474
+ "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1475
+ ..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
1476
+ "--cache-to", `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
1477
+ ...argFlags,
1478
+ "-f", dfPath, WORKSPACE,
1479
+ ];
1480
+ } else if (useLocal) {
1481
+ // The daemon came up but reported no cache directory: build on it
1482
+ // and `--load` the result into dockerd.
1366
1483
  buildArgs = [
1367
1484
  "buildx", "build",
1368
- "--builder", REMOTE_BUILDER_NAME,
1485
+ "--builder", LOCAL_BUILDER_NAME,
1369
1486
  "--load",
1370
1487
  "--progress=plain",
1371
1488
  ...argFlags,
@@ -1674,6 +1791,45 @@ async function waitForReady(svc: NamedService): Promise<void> {
1674
1791
  throw new Error(msg);
1675
1792
  }
1676
1793
 
1794
+ /**
1795
+ * Add the container's own account of its death to an error raised by its
1796
+ * `setup` hook.
1797
+ *
1798
+ * `waitForReady` already does this, because a container that never becomes
1799
+ * ready is obviously the container's fault. A `setup` hook is the case that
1800
+ * was missing, and it is the one that reads most misleadingly: the hook
1801
+ * talks to the service over the network, so when the container dies
1802
+ * mid-hook what surfaces is a name that no longer resolves or a rollout
1803
+ * that never finished — a symptom from the far end of a connection to
1804
+ * something that is not there any more. The reason is in a log that goes
1805
+ * with the VM at teardown.
1806
+ *
1807
+ * Only for a container that is **gone**: one that is still up did not cause
1808
+ * this, and its log would bury the real error. Best-effort throughout — a
1809
+ * diagnostic must never replace the failure it explains.
1810
+ */
1811
+ async function withContainerPostMortem(name: string, err: unknown): Promise<unknown> {
1812
+ try {
1813
+ const state = await docker(
1814
+ ["inspect", "-f", "{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}", name],
1815
+ 15_000,
1816
+ );
1817
+ const [running, code, oom] = state.stdout.trim().split(/\s+/);
1818
+ if (state.code !== 0 || running !== "false") return err;
1819
+ const logs = await docker(["logs", "--tail=120", name], 30_000);
1820
+ const output = `${logs.stdout}\n${logs.stderr}`.trim();
1821
+ const base = err instanceof Error ? err : new Error(String(err));
1822
+ base.message +=
1823
+ `\n\nThe "${name}" container exited (code ${code}` +
1824
+ `${oom === "true" ? ", OOM-killed" : ""}) while its setup hook was running, ` +
1825
+ `which is why the hook could not reach it.` +
1826
+ (output ? `\nIts last output:\n${output}` : `\nIt logged nothing.`);
1827
+ return base;
1828
+ } catch {
1829
+ return err;
1830
+ }
1831
+ }
1832
+
1677
1833
  /** Validate the `dependsOn` graph and return the name→service map used to
1678
1834
  * walk it. Rules live in `harness/service-graph.ts`. */
1679
1835
  function validateServiceGraph(services: NamedService[]): Map<string, NamedService> {
@@ -3448,18 +3604,14 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3448
3604
  // at "image ready" waiting for an unrelated slow build elsewhere.
3449
3605
  //
3450
3606
  // Prep concurrency: registry pulls always run in parallel (network-bound,
3451
- // low VM RAM). Dockerfile builds parallelize *only* when the host
3452
- // buildkitd is in play — there the build executes host-side under runc, so
3453
- // N concurrent builds don't touch the VM's memory ceiling. When we fall
3454
- // back to the in-VM builder, two or more concurrent builds routinely OOM a
3455
- // single VM on monorepos with parallel pnpm/npm installs (each install
3456
- // fans out to ~16 fetchers + lifecycle workers, ~70 MB/process), so we
3457
- // serialize that case behind a FIFO chain — but only the in-VM builds
3458
- // serialize; pulls and starts run freely alongside them. The remote-builder
3459
- // probe is memoized, so this up-front call is free; skip it with no builds.
3607
+ // low VM RAM). Dockerfile builds run inside the VM, and two or more
3608
+ // concurrent builds routinely OOM a single VM on monorepos with parallel
3609
+ // pnpm/npm installs (each install fans out to ~16 fetchers + lifecycle
3610
+ // workers, ~70 MB/process), so builds serialize behind a FIFO chain —
3611
+ // but only the builds; pulls and starts run freely alongside them.
3460
3612
  const tags = new Map<string, string>();
3461
3613
  const builds = services.filter((s) => s.image.type === "dockerfile");
3462
- const buildsRunHostSide = builds.length > 0 && (await ensureRemoteBuilder());
3614
+ const buildsRunHostSide = false;
3463
3615
  // A promise chain is a fair FIFO mutex: when builds run in-VM, each build
3464
3616
  // waits for the previous to settle. Pulls and host-side builds bypass it.
3465
3617
  let inVmBuildChain: Promise<unknown> = Promise.resolve();
@@ -3546,11 +3698,15 @@ async function bootstrapInner(): Promise<BootstrapTimings> {
3546
3698
  if (svc.setup) {
3547
3699
  progressService(svc.name, { status: "probing", detail: "running setup" });
3548
3700
  const helpers = await ensureHelpers(svc.name, svc);
3549
- await svc.setup({
3550
- name: svc.name,
3551
- helpers,
3552
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3553
- });
3701
+ try {
3702
+ await svc.setup({
3703
+ name: svc.name,
3704
+ helpers,
3705
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3706
+ });
3707
+ } catch (err) {
3708
+ throw await withContainerPostMortem(svc.name, err);
3709
+ }
3554
3710
  }
3555
3711
  progressService(svc.name, { status: "ready", detail: undefined });
3556
3712
  const ti = timings.get(svc.name);
@@ -1,7 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
- CACHE_ROOT,
5
4
  resolveHostPath,
6
5
  sanitizeSegment,
7
6
  survivesTeardown,
@@ -68,35 +67,3 @@ describe("resolveHostPath", () => {
68
67
  });
69
68
  });
70
69
 
71
- describe("cache volumes and delta teardown", () => {
72
- /** The whole point of the flag: teardown does `rm -rf /workspace`, so a
73
- * cache volume must be rooted outside it or it does not survive. */
74
- test("a cache volume is rooted outside the workspace", () => {
75
- const host = resolveHostPath("bun", { target: "/root/.bun/install/cache", cache: true }, WS);
76
- expect(host.startsWith(CACHE_ROOT)).toBe(true);
77
- expect(host.startsWith(WS)).toBe(false);
78
- });
79
-
80
- test("a non-cache volume is inside the workspace, so teardown wipes it", () => {
81
- const host = resolveHostPath("db", { target: "/data" }, WS);
82
- expect(host.startsWith(`${WS}/`)).toBe(true);
83
- });
84
-
85
- test("a shared volume honours the cache flag too", () => {
86
- const cached = resolveHostPath("a", { name: "layers", target: "/x", cache: true }, WS);
87
- const plain = resolveHostPath("a", { name: "layers", target: "/x" }, WS);
88
- expect(cached.startsWith(CACHE_ROOT)).toBe(true);
89
- expect(plain.startsWith(`${WS}/`)).toBe(true);
90
- expect(cached).not.toBe(plain);
91
- });
92
-
93
- test("survivesTeardown agrees with where the path landed", () => {
94
- expect(survivesTeardown({ target: "/data" }, "db", WS)).toBe(false);
95
- expect(survivesTeardown({ target: "/data", cache: true }, "db", WS)).toBe(true);
96
- // An absolute source outside /workspace also survives — worth knowing,
97
- // because it is a way to keep state by accident.
98
- expect(survivesTeardown({ source: "/mnt/big", target: "/d" }, "db", WS)).toBe(true);
99
- // …but an absolute source inside /workspace does not.
100
- expect(survivesTeardown({ source: "/workspace/keep", target: "/d" }, "db", WS)).toBe(false);
101
- });
102
- });
@@ -6,27 +6,18 @@
6
6
  * delta-restore teardown**.
7
7
  *
8
8
  * Teardown wipes `/workspace` to give a restored environment fresh-state
9
- * semantics. Anything that must survive it therefore has to live outside
10
- * `/workspace` — and that is exactly what `cache: true` selects, by
11
- * rooting the directory under `/var/cache/spectest/volumes` instead.
12
- *
13
- * The flag is only ever correct for **content-addressed accelerator
14
- * data**: package stores, layer caches — data whose presence can change
15
- * how *fast* something runs but never *what* it does. It is wrong for any
16
- * real state, because a restored environment would then start with a
17
- * previous run's data and stop being reproducible. (Counter-example worth
18
- * remembering: `k3s()` deliberately does not cache its containerd store —
19
- * a fresh cluster over an un-cleanly-killed store wedged the apiserver.)
9
+ * semantics. Every volume lives inside it: the one cache spectest keeps
10
+ * across environments is the container store, which is not a volume at
11
+ * all (CONTAINER_STORE.md).
20
12
  */
21
13
 
22
14
  import path from "node:path";
23
15
 
16
+ import { expandServiceToken } from "./file-mounts";
17
+
24
18
  /** Root of the per-environment state tree. Wiped by delta teardown. */
25
19
  export const DEFAULT_WORKSPACE = "/workspace";
26
20
 
27
- /** Root of the cache tree. Deliberately outside the workspace. */
28
- export const CACHE_ROOT = "/var/cache/spectest/volumes";
29
-
30
21
  /** Directory holding named shared volumes, under whichever root applies. */
31
22
  export const SHARED_DIR = "_shared";
32
23
 
@@ -40,8 +31,6 @@ export interface VolumeSpec {
40
31
  /** Path inside the container. Used to derive a directory when neither
41
32
  * `name` nor `source` is given. */
42
33
  target: string;
43
- /** Survive the delta-restore teardown. Content-addressed data only. */
44
- cache?: boolean;
45
34
  }
46
35
 
47
36
  /**
@@ -70,6 +59,13 @@ export function sanitizeSegment(p: string): string {
70
59
  * 3. A relative `source`, or nothing at all — under the service's own
71
60
  * directory, derived from `target` when `source` is absent.
72
61
  *
62
+ * `source` honours the `{{SPECTEST_SERVICE}}` token, for the same reason
63
+ * `files` does: a component cannot know the map key the user will give it,
64
+ * and an **absolute** source gets no automatic per-service directory. A
65
+ * component that needs one — a nested runtime keeping its store under
66
+ * {@link NESTED_STORE_ROOT}, where two of them sharing one directory would
67
+ * be two daemons on one metadata store — writes the token into the path.
68
+ *
73
69
  * `workspace` is a parameter rather than a module constant so the rule is
74
70
  * testable without touching the filesystem.
75
71
  */
@@ -79,19 +75,18 @@ export function resolveHostPath(
79
75
  workspace: string = DEFAULT_WORKSPACE,
80
76
  ): string {
81
77
  const stateRoot = [workspace, ".spectest", "volumes"];
78
+ const source = vol.source === undefined ? undefined : expandServiceToken(vol.source, service);
82
79
 
83
80
  if (vol.name) {
84
- const root = vol.cache ? [CACHE_ROOT, SHARED_DIR] : [...stateRoot, SHARED_DIR];
85
- return path.join(...root, sanitizeSegment(vol.name));
81
+ return path.join(...stateRoot, SHARED_DIR, sanitizeSegment(vol.name));
86
82
  }
87
83
 
88
- // An absolute source is the project's own path; `cache` doesn't apply
89
- // because the location was already chosen explicitly.
90
- if (vol.source && vol.source.startsWith("/")) return vol.source;
84
+ // An absolute source is the project's own path, used as is.
85
+ if (source && source.startsWith("/")) return source;
91
86
 
92
- const root = vol.cache ? [CACHE_ROOT, service] : [...stateRoot, service];
93
- if (vol.source) {
94
- return path.join(...root, vol.source.replace(/^\/+/, ""));
87
+ const root = [...stateRoot, service];
88
+ if (source) {
89
+ return path.join(...root, source.replace(/^\/+/, ""));
95
90
  }
96
91
  return path.join(...root, sanitizeSegment(vol.target));
97
92
  }
@@ -99,8 +94,9 @@ export function resolveHostPath(
99
94
  /**
100
95
  * Does this volume survive a delta-restore teardown?
101
96
  *
102
- * True for cache-flagged volumes and for absolute sources outside the
103
- * workspace — the two ways a directory ends up beyond `rm -rf /workspace`.
97
+ * True for a volume on a mounted cache disk, and for an absolute source
98
+ * outside the workspace — the two ways a directory ends up beyond
99
+ * `rm -rf /workspace`.
104
100
  */
105
101
  export function survivesTeardown(
106
102
  vol: VolumeSpec,
package/src/index.ts CHANGED
@@ -1276,15 +1276,6 @@ export interface VolumeMount {
1276
1276
  /** Container path. */
1277
1277
  target: string;
1278
1278
  readOnly?: boolean;
1279
- /**
1280
- * Cache volume: the backing dir lives outside the per-env state tree and
1281
- * survives a delta-restore teardown (which recreates every container,
1282
- * volume, and the daemon for fresh-state semantics). Reserve this for
1283
- * content-addressed data whose presence is purely an accelerator — an
1284
- * image/layer store, a package cache — never for app state: anything in
1285
- * a cache volume is visible to the "fresh" environment.
1286
- */
1287
- cache?: boolean;
1288
1279
  }
1289
1280
 
1290
1281
  export interface FileMount {