@specific.dev/spectest 0.71.1 → 0.73.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.
package/dist/daemon.js CHANGED
@@ -50,6 +50,7 @@ import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectC
50
50
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
51
51
  import { startTlsTerminator } from "./harness/tls-terminator.js";
52
52
  import { runContainerArgs } from "./harness/container-run.js";
53
+ import { IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache } from "./harness/image-cache.js";
53
54
  import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
54
55
  import { conflict, notFound, requireString, } from "./harness/methods.js";
55
56
  import { openTerminal } from "./terminal.js";
@@ -448,7 +449,6 @@ async function hasBuildx() {
448
449
  // in-VM buildkitd keeps its exported cache there too, so a fresh VM finds
449
450
  // every layer it built before. Detected once; if the daemon will not
450
451
  // start, dockerd's own BuildKit builds instead.
451
- const IMAGE_CACHE_MANIFEST = "/run/spectest-image-cache.json";
452
452
  const LOCAL_BUILDER_NAME = "spectest-local";
453
453
  const LOCAL_BUILDKIT_ADDR = "tcp://127.0.0.1:1234";
454
454
  /** The bring-up script the cache base bakes (`base.rs::BUILDKITD_UP_SH`). */
@@ -457,16 +457,7 @@ const BUILDKITD_UP_PATH = "/usr/local/bin/spectest-buildkitd-up";
457
457
  * root (read-write, this VM's own) and the layers disk (read-only, shared
458
458
  * by every VM of a generation). `null` when the VM carries no cache. */
459
459
  async function imageCachePaths() {
460
- try {
461
- const raw = await fs.readFile(IMAGE_CACHE_MANIFEST, "utf8");
462
- const parsed = JSON.parse(raw);
463
- const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
464
- const layers = (parsed.disks ?? []).find((d) => d.role === "layers" && d.path)?.path;
465
- return root && layers ? { root, layers } : null;
466
- }
467
- catch {
468
- return null;
469
- }
460
+ return imageCachePathsSync(IMAGE_CACHE_MANIFEST);
470
461
  }
471
462
  let _localBuilder;
472
463
  /**
@@ -602,7 +593,10 @@ async function ensureVolumes(svc) {
602
593
  // serving older SDKs. Leaving it out of the manifest is what
603
594
  // protects a project running this SDK against a server whose
604
595
  // teardown guard predates it.
605
- const durable = host.startsWith("/var/cache/spectest/");
596
+ // A directory on a cache disk is the same kind of thing: a nested
597
+ // runtime's containerd root (`k3s()`), kept as a cache by the
598
+ // lineage exactly as the container store one level up is.
599
+ const durable = host.startsWith("/var/cache/spectest/") || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
606
600
  if (vol.source?.startsWith("/") && !durable) {
607
601
  await recordAbsoluteVolumeDir(host);
608
602
  }
@@ -919,7 +913,6 @@ async function prepareServiceImage(svc, opts) {
919
913
  if (tagr.code !== 0) {
920
914
  throw new Error(`docker tag ${ref} ${tag} failed: ${tagr.stderr.trim()}`);
921
915
  }
922
- await ensureCaTrustedImage(svc.name, tag);
923
916
  return { tag };
924
917
  }
925
918
  // Dockerfile build. Within one bootstrap, identical definitions (shared
@@ -964,61 +957,6 @@ async function prepareServiceImage(svc, opts) {
964
957
  }
965
958
  return buildServiceImage(svc.name, image, tag);
966
959
  }
967
- /** Printed by the folded CA step when it could not write the trust
968
- * store at all — the one outcome that still needs the derivative build. */
969
- const CA_FOLD_UNWRITABLE = "[spectest-ca] trust store not writable";
970
- /**
971
- * The CA-trust steps as a suffix appended to a dockerfile service's OWN
972
- * Dockerfile, so one build produces the finished image instead of a build
973
- * plus a derivative rebuild ({@link ensureCaTrustedImage}) per service.
974
- * Returns null when there is no CA to layer, or when the PEM can't be
975
- * quoted — the caller then falls back to the derivative build.
976
- *
977
- * The PEM is written INLINE rather than `COPY`d: the build context is
978
- * `/workspace` under a per-service ignore file that the project itself
979
- * contributes to (a `**` line with re-includes is the common idiom), and
980
- * a context path we don't control is a context path that can be excluded.
981
- * printf needs nothing but a shell.
982
- *
983
- * Two rules make this safe to bolt onto user code. It must never fail the
984
- * build — every branch ends in an echo, so the RUN exits 0 whatever the
985
- * image lacks — and it must never change the image, beyond the trust
986
- * store: notably no `USER root`, since we cannot know statically what
987
- * user to hand back. An image that declares a non-root user therefore
988
- * fails to write and is finished by the derivative build, which inspects
989
- * the built image and can escalate properly.
990
- */
991
- async function caTrustSuffix() {
992
- if (!existsSync(CA_PATH))
993
- return null;
994
- const pem = (await fs.readFile(CA_PATH, "utf8")).trim();
995
- // A quote in the PEM would break out of the shell quoting below. PEM is
996
- // base64 and dashes, so this is a guard, not a case we expect.
997
- if (!pem || pem.includes("'"))
998
- return null;
999
- const args = pem
1000
- .split("\n")
1001
- .map((l) => `'${l.trimEnd()}'`)
1002
- .join(" ");
1003
- const dst = "/usr/local/share/ca-certificates/spectest-ca.crt";
1004
- return `
1005
- # spectest: trust the environment's root CA. Appended by the harness —
1006
- # not part of the project's Dockerfile.
1007
- RUN P='[spectest-ca]'; \\
1008
- mkdir -p /usr/local/share/ca-certificates 2>/dev/null; \\
1009
- if printf '%s\\n' ${args} > ${dst} 2>/dev/null; then \\
1010
- if command -v update-ca-certificates >/dev/null 2>&1 && update-ca-certificates >/dev/null 2>&1; then \\
1011
- echo "$P trusted via update-ca-certificates"; \\
1012
- elif command -v update-ca-trust >/dev/null 2>&1 && cp ${dst} /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract >/dev/null 2>&1; then \\
1013
- echo "$P trusted via update-ca-trust"; \\
1014
- else \\
1015
- echo "$P no system CA trust tool in image; env-var trust only"; \\
1016
- fi; \\
1017
- else \\
1018
- echo "$P trust store not writable by this image's user"; \\
1019
- fi
1020
- `;
1021
- }
1022
960
  /** The built form of a dockerfile image. `resolveServiceImage` read a
1023
961
  * `path` into `content` at config time, so a service that still carries
1024
962
  * `path` here skipped that step — a programming error, not user input. */
@@ -1030,58 +968,29 @@ function dockerfileContent(svc) {
1030
968
  return image;
1031
969
  }
1032
970
  /**
1033
- * Build a dockerfile service's image.
971
+ * Build a dockerfile service's image — the project's Dockerfile as
972
+ * written, nothing appended.
1034
973
  *
1035
- * The CA-trust layer is folded into THIS build when it can be (see
1036
- * {@link caTrustSuffix}), so a service costs one image build and one
1037
- * export rather than two. The derivative build stays as the fallback for
1038
- * everything the folded form can't serve: an image with no shell to run
1039
- * the step (distroless, scratch — the appended RUN can't execute, so the
1040
- * build fails and we rebuild the project's Dockerfile untouched), and an
1041
- * image that declares a non-root user (the step runs as that user and
1042
- * can't write the trust store).
974
+ * Trust in the environment's CA is not the image's business any more:
975
+ * `runContainer` mounts the combined bundle over the system trust store
976
+ * (`container-run.ts`, `SYSTEM_TRUST_BUNDLE_PATHS`). Until 2026-09-06 a
977
+ * CA step was folded into this build and a derivative `docker build`
978
+ * finished any image the fold could not write to (non-root `USER`, no
979
+ * shell), which was one or two extra builds per service, and one per
980
+ * pulled image, on every cold start.
1043
981
  */
1044
982
  async function buildServiceImage(name, image, tag) {
1045
- const suffix = await caTrustSuffix();
1046
- let attempt = await runServiceBuild(name, image, tag, suffix);
1047
- if (!attempt.ok && suffix) {
1048
- // Our step must not be able to break a project's build.
1049
- // eslint-disable-next-line no-console
1050
- console.warn(`[ca-trust] folded CA step could not run in ${name}'s image; rebuilding without it`);
1051
- attempt = await runServiceBuild(name, image, tag, null);
1052
- }
983
+ const attempt = await runServiceBuild(name, image, tag);
1053
984
  if (!attempt.ok) {
1054
985
  progressService(name, { status: "failed" });
1055
986
  throw new Error(`docker build for ${name} failed:\n${attempt.log}`);
1056
987
  }
1057
- // The derivative build is still needed for the one thing the folded step
1058
- // cannot do: write the trust store of an image that does not run as
1059
- // root. That is decided on the IMAGE, not on the build log — a CACHED
1060
- // layer prints nothing, so a log-only check would quietly stop
1061
- // re-applying the moment BuildKit had the layer. The log covers the
1062
- // rarer case of a root image whose /etc is read-only.
1063
- //
1064
- // An image with no trust tool at all needs nothing further: the
1065
- // derivative build would reach the same dead end, and `runContainer`'s
1066
- // env vars are the fallback either way.
1067
- const needsDerivative = !attempt.folded ||
1068
- attempt.log.includes(CA_FOLD_UNWRITABLE) ||
1069
- !(await imageRunsAsRoot(tag));
1070
- if (needsDerivative)
1071
- await ensureCaTrustedImage(name, tag);
1072
988
  return { tag, buildSteps: attempt.buildSteps };
1073
989
  }
1074
- /** Whether `tag`'s declared `USER` is root (or unset, which means root). */
1075
- async function imageRunsAsRoot(tag) {
1076
- const user = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
1077
- return user === "" || user === "root" || user === "0";
1078
- }
1079
- async function runServiceBuild(name, image, tag, caSuffix) {
990
+ async function runServiceBuild(name, image, tag) {
1080
991
  let buildSteps;
1081
992
  {
1082
- const content = caSuffix
1083
- ? `${image.content.replace(/\n*$/, "\n")}${caSuffix}`
1084
- : image.content;
993
+ const content = image.content;
1085
994
  const dfDir = path.join(WORKSPACE, ".spectest", "services", name);
1086
995
  await fs.mkdir(dfDir, { recursive: true });
1087
996
  const dfPath = path.join(dfDir, "Dockerfile");
@@ -1182,9 +1091,7 @@ async function runServiceBuild(name, image, tag, caSuffix) {
1182
1091
  });
1183
1092
  const log = `${build.stderr.trim()}\n${build.stdout.trim()}`;
1184
1093
  if (build.code !== 0) {
1185
- // The caller decides whether this is fatal: a failure with the CA
1186
- // step appended is retried without it before anyone hears about it.
1187
- return { ok: false, folded: caSuffix !== null, log };
1094
+ return { ok: false, log };
1188
1095
  }
1189
1096
  if (useBuildKit) {
1190
1097
  // Keep only the slowest dozen steps ≥1s — enough to profile, small
@@ -1193,50 +1100,7 @@ async function runServiceBuild(name, image, tag, caSuffix) {
1193
1100
  .filter((s) => s.secs >= 1)
1194
1101
  .slice(0, 12);
1195
1102
  }
1196
- return { ok: true, folded: caSuffix !== null, log, buildSteps };
1197
- }
1198
- }
1199
- /**
1200
- * Build a derivative image on top of `tag` that copies the spectest
1201
- * root CA into the system trust store. Tagged back as `tag`, so the
1202
- * rest of the orchestrator (runContainer, image cache) is oblivious.
1203
- * Failures are warned-and-ignored: the env-var injection in
1204
- * runContainer is the universal fallback, so apps that use it (most
1205
- * Node/Python/Ruby/AWS clients) still trust the CA even when the
1206
- * image's trust store can't be updated.
1207
- */
1208
- async function ensureCaTrustedImage(serviceName, tag) {
1209
- if (!existsSync(CA_PATH)) {
1210
- // Daemon running outside a base-snapshot VM (dev/test). Nothing to
1211
- // layer; env vars also harmless (they point at a missing path, but
1212
- // most consumers ignore missing files).
1213
- return;
1214
- }
1215
- const ctxDir = path.join(WORKSPACE, ".spectest", "ca-trust", serviceName);
1216
- await fs.mkdir(ctxDir, { recursive: true });
1217
- await fs.copyFile(CA_PATH, path.join(ctxDir, "spectest-ca.crt"));
1218
- // An image that declares a non-root `USER` runs this step as that user,
1219
- // and writing the trust store then fails ("cannot create
1220
- // /etc/ssl/certs/ca-certificates.crt.new: Permission denied") — the CA
1221
- // silently never lands. Take root for the one command, then hand the
1222
- // image back its own user so containers still run as it.
1223
- const declaredUser = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
1224
- const needsRoot = declaredUser !== "" && declaredUser !== "root" && declaredUser !== "0";
1225
- const dockerfile = `FROM ${tag}
1226
- ${needsRoot ? "USER root\n" : ""}COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
1227
- RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
1228
- update-ca-certificates; \\
1229
- elif command -v update-ca-trust >/dev/null 2>&1; then \\
1230
- cp /usr/local/share/ca-certificates/spectest-ca.crt /etc/pki/ca-trust/source/anchors/spectest-ca.crt && update-ca-trust extract; \\
1231
- else \\
1232
- echo "[spectest] no system CA trust tool in image; env-var trust only"; \\
1233
- fi
1234
- ${needsRoot ? `USER ${declaredUser}\n` : ""}`;
1235
- await fs.writeFile(path.join(ctxDir, "Dockerfile"), dockerfile);
1236
- const build = await docker(["build", "-t", tag, ctxDir], 300_000);
1237
- if (build.code !== 0) {
1238
- // eslint-disable-next-line no-console
1239
- console.warn(`[ca-trust] could not layer spectest CA into ${serviceName} (${tag}); env-var fallback only:\n${build.stderr.trim() || build.stdout.trim()}`);
1103
+ return { ok: true, log, buildSteps };
1240
1104
  }
1241
1105
  }
1242
1106
  /** Memoized across containers — the CA never rotates within a VM's life. */
@@ -1246,10 +1110,14 @@ let caBundlePromise = null;
1246
1110
  * return its path, or null when there's no CA to trust (daemon running
1247
1111
  * outside a base-snapshot VM).
1248
1112
  *
1249
- * This is what the replace-semantics trust env vars must point at. Falling
1250
- * back to the bare CA when the guest bundle is unreadable keeps the old
1251
- * behaviour — fakes verify, public HTTPS doesn't — which is strictly better
1252
- * than dropping the CA and breaking the fakes everything else depends on.
1113
+ * This is what the replace-semantics trust env vars must point at, and
1114
+ * what `runContainer` mounts over the image's own system trust store
1115
+ * (`container-run.ts`). Falling back to the bare CA when the guest bundle
1116
+ * is unreadable keeps the old behaviour for the variables — fakes verify,
1117
+ * public HTTPS doesn't — which is strictly better than dropping the CA and
1118
+ * breaking the fakes everything else depends on; the system-store mount
1119
+ * is skipped in that case, since the bare CA there would take the public
1120
+ * roots away from clients that read no variable.
1253
1121
  */
1254
1122
  async function ensureCaBundle() {
1255
1123
  caBundlePromise ??= (async () => {
@@ -35,6 +35,20 @@
35
35
  * `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
36
36
  * and the platform store is `SSL_CERT_FILE` — the combined bundle.
37
37
  *
38
+ * **The system trust store is the fourth, and it is a mount, not a
39
+ * layer.** Go, curl, OpenSSL's defaults and Python's `ssl` with no
40
+ * variable set read the distro's bundle file directly, so the combined
41
+ * bundle is bind-mounted read-only over the paths those bundles live at
42
+ * ({@link SYSTEM_TRUST_BUNDLE_PATHS}). Until 2026-09-06 this was a
43
+ * derivative image build per service (`COPY` the CA, run
44
+ * `update-ca-certificates`) — a `docker build` for every image, built or
45
+ * pulled, on every cold start, and most of what a cached bring-up still
46
+ * paid. A mount costs nothing, needs no cache, and behaves the same on a
47
+ * warm, delta or cold start. What it does not cover: a Java keystore
48
+ * (`cacerts`), and an entrypoint that runs `update-ca-certificates`
49
+ * itself — its final `mv` over the bundle fails with EBUSY on a
50
+ * mountpoint.
51
+ *
38
52
  * **`command` and `args` are different overrides.** `command` replaces the
39
53
  * entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
40
54
  * overrides CMD, which is what init-wrapped images like postgres need to
@@ -91,5 +105,17 @@ export interface ContainerRunInput {
91
105
  * never `--network=host`, where writing `net.*` is denied.
92
106
  */
93
107
  export declare const TCP_RETRIES2 = 6;
108
+ /**
109
+ * Where a distro keeps the bundle that clients reading the system store
110
+ * open. Debian, Ubuntu and Alpine resolve every default path to the first
111
+ * through symlinks (`/etc/ssl/cert.pem`, `/usr/lib/ssl/cert.pem`); the
112
+ * RHEL family resolves to the second (itself a symlink into
113
+ * `/etc/pki/ca-trust/extracted/pem/`, which the runtime follows before it
114
+ * mounts). Go checks both names itself. An image with neither path gets
115
+ * the file created by the mount, which is what a scratch image with a
116
+ * Go binary wants. Read-only, so a container cannot change what its
117
+ * siblings trust.
118
+ */
119
+ export declare const SYSTEM_TRUST_BUNDLE_PATHS: readonly ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"];
94
120
  /** Build the full `docker run` argv. Pure: no I/O, no module state. */
95
121
  export declare function runContainerArgs(input: ContainerRunInput): string[];
@@ -35,6 +35,20 @@
35
35
  * `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
36
36
  * and the platform store is `SSL_CERT_FILE` — the combined bundle.
37
37
  *
38
+ * **The system trust store is the fourth, and it is a mount, not a
39
+ * layer.** Go, curl, OpenSSL's defaults and Python's `ssl` with no
40
+ * variable set read the distro's bundle file directly, so the combined
41
+ * bundle is bind-mounted read-only over the paths those bundles live at
42
+ * ({@link SYSTEM_TRUST_BUNDLE_PATHS}). Until 2026-09-06 this was a
43
+ * derivative image build per service (`COPY` the CA, run
44
+ * `update-ca-certificates`) — a `docker build` for every image, built or
45
+ * pulled, on every cold start, and most of what a cached bring-up still
46
+ * paid. A mount costs nothing, needs no cache, and behaves the same on a
47
+ * warm, delta or cold start. What it does not cover: a Java keystore
48
+ * (`cacerts`), and an entrypoint that runs `update-ca-certificates`
49
+ * itself — its final `mv` over the bundle fails with EBUSY on a
50
+ * mountpoint.
51
+ *
38
52
  * **`command` and `args` are different overrides.** `command` replaces the
39
53
  * entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
40
54
  * overrides CMD, which is what init-wrapped images like postgres need to
@@ -57,6 +71,21 @@
57
71
  * never `--network=host`, where writing `net.*` is denied.
58
72
  */
59
73
  export const TCP_RETRIES2 = 6;
74
+ /**
75
+ * Where a distro keeps the bundle that clients reading the system store
76
+ * open. Debian, Ubuntu and Alpine resolve every default path to the first
77
+ * through symlinks (`/etc/ssl/cert.pem`, `/usr/lib/ssl/cert.pem`); the
78
+ * RHEL family resolves to the second (itself a symlink into
79
+ * `/etc/pki/ca-trust/extracted/pem/`, which the runtime follows before it
80
+ * mounts). Go checks both names itself. An image with neither path gets
81
+ * the file created by the mount, which is what a scratch image with a
82
+ * Go binary wants. Read-only, so a container cannot change what its
83
+ * siblings trust.
84
+ */
85
+ export const SYSTEM_TRUST_BUNDLE_PATHS = [
86
+ "/etc/ssl/certs/ca-certificates.crt",
87
+ "/etc/pki/tls/certs/ca-bundle.crt",
88
+ ];
60
89
  /** Build the full `docker run` argv. Pure: no I/O, no module state. */
61
90
  export function runContainerArgs(input) {
62
91
  const { svc, tag, network } = input;
@@ -107,6 +136,14 @@ export function runContainerArgs(input) {
107
136
  args.push("-e", `SSL_CERT_FILE=${bundle}`);
108
137
  args.push("-e", `REQUESTS_CA_BUNDLE=${bundle}`);
109
138
  args.push("-e", `AWS_CA_BUNDLE=${bundle}`);
139
+ // The system store itself, for clients that read no variable at all.
140
+ // Only a real bundle: the caller falls back to the bare CA when the
141
+ // guest's roots are unreadable, and the bare CA over the system store
142
+ // would leave Go and curl trusting spectest and nothing else.
143
+ if (bundle !== input.caPath) {
144
+ for (const p of SYSTEM_TRUST_BUNDLE_PATHS)
145
+ args.push(`--volume=${bundle}:${p}:ro`);
146
+ }
110
147
  }
111
148
  // The service's own env comes after ours, so a project can override the
112
149
  // defaults we set rather than being silently overridden by them.
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The image cache as the guest sees it (`CONTAINER_STORE.md`).
3
+ *
4
+ * The control plane writes one manifest per VM at start naming the two
5
+ * cache disks it attached: the **root** (containerd's own root,
6
+ * read-write, this VM's clone) and the **layers** disk (read-only, one
7
+ * EROFS file per layer, shared by every VM of a generation). Both paths
8
+ * are fixed by the control plane; the manifest is how a harness learns
9
+ * whether this VM carries a cache at all (the fake backend does not,
10
+ * and neither does a server older than the cache).
11
+ *
12
+ * Read synchronously as well as asynchronously: a component's service
13
+ * definition is built inside `defineEnvironment`, which is synchronous,
14
+ * and `k3s()` decides its mounts there.
15
+ */
16
+ /** Written by `env.rs` before the harness starts. */
17
+ export declare const IMAGE_CACHE_MANIFEST = "/run/spectest-image-cache.json";
18
+ /** Where the cache's paths are, when this VM carries one. */
19
+ export interface ImageCachePaths {
20
+ /** containerd's root: read-write, this VM's own clone. */
21
+ root: string;
22
+ /** The layers disk: read-only, shared by every VM of a generation. */
23
+ layers: string;
24
+ }
25
+ /**
26
+ * Directory under the root disk holding a nested runtime's containerd
27
+ * root, one per service: `<root>/spectest-nested/<service>`. The merge
28
+ * (`image_cache/merge.rs::NESTED_DIR`) reads every store it finds there
29
+ * exactly as it reads the disk's own.
30
+ */
31
+ export declare const NESTED_STORES_DIR = "spectest-nested";
32
+ /** The guest's static `mkfs.erofs`, which a nested runtime's EROFS
33
+ * differ needs and no runtime image ships. */
34
+ export declare const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
35
+ /** The guest's adopt helper (`base.rs::STORE_ADOPT_SH`), POSIX sh so a
36
+ * nested runtime's busybox can run the same file. */
37
+ export declare const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
38
+ /** The cache's paths, or `null` when this VM carries none.
39
+ * `SPECTEST_IMAGE_CACHE_MANIFEST` points a test at another file. */
40
+ export declare function imageCachePathsSync(manifest?: string): ImageCachePaths | null;
41
+ /** The host directory a nested runtime keeps its containerd root in. */
42
+ export declare function nestedStoreDir(paths: ImageCachePaths, service: string): string;
43
+ /**
44
+ * Is a volume's host path on a cache disk? Such a directory is a cache
45
+ * the lineage keeps — like the container store one level up — and the
46
+ * delta-restore teardown must not wipe it.
47
+ */
48
+ export declare function isOnImageCache(hostPath: string, paths: ImageCachePaths | null): boolean;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The image cache as the guest sees it (`CONTAINER_STORE.md`).
3
+ *
4
+ * The control plane writes one manifest per VM at start naming the two
5
+ * cache disks it attached: the **root** (containerd's own root,
6
+ * read-write, this VM's clone) and the **layers** disk (read-only, one
7
+ * EROFS file per layer, shared by every VM of a generation). Both paths
8
+ * are fixed by the control plane; the manifest is how a harness learns
9
+ * whether this VM carries a cache at all (the fake backend does not,
10
+ * and neither does a server older than the cache).
11
+ *
12
+ * Read synchronously as well as asynchronously: a component's service
13
+ * definition is built inside `defineEnvironment`, which is synchronous,
14
+ * and `k3s()` decides its mounts there.
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ /** Written by `env.rs` before the harness starts. */
18
+ export const IMAGE_CACHE_MANIFEST = "/run/spectest-image-cache.json";
19
+ /**
20
+ * Directory under the root disk holding a nested runtime's containerd
21
+ * root, one per service: `<root>/spectest-nested/<service>`. The merge
22
+ * (`image_cache/merge.rs::NESTED_DIR`) reads every store it finds there
23
+ * exactly as it reads the disk's own.
24
+ */
25
+ export const NESTED_STORES_DIR = "spectest-nested";
26
+ /** The guest's static `mkfs.erofs`, which a nested runtime's EROFS
27
+ * differ needs and no runtime image ships. */
28
+ export const MKFS_EROFS_PATH = "/usr/local/bin/mkfs.erofs";
29
+ /** The guest's adopt helper (`base.rs::STORE_ADOPT_SH`), POSIX sh so a
30
+ * nested runtime's busybox can run the same file. */
31
+ export const STORE_ADOPT_PATH = "/usr/local/bin/spectest-store-adopt";
32
+ function parse(raw) {
33
+ const parsed = JSON.parse(raw);
34
+ const root = (parsed.disks ?? []).find((d) => d.role === "root" && d.path)?.path;
35
+ const layers = (parsed.disks ?? []).find((d) => d.role === "layers" && d.path)?.path;
36
+ return root && layers ? { root, layers } : null;
37
+ }
38
+ /** The cache's paths, or `null` when this VM carries none.
39
+ * `SPECTEST_IMAGE_CACHE_MANIFEST` points a test at another file. */
40
+ export function imageCachePathsSync(manifest = process.env.SPECTEST_IMAGE_CACHE_MANIFEST || IMAGE_CACHE_MANIFEST) {
41
+ try {
42
+ if (!existsSync(manifest))
43
+ return null;
44
+ return parse(readFileSync(manifest, "utf8"));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ /** The host directory a nested runtime keeps its containerd root in. */
51
+ export function nestedStoreDir(paths, service) {
52
+ return `${paths.root}/${NESTED_STORES_DIR}/${service}`;
53
+ }
54
+ /**
55
+ * Is a volume's host path on a cache disk? Such a directory is a cache
56
+ * the lineage keeps — like the container store one level up — and the
57
+ * delta-restore teardown must not wipe it.
58
+ */
59
+ export function isOnImageCache(hostPath, paths) {
60
+ if (!paths)
61
+ return false;
62
+ return hostPath === paths.root || hostPath.startsWith(`${paths.root}/`) || hostPath === paths.layers || hostPath.startsWith(`${paths.layers}/`);
63
+ }
@@ -49,8 +49,9 @@ 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
- * {@link NESTED_STORE_ROOT}, where two of them sharing one directory would
53
- * be two daemons on one metadata store — writes the token into the path.
52
+ * the image cache's `spectest-nested/<service>` (`image-cache.ts::nestedStoreDir`),
53
+ * where two of them sharing one directory would be two daemons on one
54
+ * metadata store — writes the token into the path.
54
55
  *
55
56
  * `workspace` is a parameter rather than a module constant so the rule is
56
57
  * testable without touching the filesystem.
@@ -45,8 +45,9 @@ 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
- * {@link NESTED_STORE_ROOT}, where two of them sharing one directory would
49
- * be two daemons on one metadata store — writes the token into the path.
48
+ * the image cache's `spectest-nested/<service>` (`image-cache.ts::nestedStoreDir`),
49
+ * where two of them sharing one directory would be two daemons on one
50
+ * metadata store — writes the token into the path.
50
51
  *
51
52
  * `workspace` is a parameter rather than a module constant so the rule is
52
53
  * testable without touching the filesystem.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.71.1",
3
+ "version": "0.73.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",