@specific.dev/spectest 0.90.2 → 0.91.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.
Files changed (45) hide show
  1. package/dist/browser-coverage.d.ts +5 -5
  2. package/dist/browser-coverage.js +11 -14
  3. package/dist/components/expo.d.ts +2 -0
  4. package/dist/components/index.d.ts +1 -1
  5. package/dist/components/k3s-trust.d.ts +57 -0
  6. package/dist/components/k3s-trust.js +125 -0
  7. package/dist/components/k3s.d.ts +17 -5
  8. package/dist/components/k3s.js +77 -11
  9. package/dist/coverage.d.ts +1 -1
  10. package/dist/coverage.js +3 -3
  11. package/dist/daemon.js +53 -14
  12. package/dist/harness/component-images.d.ts +31 -0
  13. package/dist/harness/component-images.js +69 -0
  14. package/dist/harness/k3s-image-loader.d.ts +5 -0
  15. package/dist/harness/k3s-image-loader.js +150 -0
  16. package/dist/harness/k3s-prepull.d.ts +7 -0
  17. package/dist/harness/k3s-prepull.js +39 -0
  18. package/dist/harness/sticky-consumer.d.ts +3 -3
  19. package/dist/harness/sticky-consumer.js +19 -4
  20. package/dist/harness/sticky-kubernetes.d.ts +15 -0
  21. package/dist/harness/sticky-kubernetes.js +163 -0
  22. package/dist/harness/sticky-paths.d.ts +5 -0
  23. package/dist/harness/sticky-paths.js +18 -0
  24. package/dist/index.d.ts +32 -2
  25. package/dist/index.js +25 -2
  26. package/package.json +1 -1
  27. package/src/browser-coverage.ts +11 -13
  28. package/src/components/index.ts +1 -0
  29. package/src/components/k3s-trust.test.ts +81 -0
  30. package/src/components/k3s-trust.ts +135 -0
  31. package/src/components/k3s.ts +81 -10
  32. package/src/coverage.test.ts +50 -0
  33. package/src/coverage.ts +3 -3
  34. package/src/daemon.ts +56 -15
  35. package/src/harness/component-images.test.ts +44 -0
  36. package/src/harness/component-images.ts +73 -0
  37. package/src/harness/k3s-image-loader.ts +129 -0
  38. package/src/harness/k3s-prepull.test.ts +47 -0
  39. package/src/harness/k3s-prepull.ts +36 -0
  40. package/src/harness/sticky-consumer.test.ts +28 -0
  41. package/src/harness/sticky-consumer.ts +14 -3
  42. package/src/harness/sticky-kubernetes.test.ts +88 -0
  43. package/src/harness/sticky-kubernetes.ts +165 -0
  44. package/src/harness/sticky-paths.ts +18 -0
  45. package/src/index.ts +48 -3
@@ -22,10 +22,10 @@ export declare function foldBrowserCoverage(service: string, files: FileCounts):
22
22
  /** Harvest every live session, so a capture sees the counters of code
23
23
  * that ran since the last browser op. */
24
24
  export declare function harvestAllBrowserCoverage(): Promise<void>;
25
- /** One lcov document per service that has any browser coverage — and
26
- * the totals are cleared, so the next capture holds only what runs
27
- * from now on. Module memory forks with the environment, so a forked
28
- * child starts from its parent's last capture, empty. */
29
- export declare function takeBrowserCoverageReports(): Map<string, string>;
25
+ /** Take one service's lcov report and clear only its totals. Other
26
+ * services retain their coverage until their own capture. Each capture
27
+ * is a delta; after every service is captured, a forked child inherits
28
+ * empty totals from its parent. */
29
+ export declare function takeBrowserCoverageReport(service: string): string;
30
30
  /** The current totals, without clearing them (tests). */
31
31
  export declare function browserCoverageReports(): Map<string, string>;
@@ -12,8 +12,8 @@
12
12
  //
13
13
  // Harvest runs before every browser step (a click that navigates
14
14
  // cross-site replaces the renderer, and the old counters with it) and at
15
- // every coverage capture. `browserCoverageReports` renders the totals as
16
- // one lcov per service for the `case-coverage` bundle.
15
+ // every coverage capture. `takeBrowserCoverageReport` renders and clears
16
+ // one service's totals for the `case-coverage` bundle.
17
17
  //
18
18
  // Configured by the daemon at /load with the host → service table; a
19
19
  // project with no `coverage: { browser: true }` service never attaches
@@ -196,18 +196,15 @@ function parseDataUrl(ref) {
196
196
  export async function harvestAllBrowserCoverage() {
197
197
  await Promise.all([...LIVE_COLLECTORS].map((c) => harvestBrowserCoverage(c.cdp)));
198
198
  }
199
- /** One lcov document per service that has any browser coverage — and
200
- * the totals are cleared, so the next capture holds only what runs
201
- * from now on. Module memory forks with the environment, so a forked
202
- * child starts from its parent's last capture, empty. */
203
- export function takeBrowserCoverageReports() {
204
- const out = new Map();
205
- for (const [service, files] of TOTALS) {
206
- if (files.size > 0)
207
- out.set(service, lcovDocument(files));
208
- }
209
- TOTALS.clear();
210
- return out;
199
+ /** Take one service's lcov report and clear only its totals. Other
200
+ * services retain their coverage until their own capture. Each capture
201
+ * is a delta; after every service is captured, a forked child inherits
202
+ * empty totals from its parent. */
203
+ export function takeBrowserCoverageReport(service) {
204
+ const files = TOTALS.get(service);
205
+ const report = files ? lcovDocument(files) : "TN:\n";
206
+ TOTALS.delete(service);
207
+ return report;
211
208
  }
212
209
  /** The current totals, without clearing them (tests). */
213
210
  export function browserCoverageReports() {
@@ -103,6 +103,7 @@ export declare function nodeMajorFromRange(range: string): number | null;
103
103
  export declare function expo(opts?: ExpoOptions): {
104
104
  image: {
105
105
  type: "dockerfile";
106
+ reference?: string;
106
107
  path: string;
107
108
  content?: never;
108
109
  context?: string;
@@ -111,6 +112,7 @@ export declare function expo(opts?: ExpoOptions): {
111
112
  buildArgs?: Readonly<Record<string, string>>;
112
113
  } | {
113
114
  type: "dockerfile";
115
+ reference?: string;
114
116
  content: string;
115
117
  path?: never;
116
118
  context?: string;
@@ -2,7 +2,7 @@ export { temporal, temporalClient, type TemporalOptions, type TemporalClient, ty
2
2
  export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
3
3
  export { s3, type S3Options, type S3Helpers, } from "./s3.js";
4
4
  export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
5
- export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
5
+ export { k3s, type K3sOptions, type KubernetesImage, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
6
6
  export { expo, type ExpoOptions, type ExpoHelpers, type ExpoPackageManager, } from "./expo.js";
7
7
  export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
8
8
  export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
@@ -0,0 +1,57 @@
1
+ export declare const POD_CA_PATH = "/spectest/ca-bundle.crt";
2
+ interface Container {
3
+ env?: {
4
+ name: string;
5
+ value?: string;
6
+ valueFrom?: unknown;
7
+ }[];
8
+ volumeMounts?: {
9
+ name: string;
10
+ mountPath: string;
11
+ subPath?: string;
12
+ readOnly?: boolean;
13
+ }[];
14
+ }
15
+ interface Pod {
16
+ metadata?: {
17
+ annotations?: Record<string, string>;
18
+ };
19
+ spec?: {
20
+ os?: {
21
+ name: string;
22
+ };
23
+ volumes?: {
24
+ name: string;
25
+ configMap?: {
26
+ name: string;
27
+ };
28
+ [key: string]: unknown;
29
+ }[];
30
+ containers?: Container[];
31
+ initContainers?: Container[];
32
+ ephemeralContainers?: Container[];
33
+ };
34
+ }
35
+ interface Patch {
36
+ op: "add";
37
+ path: string;
38
+ value: unknown;
39
+ }
40
+ export declare const TRUST_OPT_OUT = "spectest.dev/inject-ca";
41
+ export declare function podTrustPatch(pod: Pod, configMap: string, ephemeral?: boolean): Patch[];
42
+ export interface TrustDependencies {
43
+ /** Ensure this immutable ConfigMap exists in the requesting namespace. */
44
+ ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
45
+ apply(configuration: unknown): Promise<void>;
46
+ }
47
+ export declare function installPodTrust(options: {
48
+ name: string;
49
+ gateway: string;
50
+ bundle: string;
51
+ cert: string;
52
+ key: string;
53
+ ca: string;
54
+ }, deps: TrustDependencies): Promise<void>;
55
+ /** Release a component listener on replacement or in standalone tests. */
56
+ export declare function stopPodTrust(name: string): void;
57
+ export {};
@@ -0,0 +1,125 @@
1
+ /** Pod trust belongs to the cluster. Admission adds read-only ConfigMap
2
+ * mounts and environment defaults, without changing any image bytes. */
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { createServer } from "node:https";
5
+ export const POD_CA_PATH = "/spectest/ca-bundle.crt";
6
+ const TRUST_ENV = {
7
+ NODE_EXTRA_CA_CERTS: POD_CA_PATH, SSL_CERT_FILE: POD_CA_PATH,
8
+ REQUESTS_CA_BUNDLE: POD_CA_PATH, AWS_CA_BUNDLE: POD_CA_PATH,
9
+ PGSSLROOTCERT: POD_CA_PATH, DENO_TLS_CA_STORE: "mozilla,system",
10
+ };
11
+ const MOUNT_PATHS = [POD_CA_PATH, "/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt"];
12
+ export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
13
+ export function podTrustPatch(pod, configMap, ephemeral = false) {
14
+ if (!pod.spec || pod.spec.os?.name === "windows" || pod.metadata?.annotations?.[TRUST_OPT_OUT] === "false")
15
+ return [];
16
+ const patches = [];
17
+ const volumes = pod.spec.volumes ?? [];
18
+ // Reuse our immutable ConfigMap, but never overwrite a user's volume.
19
+ let volume = volumes.find(v => v.configMap?.name === configMap)?.name;
20
+ if (!volume && ephemeral)
21
+ return []; // Pod volumes cannot change on this subresource.
22
+ if (!volume) {
23
+ const names = new Set(volumes.map(v => v.name));
24
+ volume = "spectest-ca";
25
+ for (let n = 1; names.has(volume); n++)
26
+ volume = `spectest-ca-${n}`;
27
+ const value = { name: volume, configMap: { name: configMap, defaultMode: 0o444, items: [{ key: "ca-bundle.crt", path: "ca-bundle.crt" }] } };
28
+ patches.push(pod.spec.volumes
29
+ ? { op: "add", path: "/spec/volumes/-", value }
30
+ : { op: "add", path: "/spec/volumes", value: [value] });
31
+ }
32
+ const groups = ephemeral ? ["ephemeralContainers"] : ["containers", "initContainers"];
33
+ for (const group of groups) {
34
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
35
+ const prefix = `/spec/${group}/${index}`;
36
+ const env = container.env ?? [];
37
+ const names = new Set(env.map(e => e.name));
38
+ const extraEnv = Object.entries(TRUST_ENV).filter(([name]) => !names.has(name)).map(([name, value]) => ({ name, value }));
39
+ const mounts = container.volumeMounts ?? [];
40
+ // A user mount at a path (or a parent directory) owns that path.
41
+ const extraMounts = MOUNT_PATHS.filter(path => !mounts.some(m => path === m.mountPath || path.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
42
+ .map(mountPath => ({ name: volume, mountPath, subPath: "ca-bundle.crt", readOnly: true }));
43
+ if (extraEnv.length)
44
+ patches.push({ op: "add", path: `${prefix}/env`, value: [...env, ...extraEnv] });
45
+ if (extraMounts.length)
46
+ patches.push({ op: "add", path: `${prefix}/volumeMounts`, value: [...mounts, ...extraMounts] });
47
+ }
48
+ }
49
+ return patches;
50
+ }
51
+ const servers = new Map();
52
+ export async function installPodTrust(options, deps) {
53
+ const configMap = `spectest-ca-${createHash("sha256").update(options.bundle).digest("hex").slice(0, 20)}`;
54
+ const endpoint = `/admit/${randomUUID()}`;
55
+ const server = createServer({ cert: options.cert, key: options.key }, async (req, res) => {
56
+ if (req.method !== "POST" || new URL(req.url ?? "/", "https://local").pathname !== endpoint) {
57
+ res.writeHead(404).end();
58
+ return;
59
+ }
60
+ const chunks = [];
61
+ let size = 0;
62
+ try {
63
+ for await (const chunk of req) {
64
+ size += chunk.length;
65
+ if (size > 2 * 1024 * 1024) {
66
+ res.writeHead(413).end();
67
+ return;
68
+ }
69
+ chunks.push(Buffer.from(chunk));
70
+ }
71
+ const review = JSON.parse(Buffer.concat(chunks).toString());
72
+ const request = review.request;
73
+ if (!request?.uid || request.resource?.resource !== "pods" || !request.object || !request.namespace) {
74
+ res.writeHead(400).end("invalid Pod admission request");
75
+ return;
76
+ }
77
+ const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
78
+ // Dry runs return the same patch but perform no API writes.
79
+ if (patch.length && !request.dryRun)
80
+ await deps.ensureBundle(request.namespace, configMap, options.bundle);
81
+ res.setHeader("Content-Type", "application/json");
82
+ res.end(JSON.stringify({ apiVersion: "admission.k8s.io/v1", kind: "AdmissionReview", response: {
83
+ uid: request.uid, allowed: true,
84
+ ...(patch.length ? { patchType: "JSONPatch", patch: Buffer.from(JSON.stringify(patch)).toString("base64") } : {}),
85
+ } }));
86
+ }
87
+ catch (err) {
88
+ console.error(`[k3s-trust] ${options.name}: ${err instanceof Error ? err.message : err}`);
89
+ res.writeHead(500).end("could not mount the environment CA");
90
+ }
91
+ });
92
+ server.requestTimeout = 15_000;
93
+ try {
94
+ await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, options.gateway, resolve); });
95
+ const port = server.address().port;
96
+ await deps.apply({
97
+ apiVersion: "admissionregistration.k8s.io/v1", kind: "MutatingWebhookConfiguration",
98
+ metadata: { name: "spectest-pod-trust" },
99
+ webhooks: [{
100
+ name: "pod-trust.spectest.dev", admissionReviewVersions: ["v1"],
101
+ sideEffects: "NoneOnDryRun", failurePolicy: "Fail", timeoutSeconds: 10,
102
+ reinvocationPolicy: "IfNeeded", matchPolicy: "Equivalent",
103
+ clientConfig: { url: `https://${options.gateway}:${port}${endpoint}`, caBundle: Buffer.from(options.ca).toString("base64") },
104
+ rules: [{ operations: ["CREATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods"], scope: "Namespaced" },
105
+ { operations: ["UPDATE"], apiGroups: [""], apiVersions: ["v1"], resources: ["pods/ephemeralcontainers"], scope: "Namespaced" }],
106
+ }],
107
+ });
108
+ }
109
+ catch (err) {
110
+ server.closeAllConnections();
111
+ server.close();
112
+ throw err;
113
+ }
114
+ const previous = servers.get(options.name);
115
+ servers.set(options.name, server);
116
+ previous?.closeAllConnections();
117
+ previous?.close();
118
+ }
119
+ /** Release a component listener on replacement or in standalone tests. */
120
+ export function stopPodTrust(name) {
121
+ const server = servers.get(name);
122
+ servers.delete(name);
123
+ server?.closeAllConnections();
124
+ server?.close();
125
+ }
@@ -1,8 +1,22 @@
1
1
  import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, PatchStrategy, type KubernetesListObject, type KubernetesObject, type V1DeleteOptions, type V1Job, type V1Status } from "@kubernetes/client-node";
2
- import type { ServiceHelpersContext } from "../index.js";
2
+ import type { ServiceHelpersContext, ServiceSetupContext } from "../index.js";
3
+ import { type KubernetesImage } from "../harness/component-images.js";
4
+ export type { KubernetesImage } from "../harness/component-images.js";
3
5
  import type { Wrapped } from "../inspect.js";
4
6
  export type { KubernetesObject, KubernetesListObject, } from "@kubernetes/client-node";
7
+ /** Kubernetes pods receive read-only environment CA mounts during admission.
8
+ * Image bytes and digests stay unchanged; explicit pod trust settings win. */
5
9
  export interface K3sOptions {
10
+ /** Images available to kubelet before project setup. Use registry strings,
11
+ * or Dockerfile builds with a full `reference` matching your manifests. Builds
12
+ * support path/content, context, exclude, target and buildArgs, and run in
13
+ * parallel with service builds through the standard BuildKit pipeline.
14
+ * Setup waits for every pull and fails if a requested image cannot be pulled.
15
+ * Sticky-enabled projects retain image content and unpacked layers between
16
+ * fresh builds automatically. Digest references can reuse the cache offline;
17
+ * tags are resolved again on each fresh setup. Pod imagePullPolicy still applies.
18
+ */
19
+ images?: readonly KubernetesImage[];
6
20
  /**
7
21
  * Image tag for the official `rancher/k3s` image. Default
8
22
  * `"v1.33.5-k3s1"`. Any tag works; a k3s older than 1.33 embeds a
@@ -319,6 +333,7 @@ export declare function k3s(opts?: K3sOptions): {
319
333
  type: "registry";
320
334
  reference: string;
321
335
  };
336
+ imageBuilds: import("../index.js").ComponentImageBuild[];
322
337
  command: string;
323
338
  privileged: true;
324
339
  tmpfs: string[];
@@ -344,9 +359,6 @@ export declare function k3s(opts?: K3sOptions): {
344
359
  command: string;
345
360
  timeoutSecs: number;
346
361
  };
347
- setup: ({ name, helpers }: {
348
- name: string;
349
- helpers: K3sHelpers;
350
- }) => Promise<void>;
362
+ setup: (ctx: ServiceSetupContext<K3sHelpers>) => Promise<void>;
351
363
  helpers: ({ name, exec, poll }: ServiceHelpersContext) => Promise<K3sHelpers>;
352
364
  };
@@ -1,3 +1,6 @@
1
+ import { isIP } from "node:net";
2
+ import { installPodTrust } from "./k3s-trust.js";
3
+ import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
1
4
  import { AsyncLocalStorage } from "node:async_hooks";
2
5
  import { spawn as nodeSpawn } from "node:child_process";
3
6
  import { randomUUID } from "node:crypto";
@@ -7,6 +10,10 @@ import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, Patc
7
10
  import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
8
11
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
9
12
  import { MKFS_EROFS_PATH, STORE_ADOPT_PATH, imageCachePathsSync, nestedStoreDir, } from "../harness/image-cache.js";
13
+ import { stickyDiskPathSync } from "../harness/sticky-paths.js";
14
+ import { registerStickyKubernetesStore, stickyKubernetesDirectory } from "../harness/sticky-kubernetes.js";
15
+ import { prePullK3sImages } from "../harness/k3s-prepull.js";
16
+ import { componentRuntime, kubernetesImages } from "../harness/component-images.js";
10
17
  import { deepUnwrap, readTag, wrap } from "../inspect.js";
11
18
  import { recorderAnnotate, recorderRemove } from "../recorder.js";
12
19
  /** Port the in-cluster registry listens on (plain HTTP). */
@@ -32,8 +39,13 @@ const CRI_MANAGED_LABEL = "io.cri-containerd.image=managed";
32
39
  */
33
40
  const BOOT_MARKER = ".spectest-boot-incomplete";
34
41
  /**
35
- * The cluster's image store, and how it shares the environment's image
36
- * cache (`CONTAINER_STORE.md` §12).
42
+ * Sticky-enabled projects bind a per-service/version directory on the
43
+ * sticky disk at CLUSTER_STORE_TARGET and use overlayfs. Publication's
44
+ * sticky-kubernetes adapter removes runtime state and retains images and
45
+ * unpacked layers under the shared filesystem budget. Test forks do not
46
+ * write back. The preparation wrapper below also handles delta rebuilds.
47
+ *
48
+ * Legacy image-cache integration (`CONTAINER_STORE.md` §12):
37
49
  *
38
50
  * The cluster's embedded containerd keeps its root on the **root disk**
39
51
  * of the environment's image cache, in a directory of its own
@@ -195,8 +207,8 @@ function storePrepareSh(cache) {
195
207
  `timeout 60 ctr -a "$A" -n k8s.io containers rm $ids >/dev/null 2>&1 || true; fi; ` +
196
208
  // containerd 2.x keeps sandboxes in a store of their own; on the 1.7
197
209
  // k3s ships they are ordinary containers and this is a no-op.
198
- `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls -q 2>/dev/null); ` +
199
- `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm $sb >/dev/null 2>&1; return 0; }; ` +
210
+ `sb=$(timeout 30 ctr -a "$A" -n k8s.io sandboxes ls 2>/dev/null | awk 'NR > 1 {print $1}'); ` +
211
+ `[ -n "$sb" ] && timeout 60 ctr -a "$A" -n k8s.io sandboxes rm --force $sb >/dev/null 2>&1; return 0; }; ` +
200
212
  adopt +
201
213
  `if [ -e "$M" ]; then ` +
202
214
  `echo 'spectest: the previous cluster on this image store never became ready; starting from an empty one' >&2; ` +
@@ -287,7 +299,7 @@ async function issueIngressCert(hostnames) {
287
299
  const id = `spectest-k3s-ingress-${randomUUID().slice(0, 8)}`;
288
300
  const keyPath = `/tmp/${id}.key`;
289
301
  const crtPath = `/tmp/${id}.crt`;
290
- const sans = hostnames.map((h) => `DNS:${h}`).join(",");
302
+ const sans = hostnames.map((h) => `${isIP(h) ? "IP" : "DNS"}:${h}`).join(",");
291
303
  const r = await runProcess("openssl", [
292
304
  "req",
293
305
  "-newkey",
@@ -1393,6 +1405,7 @@ async function collectDiagnostics(client, namespace) {
1393
1405
  const DEFAULT_K3S_VERSION = "v1.33.5-k3s1";
1394
1406
  export function k3s(opts = {}) {
1395
1407
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1408
+ const images = kubernetesImages(opts.images ?? []);
1396
1409
  const extra = opts.extraArgs ?? [];
1397
1410
  assertUsableExtraArgs(extra);
1398
1411
  const registryEnabled = opts.registry !== false;
@@ -1409,7 +1422,8 @@ export function k3s(opts = {}) {
1409
1422
  // The environment's image cache, when this VM carries one: the
1410
1423
  // cluster's store goes on its root disk and its layers come off the
1411
1424
  // shared layers disk (see the store section above).
1412
- const cache = imageCachePathsSync();
1425
+ const sticky = stickyDiskPathSync();
1426
+ const cache = sticky ? null : imageCachePathsSync();
1413
1427
  const serverArgs = [
1414
1428
  "k3s",
1415
1429
  "server",
@@ -1507,6 +1521,7 @@ export function k3s(opts = {}) {
1507
1521
  const readyCmd = `kubectl get --raw=/readyz >/dev/null 2>&1 && rm -f ${CLUSTER_STORE_TARGET}/${BOOT_MARKER}`;
1508
1522
  const def = {
1509
1523
  image: { type: "registry", reference: `rancher/k3s:${version}` },
1524
+ imageBuilds: images.builds,
1510
1525
  command: cmd,
1511
1526
  privileged: true,
1512
1527
  tmpfs: ["/run", "/var/run"],
@@ -1520,21 +1535,23 @@ export function k3s(opts = {}) {
1520
1535
  // the same netns and are reachable the same way, without appearing
1521
1536
  // in this list (it's documentation, not a firewall).
1522
1537
  ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
1523
- // The cluster's image store (see the store section above): its
1538
+ // Sticky disks retain an independent overlayfs store per service/version.
1539
+ // The legacy image cache (see the store section above) keeps its
1524
1540
  // containerd root on the image cache's root disk, the shared layers
1525
1541
  // disk read-only at its guest path, golden's mkfs.erofs and the adopt
1526
1542
  // helper — or, with no cache, a per-environment volume. Two clusters
1527
1543
  // in one environment get separate roots either way (the service
1528
1544
  // token names the directory), because two containerd daemons cannot
1529
1545
  // share a store: bolt takes an exclusive lock.
1530
- volumes: cache
1531
- ? [
1546
+ volumes: sticky
1547
+ ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
1548
+ : cache ? [
1532
1549
  { source: nestedStoreDir(cache, SELF_SERVICE_TOKEN), target: CLUSTER_STORE_TARGET },
1533
1550
  { source: cache.layers, target: cache.layers, readOnly: true },
1534
1551
  { source: MKFS_EROFS_PATH, target: MKFS_EROFS_PATH, readOnly: true },
1535
1552
  { source: STORE_ADOPT_PATH, target: STORE_ADOPT_PATH, readOnly: true },
1536
1553
  ]
1537
- : [{ target: CLUSTER_STORE_TARGET }],
1554
+ : [{ target: CLUSTER_STORE_TARGET }],
1538
1555
  files: [
1539
1556
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
1540
1557
  ...(cache
@@ -1549,12 +1566,61 @@ export function k3s(opts = {}) {
1549
1566
  command: readyCmd,
1550
1567
  timeoutSecs: opts.readyTimeoutSecs ?? 120,
1551
1568
  },
1552
- setup: async ({ name, helpers }) => {
1569
+ setup: async (ctx) => {
1570
+ const { name, helpers, exec } = ctx;
1571
+ const runtime = componentRuntime(ctx);
1572
+ if (images.builds.length && !runtime)
1573
+ throw new Error(`k3s(${name}): prepared image inputs are missing`);
1574
+ if (runtime?.caBundle && caPresent()) {
1575
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
1576
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1577
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1578
+ }, {
1579
+ apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1580
+ ensureBundle: async (namespace, configMap, bundle) => {
1581
+ try {
1582
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
1583
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
1584
+ } });
1585
+ }
1586
+ catch (err) {
1587
+ // A simultaneous Pod may have created the same namespace's bundle.
1588
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
1589
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true)
1590
+ throw err;
1591
+ }
1592
+ },
1593
+ });
1594
+ // The API server observes webhook registration asynchronously. A dry-run
1595
+ // Pod proves TLS reachability and admission before any project workload.
1596
+ const deadline = Date.now() + 30_000;
1597
+ for (;;) {
1598
+ try {
1599
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
1600
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
1601
+ } })).unwrap();
1602
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
1603
+ break;
1604
+ if (Date.now() >= deadline)
1605
+ throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
1606
+ }
1607
+ catch (err) {
1608
+ if (Date.now() >= deadline)
1609
+ throw err;
1610
+ }
1611
+ await new Promise(resolve => setTimeout(resolve, 100));
1612
+ }
1613
+ }
1614
+ if (runtime)
1615
+ await loadK3sBuildImages(name, runtime.images, runtime.gateway, exec);
1553
1616
  await setupK3sCluster(name, helpers, {
1554
1617
  registry: registryEnabled,
1555
1618
  ingressDomains,
1556
1619
  traefik: traefikEnabled,
1557
1620
  });
1621
+ await prePullK3sImages(name, images.pulls, exec);
1622
+ if (sticky)
1623
+ await registerStickyKubernetesStore(name, version);
1558
1624
  },
1559
1625
  helpers: async ({ name, exec, poll }) => {
1560
1626
  // Read the cluster's kubeconfig and address the API server by its
@@ -294,7 +294,7 @@ export declare function collectNodeReports(dir: string): Promise<NodeReportStats
294
294
  * loads from this service's origins, maps it back to source through the
295
295
  * served source map, and writes one lcov per capture (`browser.lcov`)
296
296
  * holding what ran since the previous capture: every live session is
297
- * harvested first, and the totals are taken and cleared. Nothing ran ⇒
297
+ * harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
298
298
  * an lcov with no records, which is a report saying so.
299
299
  */
300
300
  export declare function browser(): CoverageAdapter;
package/dist/coverage.js CHANGED
@@ -27,7 +27,7 @@
27
27
  // import * as coverage from "@specific.dev/spectest/coverage";
28
28
  // coverage: { adapters: [coverage.node(), coverage.browser()] }
29
29
  import { createHash } from "node:crypto";
30
- import { harvestAllBrowserCoverage, takeBrowserCoverageReports } from "./browser-coverage.js";
30
+ import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
31
31
  import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
32
32
  export { COVERAGE_CONTAINER_DIR };
33
33
  /** Where `node()` mounts its hook inside the container. */
@@ -810,7 +810,7 @@ export async function collectNodeReports(dir) {
810
810
  * loads from this service's origins, maps it back to source through the
811
811
  * served source map, and writes one lcov per capture (`browser.lcov`)
812
812
  * holding what ran since the previous capture: every live session is
813
- * harvested first, and the totals are taken and cleared. Nothing ran ⇒
813
+ * harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
814
814
  * an lcov with no records, which is a report saying so.
815
815
  */
816
816
  export function browser() {
@@ -822,7 +822,7 @@ export function browser() {
822
822
  },
823
823
  async capture(ctx) {
824
824
  await harvestAllBrowserCoverage();
825
- const lcov = takeBrowserCoverageReports().get(ctx.service) ?? "TN:\n";
825
+ const lcov = takeBrowserCoverageReport(ctx.service);
826
826
  await ctx.writeReport("browser.lcov", lcov);
827
827
  },
828
828
  };