@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/dist/components/k3s.d.ts +3 -0
- package/dist/components/k3s.js +156 -60
- package/dist/daemon.js +228 -67
- package/dist/harness/volume-paths.d.ts +13 -17
- package/dist/harness/volume-paths.js +22 -24
- package/dist/index.d.ts +0 -9
- package/package.json +1 -1
- package/src/components/k3s.ts +160 -66
- package/src/daemon.ts +226 -70
- package/src/harness/volume-paths.test.ts +0 -33
- package/src/harness/volume-paths.ts +22 -26
- package/src/index.ts +0 -9
package/dist/daemon.js
CHANGED
|
@@ -40,6 +40,7 @@ import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta
|
|
|
40
40
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
41
41
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
42
42
|
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
43
|
+
import { cpus } from "node:os";
|
|
43
44
|
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
44
45
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
|
|
45
46
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
@@ -67,29 +68,51 @@ function namedServices(cfg) {
|
|
|
67
68
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
68
69
|
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
69
70
|
// Stable hostname every service container resolves to the host (the
|
|
70
|
-
// `spectest-br0` gateway) — so
|
|
71
|
-
// can point
|
|
72
|
-
//
|
|
73
|
-
//
|
|
71
|
+
// `spectest-br0` gateway) — so an app that runs its OWN BuildKit inside a
|
|
72
|
+
// test can point its cache export at the host's build-cache registry,
|
|
73
|
+
// `spectest-host:5007`, without hard-coding the gateway IP. Nothing else
|
|
74
|
+
// lives behind it any more: images are pulled and built inside the VM
|
|
75
|
+
// against the container store (CONTAINER_STORE.md). Injected into each
|
|
76
|
+
// container's /etc/hosts in runContainer.
|
|
74
77
|
const SPECTEST_HOST_NAME = "spectest-host";
|
|
75
|
-
// The host
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
78
|
+
// The host gateway, read once from the guest's own default route
|
|
79
|
+
// (/proc/net/route: destination 0, gateway as a little-endian hex word).
|
|
80
|
+
// `null` when the guest has no default route, in which case nothing is
|
|
81
|
+
// injected.
|
|
79
82
|
let _hostCacheGateway;
|
|
80
83
|
function hostCacheGateway() {
|
|
81
84
|
if (_hostCacheGateway !== undefined)
|
|
82
85
|
return _hostCacheGateway;
|
|
86
|
+
_hostCacheGateway = null;
|
|
83
87
|
try {
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
88
|
+
for (const line of readFileSync("/proc/net/route", "utf8").split("\n").slice(1)) {
|
|
89
|
+
const f = line.trim().split(/\s+/);
|
|
90
|
+
if (f.length < 3 || f[1] !== "00000000")
|
|
91
|
+
continue;
|
|
92
|
+
const hex = f[2];
|
|
93
|
+
const octets = [6, 4, 2, 0].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
94
|
+
if (octets.every((o) => Number.isFinite(o))) {
|
|
95
|
+
_hostCacheGateway = octets.join(".");
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
87
99
|
}
|
|
88
100
|
catch {
|
|
89
101
|
_hostCacheGateway = null;
|
|
90
102
|
}
|
|
91
103
|
return _hostCacheGateway;
|
|
92
104
|
}
|
|
105
|
+
/** vCPUs this guest has, for BuildKit's `max-parallelism` — the number
|
|
106
|
+
* Blacksmith sets too, and the one that matters now that the build
|
|
107
|
+
* competes for the guest's own cores rather than the host's. */
|
|
108
|
+
function cpuCount() {
|
|
109
|
+
try {
|
|
110
|
+
return Math.max(1, cpus().length);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return 4;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
93
116
|
// WORKSPACE (/workspace) and APP_DIR (/opt/spectest/app) both live in
|
|
94
117
|
// project-files.ts, next to the rule that decides which copy of a project
|
|
95
118
|
// file is the current one.
|
|
@@ -419,42 +442,108 @@ async function hasBuildx() {
|
|
|
419
442
|
}
|
|
420
443
|
return _buildxAvailable;
|
|
421
444
|
}
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
445
|
+
// Every VM carries its project's container store (CONTAINER_STORE.md):
|
|
446
|
+
// the disk every pull and every build lands on, mounted by the control
|
|
447
|
+
// plane before this harness starts. The manifest below says where; the
|
|
448
|
+
// in-VM buildkitd keeps its exported cache there too, so a fresh VM finds
|
|
449
|
+
// every layer it built before. Detected once; if the daemon will not
|
|
450
|
+
// start, dockerd's own BuildKit builds instead.
|
|
451
|
+
const IMAGE_CACHE_MANIFEST = "/run/spectest-image-cache.json";
|
|
452
|
+
const LOCAL_BUILDER_NAME = "spectest-local";
|
|
453
|
+
const LOCAL_BUILDKIT_ADDR = "tcp://127.0.0.1:1234";
|
|
454
|
+
/** The bring-up script the cache base bakes (`base.rs::BUILDKITD_UP_SH`). */
|
|
455
|
+
const BUILDKITD_UP_PATH = "/usr/local/bin/spectest-buildkitd-up";
|
|
456
|
+
/** Where the control plane mounted this VM's image cache: containerd's
|
|
457
|
+
* root (read-write, this VM's own) and the layers disk (read-only, shared
|
|
458
|
+
* by every VM of a generation). `null` when the VM carries no cache. */
|
|
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
|
+
}
|
|
470
|
+
}
|
|
471
|
+
let _localBuilder;
|
|
472
|
+
/**
|
|
473
|
+
* Start buildkitd inside this VM with its state on the cache disk, and
|
|
474
|
+
* register it as a buildx `remote` builder.
|
|
475
|
+
*
|
|
476
|
+
* Lazy on purpose: it runs on the first build a project actually does,
|
|
477
|
+
* so a project with no dockerfile service never pays for a builder. And
|
|
478
|
+
* best-effort: a daemon that will not start falls back to the shared host
|
|
479
|
+
* one, which is a slower build and not a failed one.
|
|
480
|
+
*/
|
|
481
|
+
async function ensureLocalBuildkitd() {
|
|
482
|
+
if (_localBuilder !== undefined)
|
|
483
|
+
return _localBuilder;
|
|
484
|
+
_localBuilder = false;
|
|
485
|
+
const paths = await imageCachePaths();
|
|
486
|
+
if (!paths)
|
|
487
|
+
return false;
|
|
488
|
+
const state = paths.root;
|
|
489
|
+
if (!(await hasBuildx()))
|
|
490
|
+
return false;
|
|
491
|
+
// The bring-up itself is a script baked into the image cache's base
|
|
492
|
+
// snapshot (`base.rs::BUILDKITD_UP_SH`), so production and the real-VM
|
|
493
|
+
// test drive exactly the same daemon with exactly the same config. All
|
|
494
|
+
// it takes from us is where the state lives — the registry routing is
|
|
495
|
+
// its own (Docker Hub via `mirror.gcr.io`, deliberately not the host
|
|
496
|
+
// zot instance).
|
|
497
|
+
const started = await shx("/bin/bash", [BUILDKITD_UP_PATH, state], 900_000);
|
|
498
|
+
if (started.code !== 0) {
|
|
499
|
+
// eslint-disable-next-line no-console
|
|
500
|
+
console.warn(`[build] in-VM buildkitd would not start; building with dockerd's own BuildKit:\n${(started.stderr || started.stdout).trim()}`);
|
|
440
501
|
return false;
|
|
441
502
|
}
|
|
442
|
-
|
|
443
|
-
// instance"), which we treat as already-present.
|
|
444
|
-
const create = await docker(["buildx", "create", "--name", REMOTE_BUILDER_NAME, "--driver", "remote", REMOTE_BUILDER_ADDR], 30_000);
|
|
503
|
+
const create = await docker(["buildx", "create", "--name", LOCAL_BUILDER_NAME, "--driver", "remote", LOCAL_BUILDKIT_ADDR], 30_000);
|
|
445
504
|
if (create.code !== 0 && !/existing instance|already exists/i.test(create.stderr)) {
|
|
446
|
-
|
|
505
|
+
// eslint-disable-next-line no-console
|
|
506
|
+
console.warn(`[build] could not register the in-VM builder:\n${create.stderr.trim()}`);
|
|
447
507
|
return false;
|
|
448
508
|
}
|
|
449
|
-
// `inspect --bootstrap`
|
|
450
|
-
//
|
|
451
|
-
const boot = await docker(["buildx", "inspect", "--bootstrap",
|
|
452
|
-
|
|
453
|
-
if (!_remoteBuilder) {
|
|
509
|
+
// `inspect --bootstrap` dials the daemon, so it is both the readiness
|
|
510
|
+
// wait and the proof it is really answering.
|
|
511
|
+
const boot = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 120_000);
|
|
512
|
+
if (boot.code !== 0) {
|
|
454
513
|
// eslint-disable-next-line no-console
|
|
455
|
-
console.warn(`[build]
|
|
514
|
+
console.warn(`[build] in-VM buildkitd never answered; building with dockerd's own BuildKit:\n${boot.stderr.trim()}`);
|
|
515
|
+
return false;
|
|
456
516
|
}
|
|
457
|
-
|
|
517
|
+
// eslint-disable-next-line no-console
|
|
518
|
+
console.log(`[build] building in this VM against the image cache (root ${paths.root}, layers ${paths.layers})`);
|
|
519
|
+
// Imports come from the merged cache on the read-only layers disk and
|
|
520
|
+
// from this lineage's own exports on the root; exports go to the root,
|
|
521
|
+
// where the merge picks them up (image_cache/merge.rs).
|
|
522
|
+
_localCacheDir = `${state}/spectest-buildkit-cache`;
|
|
523
|
+
_localCacheImports = [`${paths.layers}/spectest-buildkit-cache`, `${state}/spectest-buildkit-cache`];
|
|
524
|
+
_localBuilder = true;
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
/** The exported-cache directory on the cache disk, once the in-VM builder is up. */
|
|
528
|
+
let _localCacheDir = null;
|
|
529
|
+
/** The cache directories a build imports from: the merged one on the
|
|
530
|
+
* layers disk, then this lineage's own exports. */
|
|
531
|
+
let _localCacheImports = [];
|
|
532
|
+
/**
|
|
533
|
+
* A reference as containerd names it. buildx's `-t` on a remote builder
|
|
534
|
+
* stores an unqualified name (`probe:bx`) that dockerd then cannot
|
|
535
|
+
* resolve (measured, CONTAINER_STORE.md), so the in-VM build names its
|
|
536
|
+
* output in full.
|
|
537
|
+
*/
|
|
538
|
+
function qualifyImageRef(ref) {
|
|
539
|
+
const slash = ref.indexOf("/");
|
|
540
|
+
const first = slash < 0 ? "" : ref.slice(0, slash);
|
|
541
|
+
const isRegistry = first.includes(".") || first.includes(":") || first === "localhost";
|
|
542
|
+
if (slash < 0)
|
|
543
|
+
return `docker.io/library/${ref}`;
|
|
544
|
+
if (!isRegistry)
|
|
545
|
+
return `docker.io/${ref}`;
|
|
546
|
+
return ref;
|
|
458
547
|
}
|
|
459
548
|
async function ensureNetwork() {
|
|
460
549
|
const inspect = await docker(["network", "inspect", NETWORK_NAME], 30_000);
|
|
@@ -509,7 +598,12 @@ async function ensureVolumes(svc) {
|
|
|
509
598
|
// created here nor recorded for the delta-restore wipe.
|
|
510
599
|
if (!(await isExistingNonDirectory(host))) {
|
|
511
600
|
await fs.mkdir(host, { recursive: true });
|
|
512
|
-
|
|
601
|
+
// …and neither is the pre-disks cache tree, kept for a server still
|
|
602
|
+
// serving older SDKs. Leaving it out of the manifest is what
|
|
603
|
+
// protects a project running this SDK against a server whose
|
|
604
|
+
// teardown guard predates it.
|
|
605
|
+
const durable = host.startsWith("/var/cache/spectest/");
|
|
606
|
+
if (vol.source?.startsWith("/") && !durable) {
|
|
513
607
|
await recordAbsoluteVolumeDir(host);
|
|
514
608
|
}
|
|
515
609
|
}
|
|
@@ -1003,24 +1097,53 @@ async function runServiceBuild(name, image, tag, caSuffix) {
|
|
|
1003
1097
|
// predates per-Dockerfile ignores — and is written ONLY when the
|
|
1004
1098
|
// project ships none of its own (see readProjectDockerignore).
|
|
1005
1099
|
await fs.writeFile(`${dfPath}.dockerignore`, serviceDockerignore(image.exclude));
|
|
1006
|
-
|
|
1007
|
-
//
|
|
1008
|
-
//
|
|
1009
|
-
//
|
|
1010
|
-
|
|
1100
|
+
// The in-VM buildkitd on the container store first: the build runs
|
|
1101
|
+
// inside the guest's own isolation boundary, against this project's
|
|
1102
|
+
// own layer cache, and the finished image is already in the store
|
|
1103
|
+
// dockerd reads. dockerd's built-in BuildKit is the fallback (a guest
|
|
1104
|
+
// with no store, or a daemon that would not start).
|
|
1105
|
+
const useLocal = await ensureLocalBuildkitd();
|
|
1106
|
+
// Both the in-VM daemon and dockerd's buildx are BuildKit, so both
|
|
1107
|
+
// emit per-step timing on stderr under `--progress=plain` (parsed
|
|
1108
|
+
// below). Only the legacy builder takes no progress flag.
|
|
1109
|
+
const useBuildKit = useLocal || (await hasBuildx());
|
|
1011
1110
|
const buildEnv = {};
|
|
1012
1111
|
let buildArgs;
|
|
1013
1112
|
// The user's `buildArgs`, as `--build-arg` flags; a plain client flag,
|
|
1014
1113
|
// so every builder — host buildkitd, in-VM BuildKit, legacy — takes it.
|
|
1015
1114
|
const argFlags = buildArgFlags(image.buildArgs);
|
|
1016
|
-
if (
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
// .
|
|
1115
|
+
if (useLocal && _localCacheDir) {
|
|
1116
|
+
// The in-VM builder is BuildKit's containerd worker on this VM's
|
|
1117
|
+
// own image store (CONTAINER_STORE.md): the output is an image
|
|
1118
|
+
// record in dockerd's namespace, unpacked, so there is no `--load`
|
|
1119
|
+
// and nothing crosses a socket. The cache directory on the image cache
|
|
1120
|
+
// disk is what outlives the VM; `mode=max` keeps every
|
|
1121
|
+
// intermediate layer, uncompressed so an import never inflates,
|
|
1122
|
+
// and one tag per service so exports do not replace each other.
|
|
1123
|
+
const cacheTag = name.replace(/[^a-z0-9-]/gi, "-").toLowerCase();
|
|
1124
|
+
// LANDMINE: the local cache importer reads the `latest` entry of
|
|
1125
|
+
// the directory's index unless told otherwise, and the exporter
|
|
1126
|
+
// below writes this service's entry under `tag=<service>`. An
|
|
1127
|
+
// import without the same tag misses every time and every
|
|
1128
|
+
// `RUN` re-executes on a seeded cache (seen on the first deploy,
|
|
1129
|
+
// 2026-09-05: the disk carried the blobs, the build used none).
|
|
1130
|
+
buildArgs = [
|
|
1131
|
+
"buildx", "build",
|
|
1132
|
+
"--builder", LOCAL_BUILDER_NAME,
|
|
1133
|
+
"--progress=plain",
|
|
1134
|
+
"--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
|
|
1135
|
+
..._localCacheImports.flatMap((src) => ["--cache-from", `type=local,src=${src},tag=${cacheTag}`]),
|
|
1136
|
+
"--cache-to", `type=local,dest=${_localCacheDir},mode=max,compression=uncompressed,force-compression=true,tag=${cacheTag}`,
|
|
1137
|
+
...argFlags,
|
|
1138
|
+
"-f", dfPath, WORKSPACE,
|
|
1139
|
+
];
|
|
1140
|
+
}
|
|
1141
|
+
else if (useLocal) {
|
|
1142
|
+
// The daemon came up but reported no cache directory: build on it
|
|
1143
|
+
// and `--load` the result into dockerd.
|
|
1021
1144
|
buildArgs = [
|
|
1022
1145
|
"buildx", "build",
|
|
1023
|
-
"--builder",
|
|
1146
|
+
"--builder", LOCAL_BUILDER_NAME,
|
|
1024
1147
|
"--load",
|
|
1025
1148
|
"--progress=plain",
|
|
1026
1149
|
...argFlags,
|
|
@@ -1287,6 +1410,43 @@ async function waitForReady(svc) {
|
|
|
1287
1410
|
msg += output ? `\nRecent container logs:\n${output}` : `\n(the container logged nothing)`;
|
|
1288
1411
|
throw new Error(msg);
|
|
1289
1412
|
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Add the container's own account of its death to an error raised by its
|
|
1415
|
+
* `setup` hook.
|
|
1416
|
+
*
|
|
1417
|
+
* `waitForReady` already does this, because a container that never becomes
|
|
1418
|
+
* ready is obviously the container's fault. A `setup` hook is the case that
|
|
1419
|
+
* was missing, and it is the one that reads most misleadingly: the hook
|
|
1420
|
+
* talks to the service over the network, so when the container dies
|
|
1421
|
+
* mid-hook what surfaces is a name that no longer resolves or a rollout
|
|
1422
|
+
* that never finished — a symptom from the far end of a connection to
|
|
1423
|
+
* something that is not there any more. The reason is in a log that goes
|
|
1424
|
+
* with the VM at teardown.
|
|
1425
|
+
*
|
|
1426
|
+
* Only for a container that is **gone**: one that is still up did not cause
|
|
1427
|
+
* this, and its log would bury the real error. Best-effort throughout — a
|
|
1428
|
+
* diagnostic must never replace the failure it explains.
|
|
1429
|
+
*/
|
|
1430
|
+
async function withContainerPostMortem(name, err) {
|
|
1431
|
+
try {
|
|
1432
|
+
const state = await docker(["inspect", "-f", "{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}", name], 15_000);
|
|
1433
|
+
const [running, code, oom] = state.stdout.trim().split(/\s+/);
|
|
1434
|
+
if (state.code !== 0 || running !== "false")
|
|
1435
|
+
return err;
|
|
1436
|
+
const logs = await docker(["logs", "--tail=120", name], 30_000);
|
|
1437
|
+
const output = `${logs.stdout}\n${logs.stderr}`.trim();
|
|
1438
|
+
const base = err instanceof Error ? err : new Error(String(err));
|
|
1439
|
+
base.message +=
|
|
1440
|
+
`\n\nThe "${name}" container exited (code ${code}` +
|
|
1441
|
+
`${oom === "true" ? ", OOM-killed" : ""}) while its setup hook was running, ` +
|
|
1442
|
+
`which is why the hook could not reach it.` +
|
|
1443
|
+
(output ? `\nIts last output:\n${output}` : `\nIt logged nothing.`);
|
|
1444
|
+
return base;
|
|
1445
|
+
}
|
|
1446
|
+
catch {
|
|
1447
|
+
return err;
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1290
1450
|
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1291
1451
|
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
1292
1452
|
function validateServiceGraph(services) {
|
|
@@ -2886,18 +3046,14 @@ async function bootstrapInner() {
|
|
|
2886
3046
|
// at "image ready" waiting for an unrelated slow build elsewhere.
|
|
2887
3047
|
//
|
|
2888
3048
|
// Prep concurrency: registry pulls always run in parallel (network-bound,
|
|
2889
|
-
// low VM RAM). Dockerfile builds
|
|
2890
|
-
//
|
|
2891
|
-
//
|
|
2892
|
-
//
|
|
2893
|
-
//
|
|
2894
|
-
// fans out to ~16 fetchers + lifecycle workers, ~70 MB/process), so we
|
|
2895
|
-
// serialize that case behind a FIFO chain — but only the in-VM builds
|
|
2896
|
-
// serialize; pulls and starts run freely alongside them. The remote-builder
|
|
2897
|
-
// probe is memoized, so this up-front call is free; skip it with no builds.
|
|
3049
|
+
// low VM RAM). Dockerfile builds run inside the VM, and two or more
|
|
3050
|
+
// concurrent builds routinely OOM a single VM on monorepos with parallel
|
|
3051
|
+
// pnpm/npm installs (each install fans out to ~16 fetchers + lifecycle
|
|
3052
|
+
// workers, ~70 MB/process), so builds serialize behind a FIFO chain —
|
|
3053
|
+
// but only the builds; pulls and starts run freely alongside them.
|
|
2898
3054
|
const tags = new Map();
|
|
2899
3055
|
const builds = services.filter((s) => s.image.type === "dockerfile");
|
|
2900
|
-
const buildsRunHostSide =
|
|
3056
|
+
const buildsRunHostSide = false;
|
|
2901
3057
|
// A promise chain is a fair FIFO mutex: when builds run in-VM, each build
|
|
2902
3058
|
// waits for the previous to settle. Pulls and host-side builds bypass it.
|
|
2903
3059
|
let inVmBuildChain = Promise.resolve();
|
|
@@ -2978,11 +3134,16 @@ async function bootstrapInner() {
|
|
|
2978
3134
|
if (svc.setup) {
|
|
2979
3135
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
2980
3136
|
const helpers = await ensureHelpers(svc.name, svc);
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
3137
|
+
try {
|
|
3138
|
+
await svc.setup({
|
|
3139
|
+
name: svc.name,
|
|
3140
|
+
helpers,
|
|
3141
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3142
|
+
});
|
|
3143
|
+
}
|
|
3144
|
+
catch (err) {
|
|
3145
|
+
throw await withContainerPostMortem(svc.name, err);
|
|
3146
|
+
}
|
|
2986
3147
|
}
|
|
2987
3148
|
progressService(svc.name, { status: "ready", detail: undefined });
|
|
2988
3149
|
const ti = timings.get(svc.name);
|
|
@@ -6,22 +6,12 @@
|
|
|
6
6
|
* delta-restore teardown**.
|
|
7
7
|
*
|
|
8
8
|
* Teardown wipes `/workspace` to give a restored environment fresh-state
|
|
9
|
-
* semantics.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
/** Root of the per-environment state tree. Wiped by delta teardown. */
|
|
22
14
|
export declare const DEFAULT_WORKSPACE = "/workspace";
|
|
23
|
-
/** Root of the cache tree. Deliberately outside the workspace. */
|
|
24
|
-
export declare const CACHE_ROOT = "/var/cache/spectest/volumes";
|
|
25
15
|
/** Directory holding named shared volumes, under whichever root applies. */
|
|
26
16
|
export declare const SHARED_DIR = "_shared";
|
|
27
17
|
export interface VolumeSpec {
|
|
@@ -34,8 +24,6 @@ export interface VolumeSpec {
|
|
|
34
24
|
/** Path inside the container. Used to derive a directory when neither
|
|
35
25
|
* `name` nor `source` is given. */
|
|
36
26
|
target: string;
|
|
37
|
-
/** Survive the delta-restore teardown. Content-addressed data only. */
|
|
38
|
-
cache?: boolean;
|
|
39
27
|
}
|
|
40
28
|
/**
|
|
41
29
|
* Make an arbitrary string safe as a single path segment.
|
|
@@ -57,6 +45,13 @@ export declare function sanitizeSegment(p: string): string;
|
|
|
57
45
|
* 3. A relative `source`, or nothing at all — under the service's own
|
|
58
46
|
* directory, derived from `target` when `source` is absent.
|
|
59
47
|
*
|
|
48
|
+
* `source` honours the `{{SPECTEST_SERVICE}}` token, for the same reason
|
|
49
|
+
* `files` does: a component cannot know the map key the user will give it,
|
|
50
|
+
* and an **absolute** source gets no automatic per-service directory. A
|
|
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.
|
|
54
|
+
*
|
|
60
55
|
* `workspace` is a parameter rather than a module constant so the rule is
|
|
61
56
|
* testable without touching the filesystem.
|
|
62
57
|
*/
|
|
@@ -64,7 +59,8 @@ export declare function resolveHostPath(service: string, vol: VolumeSpec, worksp
|
|
|
64
59
|
/**
|
|
65
60
|
* Does this volume survive a delta-restore teardown?
|
|
66
61
|
*
|
|
67
|
-
* True for cache
|
|
68
|
-
* workspace — the two ways a directory ends up beyond
|
|
62
|
+
* True for a volume on a mounted cache disk, and for an absolute source
|
|
63
|
+
* outside the workspace — the two ways a directory ends up beyond
|
|
64
|
+
* `rm -rf /workspace`.
|
|
69
65
|
*/
|
|
70
66
|
export declare function survivesTeardown(vol: VolumeSpec, service: string, workspace?: string): boolean;
|
|
@@ -6,23 +6,14 @@
|
|
|
6
6
|
* delta-restore teardown**.
|
|
7
7
|
*
|
|
8
8
|
* Teardown wipes `/workspace` to give a restored environment fresh-state
|
|
9
|
-
* semantics.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
import path from "node:path";
|
|
14
|
+
import { expandServiceToken } from "./file-mounts";
|
|
22
15
|
/** Root of the per-environment state tree. Wiped by delta teardown. */
|
|
23
16
|
export const DEFAULT_WORKSPACE = "/workspace";
|
|
24
|
-
/** Root of the cache tree. Deliberately outside the workspace. */
|
|
25
|
-
export const CACHE_ROOT = "/var/cache/spectest/volumes";
|
|
26
17
|
/** Directory holding named shared volumes, under whichever root applies. */
|
|
27
18
|
export const SHARED_DIR = "_shared";
|
|
28
19
|
/**
|
|
@@ -50,30 +41,37 @@ export function sanitizeSegment(p) {
|
|
|
50
41
|
* 3. A relative `source`, or nothing at all — under the service's own
|
|
51
42
|
* directory, derived from `target` when `source` is absent.
|
|
52
43
|
*
|
|
44
|
+
* `source` honours the `{{SPECTEST_SERVICE}}` token, for the same reason
|
|
45
|
+
* `files` does: a component cannot know the map key the user will give it,
|
|
46
|
+
* and an **absolute** source gets no automatic per-service directory. A
|
|
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.
|
|
50
|
+
*
|
|
53
51
|
* `workspace` is a parameter rather than a module constant so the rule is
|
|
54
52
|
* testable without touching the filesystem.
|
|
55
53
|
*/
|
|
56
54
|
export function resolveHostPath(service, vol, workspace = DEFAULT_WORKSPACE) {
|
|
57
55
|
const stateRoot = [workspace, ".spectest", "volumes"];
|
|
56
|
+
const source = vol.source === undefined ? undefined : expandServiceToken(vol.source, service);
|
|
58
57
|
if (vol.name) {
|
|
59
|
-
|
|
60
|
-
return path.join(...root, sanitizeSegment(vol.name));
|
|
58
|
+
return path.join(...stateRoot, SHARED_DIR, sanitizeSegment(vol.name));
|
|
61
59
|
}
|
|
62
|
-
// An absolute source is the project's own path
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return path.join(...root, vol.source.replace(/^\/+/, ""));
|
|
60
|
+
// An absolute source is the project's own path, used as is.
|
|
61
|
+
if (source && source.startsWith("/"))
|
|
62
|
+
return source;
|
|
63
|
+
const root = [...stateRoot, service];
|
|
64
|
+
if (source) {
|
|
65
|
+
return path.join(...root, source.replace(/^\/+/, ""));
|
|
69
66
|
}
|
|
70
67
|
return path.join(...root, sanitizeSegment(vol.target));
|
|
71
68
|
}
|
|
72
69
|
/**
|
|
73
70
|
* Does this volume survive a delta-restore teardown?
|
|
74
71
|
*
|
|
75
|
-
* True for cache
|
|
76
|
-
* workspace — the two ways a directory ends up beyond
|
|
72
|
+
* True for a volume on a mounted cache disk, and for an absolute source
|
|
73
|
+
* outside the workspace — the two ways a directory ends up beyond
|
|
74
|
+
* `rm -rf /workspace`.
|
|
77
75
|
*/
|
|
78
76
|
export function survivesTeardown(vol, service, workspace = DEFAULT_WORKSPACE) {
|
|
79
77
|
const host = resolveHostPath(service, vol, workspace);
|
package/dist/index.d.ts
CHANGED
|
@@ -815,15 +815,6 @@ export interface VolumeMount {
|
|
|
815
815
|
/** Container path. */
|
|
816
816
|
target: string;
|
|
817
817
|
readOnly?: boolean;
|
|
818
|
-
/**
|
|
819
|
-
* Cache volume: the backing dir lives outside the per-env state tree and
|
|
820
|
-
* survives a delta-restore teardown (which recreates every container,
|
|
821
|
-
* volume, and the daemon for fresh-state semantics). Reserve this for
|
|
822
|
-
* content-addressed data whose presence is purely an accelerator — an
|
|
823
|
-
* image/layer store, a package cache — never for app state: anything in
|
|
824
|
-
* a cache volume is visible to the "fresh" environment.
|
|
825
|
-
*/
|
|
826
|
-
cache?: boolean;
|
|
827
818
|
}
|
|
828
819
|
export interface FileMount {
|
|
829
820
|
/** Absolute path inside the container where the file is mounted. */
|