@specific.dev/spectest 0.126.0 → 0.128.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 (46) hide show
  1. package/dist/android-step.d.ts +7 -0
  2. package/dist/android.d.ts +17 -3
  3. package/dist/android.js +122 -26
  4. package/dist/components/android.d.ts +1 -1
  5. package/dist/components/android.js +1 -1
  6. package/dist/components/k3s-coverage.d.ts +24 -0
  7. package/dist/components/k3s-coverage.js +259 -0
  8. package/dist/components/k3s-trust.d.ts +4 -2
  9. package/dist/components/k3s-trust.js +13 -0
  10. package/dist/components/k3s.js +31 -0
  11. package/dist/coverage/rust.rs +102 -0
  12. package/dist/coverage-bun.d.ts +10 -0
  13. package/dist/coverage-bun.js +32 -0
  14. package/dist/coverage-rust.d.ts +23 -0
  15. package/dist/coverage-rust.js +71 -0
  16. package/dist/coverage.d.ts +11 -5
  17. package/dist/coverage.js +70 -13
  18. package/dist/daemon.js +27 -2
  19. package/dist/harness/component-images.d.ts +1 -0
  20. package/dist/harness/component-images.js +4 -2
  21. package/dist/harness/coverage-target.d.ts +10 -0
  22. package/dist/harness/coverage-target.js +54 -0
  23. package/dist/index.d.ts +21 -1
  24. package/dist/index.js +34 -1
  25. package/package.json +1 -1
  26. package/src/android-actions.test.ts +121 -0
  27. package/src/android-step.ts +2 -0
  28. package/src/android.test.ts +3 -0
  29. package/src/android.ts +107 -33
  30. package/src/components/android.test.ts +1 -0
  31. package/src/components/android.ts +2 -2
  32. package/src/components/k3s-coverage.test.ts +160 -0
  33. package/src/components/k3s-coverage.ts +234 -0
  34. package/src/components/k3s-trust.ts +14 -2
  35. package/src/components/k3s.ts +26 -0
  36. package/src/coverage/rust.rs +102 -0
  37. package/src/coverage-bun.test.ts +103 -0
  38. package/src/coverage-bun.ts +40 -0
  39. package/src/coverage-rust.test.ts +74 -0
  40. package/src/coverage-rust.ts +79 -0
  41. package/src/coverage.test.ts +4 -4
  42. package/src/coverage.ts +74 -13
  43. package/src/daemon.ts +22 -2
  44. package/src/harness/component-images.ts +5 -2
  45. package/src/harness/coverage-target.ts +55 -0
  46. package/src/index.ts +44 -4
@@ -0,0 +1,160 @@
1
+ import { expect, test } from "bun:test";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { k3sCoverage } from "./k3s-coverage";
6
+ import { k3s } from "./k3s";
7
+ import { node, browser, rust, type CoverageAdapter } from "../coverage";
8
+ import { defineEnvironment } from "../index";
9
+ import type { Pod, Patch } from "./k3s-trust";
10
+ import { podTrustPatch } from "./k3s-trust";
11
+
12
+ function apply(pod: any, patches: Patch[]): any {
13
+ const out = structuredClone(pod);
14
+ for (const operation of patches) {
15
+ const keys = operation.path.slice(1).split("/");
16
+ const key = keys.pop()!;
17
+ const parent = keys.reduce((value, key) => value[key], out);
18
+ if (key === "-") parent.push(operation.value); else parent[key] = operation.value;
19
+ }
20
+ return out;
21
+ }
22
+ const digest = `images.test/api@sha256:${"a".repeat(64)}`;
23
+ const image = { reference: "images.test/api:test", content: "FROM node:24", coverage: { adapters: [node({ sourceRoot: "packages" })] } };
24
+ const dependencies = {
25
+ image: async () => ({ aliases: [digest], root: "/app", env: { NODE_OPTIONS: "--max-old-space-size=512" } }),
26
+ files: async () => {},
27
+ };
28
+
29
+ test("admission recognizes prepared digest, preserves trust/user settings, covers init containers, and is idempotent", async () => {
30
+ const coverage = k3sCoverage([image]);
31
+ const pod = { metadata: { name: "api", annotations: { other: "value" } }, spec: {
32
+ containers: [{ name: "api", image: digest, env: [{ name: "FOO", value: "bar" }], securityContext: { runAsUser: 1000, readOnlyRootFilesystem: true } },
33
+ { name: "db", image: "postgres:17" }], initContainers: [{ name: "migrate", image: image.reference }],
34
+ } };
35
+ const trusted = apply(pod, podTrustPatch(pod, "ca-bundle"));
36
+ const patches = await coverage.mutate("k8s", trusted, "app", true, false, "admission", dependencies);
37
+ const result = apply(trusted, patches);
38
+ expect(result.spec.containers[0].image).toBe(digest);
39
+ expect(result.spec.containers[0].securityContext).toEqual(pod.spec.containers[0].securityContext);
40
+ expect(result.spec.containers[0].env).toContainEqual({ name: "NODE_OPTIONS", value: "--max-old-space-size=512 --require /spectest/coverage-hook.cjs" });
41
+ expect(result.spec.containers[0].env).toContainEqual({ name: "FOO", value: "bar" });
42
+ expect(result.spec.containers[0].volumeMounts.some((m: any) => m.mountPath === "/spectest/ca-bundle.crt")).toBe(true);
43
+ expect(result.spec.containers[1]).toEqual(trusted.spec.containers[1]);
44
+ const ids = JSON.parse(result.metadata.annotations["spectest.dev/coverage"]).instances;
45
+ expect(new Set(ids).size).toBe(2);
46
+ expect(await coverage.mutate("k8s", result, "app", true, false, "admission", dependencies)).toEqual([]);
47
+ await expect(coverage.mutate("k8s", result, "app", true, false, "another-pod", dependencies)).rejects.toThrow("inherited");
48
+ expect(await coverage.mutate("k8s", trusted, "app", true, true, "ephemeral", dependencies)).toEqual([]);
49
+ const other = structuredClone(pod);
50
+ other.spec.containers[0].image = `images.test/api@sha256:${"b".repeat(64)}`;
51
+ other.spec.initContainers = [];
52
+ expect(await coverage.mutate("k8s", other, "app", true, false, "other", dependencies)).toEqual([]);
53
+ });
54
+
55
+ test("coverage refuses colliding mounts and dynamic NODE_OPTIONS", async () => {
56
+ const coverage = k3sCoverage([image]);
57
+ for (const extra of [{ volumeMounts: [{ name: "user", mountPath: "/spectest" }] }, { env: [{ name: "NODE_OPTIONS", valueFrom: { secretKeyRef: { name: "options", key: "flags" } } }] }]) {
58
+ const pod = { metadata: {}, spec: { containers: [{ name: "api", image: digest, ...extra }] } };
59
+ await expect(coverage.mutate("k8s", pod as Pod, "app", true, false, "id", dependencies)).rejects.toThrow();
60
+ }
61
+ });
62
+
63
+ test("k3s mounts fork-local storage on every node and preserves image collectors when browser coverage is added", () => {
64
+ const environment = defineEnvironment({ name: "coverage", services: { k8s: {
65
+ ...k3s({ nodes: 3, images: [image], ingressDomains: ["app.test"] }),
66
+ coverage: { adapters: [browser()] },
67
+ } } });
68
+ const services = environment.config.services;
69
+ for (const service of Object.values(services)) {
70
+ const volume = service.volumes?.find(v => v.target === "/spectest/pod-coverage");
71
+ expect(volume?.source).toEndWith("/.spectest/coverage/k8s");
72
+ expect(volume?.reachExempt).toBe(true);
73
+ }
74
+ const wire = JSON.parse(JSON.stringify(services.k8s));
75
+ expect(wire.coverage.adapters.map((a: any) => a.adapter)).toEqual(["k3s-containers", "browser"]);
76
+ expect(wire.coverage.adapters[0].images[0].config.env.NODE_OPTIONS).toContain("--require");
77
+ expect(wire.imageBuilds[0].image.coverage).toBeUndefined();
78
+ const changed = defineEnvironment({ name: "coverage", services: { k8s: k3s({ images: [{ ...image, coverage: { adapters: [rust()] } }] }) } });
79
+ expect(JSON.stringify(changed.config)).not.toBe(JSON.stringify(environment.config));
80
+ });
81
+
82
+ test("replica counters are independent, reports survive pod deletion, and capture errors remain visible", async () => {
83
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "k3s-cov-"));
84
+ try {
85
+ const adapter: CoverageAdapter = { name: "lcov", reports: "cumulative", sourceOptions: { sourceRoot: "packages" }, ownsReport: n => n.endsWith(".lcov"),
86
+ async inventory(ctx) { await ctx.exec("inventory-probe"); return ["npm:api@1.0.0", "npm:unused@2.0.0"]; },
87
+ };
88
+ const coverage = k3sCoverage([{ ...image, coverage: { adapters: [adapter] } }]);
89
+ const ids = ["a".repeat(16), "b".repeat(16)];
90
+ for (const id of ids) {
91
+ const dir = path.join(root, ".pods", id);
92
+ await fs.mkdir(dir, { recursive: true });
93
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify({ id, image: image.reference, container: "api", namespace: "app", root: "/app" }));
94
+ await fs.writeFile(path.join(dir, "app.lcov"), "TN:\nSF:/app/src/api.ts\nDA:1,2\nend_of_record\n");
95
+ }
96
+ let pods = ids.map(id => ({ metadata: { name: `api-${id}`, annotations: { "spectest.dev/coverage": JSON.stringify({ instances: [id] }) } },
97
+ status: { containerStatuses: [{ name: "api", state: { running: {} } }] } }));
98
+ const commands: string[] = [];
99
+ const ctx = { service: "k8s", reportDir: root, signal: AbortSignal.timeout(5000), exec: async (command: string) => {
100
+ commands.push(command); return { stdout: command.startsWith("kubectl get") ? JSON.stringify({ items: pods }) : "", stderr: "" };
101
+ },
102
+ projectFiles: async () => [], readProjectFile: async () => null, writeReport: async () => {} };
103
+ const first = await coverage.adapter().collect!(ctx, true);
104
+ expect(first).toHaveLength(1);
105
+ expect(first[0].error).toBeUndefined();
106
+ expect(first[0].reports).toHaveLength(3);
107
+ expect(first[0].reports.filter(r => r.name.endsWith(".lcov")).every(r => r.sourceOptions?.sourceRoot === "packages")).toBe(true);
108
+ // The consumer recognizes this exact name; per-replica prefixes would hide it.
109
+ expect(first[0].reports.find(r => r.name === "inventory.units")?.content).toBe("npm:api@1.0.0\nnpm:unused@2.0.0");
110
+ expect(commands.filter(command => command.includes("inventory-probe")).sort()).toEqual(ids.map(id => `kubectl exec -n 'app' 'api-${id}' -c 'api' -- sh -c 'inventory-probe'`));
111
+ expect(commands.filter(command => command.startsWith("kubectl get"))).toHaveLength(1);
112
+ // No pod exists any longer. Reports remain on fork-local node storage.
113
+ pods = [];
114
+ commands.length = 0;
115
+ await fs.writeFile(path.join(root, ".pods", ids[1], "app.lcov"), "TN:\nSF:/app/src/api.ts\nDA:1,3\nend_of_record\n");
116
+ const next = await coverage.adapter().collect!(ctx, false);
117
+ expect(next[0].reports[0].content).toBe("TN:\n");
118
+ expect(next[0].reports[1].content).toContain("DA:1,1");
119
+ expect(commands).toEqual([]); // Native collection needs neither API discovery nor exec.
120
+ await fs.writeFile(path.join(root, ".pods", ids[0], "failed.txt"), "lost report");
121
+ expect((await coverage.adapter().collect!(ctx, false))[0].error).toContain("neither");
122
+ } finally { await fs.rm(root, { recursive: true, force: true }); }
123
+ });
124
+
125
+ test("collection bounds parallel targets and shares lazy discovery for command adapters", async () => {
126
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "k3s-cov-parallel-"));
127
+ try {
128
+ let active = 0;
129
+ let peak = 0;
130
+ let release!: () => void;
131
+ const gate = new Promise<void>(resolve => { release = resolve; });
132
+ const adapter: CoverageAdapter = { name: "command", async capture(ctx) {
133
+ peak = Math.max(peak, ++active);
134
+ if (active === 8) release();
135
+ await gate;
136
+ await ctx.exec("capture-probe");
137
+ await ctx.writeReport("app.lcov", "TN:\nSF:/app/main.rs\nDA:1,1\nend_of_record\n");
138
+ active--;
139
+ } };
140
+ const ids = Array.from({ length: 11 }, (_, index) => index.toString(16).padStart(16, "0"));
141
+ for (const id of ids) {
142
+ const dir = path.join(root, ".pods", id);
143
+ await fs.mkdir(dir, { recursive: true });
144
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify({ id, image: image.reference, container: "api", namespace: "app", root: "/app" }));
145
+ }
146
+ const pods = ids.map(id => ({ metadata: { name: `api-${id}`, annotations: { "spectest.dev/coverage": JSON.stringify({ instances: [id] }) } },
147
+ status: { containerStatuses: [{ name: "api", state: { running: {} } }] } }));
148
+ const commands: string[] = [];
149
+ const ctx = { service: "k8s", reportDir: root, signal: AbortSignal.timeout(5000), exec: async (command: string) => {
150
+ commands.push(command); return { stdout: command.startsWith("kubectl get") ? JSON.stringify({ items: pods }) : "", stderr: "" };
151
+ }, projectFiles: async () => [], readProjectFile: async () => null, writeReport: async () => {} };
152
+ const capture = (await k3sCoverage([{ ...image, coverage: { adapters: [adapter] } }]).adapter().collect!(ctx, false))[0];
153
+ expect(capture.error).toBeUndefined();
154
+ expect(capture.reports.map(report => report.name)).toEqual(ids.map(id => `${id}/app.lcov`));
155
+ expect(active).toBe(0);
156
+ expect(peak).toBe(8);
157
+ expect(commands.filter(command => command.startsWith("kubectl get"))).toHaveLength(1);
158
+ expect(commands.filter(command => command.includes("capture-probe"))).toHaveLength(11);
159
+ } finally { await fs.rm(root, { recursive: true, force: true }); }
160
+ }, 10000);
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { applyCoverageAdapters, coverageAdapters, type CoverageAdapter, type ServiceCoverage } from "../coverage.js";
5
+ import { coverageHostDir, COVERAGE_MAX_BYTES_PER_SERVICE, type ServiceCoverageCapture } from "../harness/coverage.js";
6
+ import { captureCoverageTarget } from "../harness/coverage-target.js";
7
+ import { canonicalImageReference, type KubernetesImage } from "../harness/component-images.js";
8
+ import type { ServiceConfig } from "../index.js";
9
+ import { WORKSPACE } from "../project-files.js";
10
+ import type { Patch, Pod } from "./k3s-trust.js";
11
+
12
+ const MOUNT = "/spectest/pod-coverage";
13
+ const ANNOTATION = "spectest.dev/coverage";
14
+ const hash = (value: string) => createHash("sha256").update(value).digest("hex").slice(0, 16);
15
+ const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
16
+ interface ImageInfo { aliases: string[]; root: string; env: Record<string, string> }
17
+ interface Instance { id: string; image: string; container: string; root: string; namespace: string }
18
+ interface Container {
19
+ name: string; image: string; workingDir?: string;
20
+ env?: { name: string; value?: string; valueFrom?: unknown }[];
21
+ volumeMounts?: { name: string; mountPath: string; [key: string]: unknown }[];
22
+ }
23
+ interface CoveragePod {
24
+ metadata?: { name?: string; annotations?: Record<string, string> };
25
+ spec?: { containers?: Container[]; initContainers?: Container[]; volumes?: { name: string; [key: string]: unknown }[] };
26
+ status?: { containerStatuses?: { name: string; state?: { running?: unknown } }[]; initContainerStatuses?: { name: string; state?: { running?: unknown } }[] };
27
+ }
28
+ export interface K3sCoverageDependencies {
29
+ image(reference: string): Promise<ImageInfo>;
30
+ files(namespace: string, name: string, data: Record<string, string>): Promise<void>;
31
+ }
32
+
33
+ /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
34
+ export function k3sCoverage(images: readonly KubernetesImage[]) {
35
+ const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
36
+ reference: canonicalImageReference(image.reference), coverage: image.coverage,
37
+ }]);
38
+ for (const image of declarations) {
39
+ const adapters = coverageAdapters(image.coverage);
40
+ if (adapters.some(adapter => adapter.load || adapter.collect)) throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
41
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
42
+ }
43
+ const metadata = new Map<string, ImageInfo>();
44
+ const service = (cluster: string, reference: string) => `${cluster}/image-${hash(reference)}`;
45
+ return {
46
+ enabled: declarations.length > 0,
47
+ volume(cluster: string) {
48
+ return { source: coverageHostDir(WORKSPACE, cluster), target: MOUNT, reachExempt: true };
49
+ },
50
+ async mutate(cluster: string, podInput: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string, deps: K3sCoverageDependencies): Promise<Patch[]> {
51
+ if (!declarations.length || ephemeral) return [];
52
+ const pod = podInput as CoveragePod;
53
+ if (!pod.spec) return [];
54
+ const existing = pod.metadata?.annotations?.[ANNOTATION];
55
+ if (existing) {
56
+ if (JSON.parse(existing).admission === uid) return [];
57
+ throw new Error("remove inherited spectest.dev/coverage annotations before creating another Pod");
58
+ }
59
+ const patch: Patch[] = [];
60
+ const volumes = [...(pod.spec.volumes ?? [])];
61
+ const instances: Instance[] = [];
62
+ const namedVolume = (stem: string, volume: Record<string, unknown>) => {
63
+ let name = stem;
64
+ for (let i = 1; volumes.some(v => v.name === name); i++) name = `${stem}-${i}`;
65
+ volumes.push({ name, ...volume }); return name;
66
+ };
67
+ for (const group of ["initContainers", "containers"] as const) {
68
+ for (const [index, container] of (pod.spec[group] ?? []).entries()) {
69
+ const requested = canonicalImageReference(container.image);
70
+ let declaration = declarations.find(image => image.reference === requested);
71
+ // Tangent deploys the prepared image by digest. Resolve aliases from
72
+ // CRI's actual image record, never by stripping a repository tag.
73
+ if (!declaration && requested.includes("@")) {
74
+ for (const candidate of declarations) {
75
+ let info = metadata.get(candidate.reference);
76
+ if (!info) metadata.set(candidate.reference, info = await deps.image(candidate.reference));
77
+ if (info.aliases.includes(requested)) { declaration = candidate; break; }
78
+ }
79
+ }
80
+ if (!declaration) continue;
81
+ let info = metadata.get(declaration.reference);
82
+ if (!info) metadata.set(declaration.reference, info = await deps.image(declaration.reference));
83
+ const id = hash(`${uid}/${group}/${container.name}`);
84
+ const name = service(cluster, declaration.reference);
85
+ const env = { ...info.env };
86
+ for (const entry of container.env ?? []) if (entry.value !== undefined) env[entry.name] = entry.value;
87
+ const configured = applyCoverageAdapters<ServiceConfig>(name, { image: { type: "registry", reference: container.image }, env, coverage: declaration.coverage });
88
+ if (Object.keys(configured).some(key => !["image", "env", "coverage", "files"].includes(key))) {
89
+ throw new Error("Kubernetes coverage adapters may inject env and files only; configure the workload's command and mounts in its manifest");
90
+ }
91
+ const changed = Object.entries(configured.env ?? {}).filter(([key, value]) => env[key] !== value);
92
+ for (const [key] of changed) if (container.env?.some(e => e.name === key && e.valueFrom)) throw new Error(`cannot inject coverage into dynamic ${key}`);
93
+ const mounts = [...(container.volumeMounts ?? [])];
94
+ const addMount = (mountPath: string, mount: Record<string, unknown>) => {
95
+ if (mounts.some(m => mountPath === m.mountPath || mountPath.startsWith(m.mountPath.replace(/\/$/, "") + "/"))) throw new Error(`coverage mount conflicts with ${mountPath}`);
96
+ mounts.push({ mountPath, ...mount } as typeof mounts[number]);
97
+ };
98
+ const volume = namedVolume(`spectest-cov-${id}`, { hostPath: { path: `${MOUNT}/.pods/${id}`, type: "Directory" } });
99
+ addMount("/spectest/coverage", { name: volume });
100
+ const files = configured.files ?? [];
101
+ if (files.some(file => file.user !== undefined || file.group !== undefined || (file.mode !== undefined && !["444", "0444", "644", "0644"].includes(file.mode)))) {
102
+ throw new Error("Kubernetes coverage hook files must be world-readable and need no custom ownership");
103
+ }
104
+ if (files.length) {
105
+ const data = Object.fromEntries(files.map((file, index) => [`file${index}`, file.content]));
106
+ const configMap = `spectest-coverage-${hash(JSON.stringify(data))}`;
107
+ if (!dryRun) await deps.files(namespace, configMap, data);
108
+ const fileVolume = namedVolume(`spectest-hooks-${id}`, { configMap: { name: configMap, defaultMode: 0o444 } });
109
+ files.forEach((file, index) => addMount(file.path, { name: fileVolume, subPath: `file${index}`, readOnly: true }));
110
+ }
111
+ const instance: Instance = { id, image: declaration.reference, namespace, container: container.name, root: container.workingDir || info.root || "/" };
112
+ if (!dryRun) {
113
+ const dir = path.join(coverageHostDir(WORKSPACE, cluster), ".pods", id);
114
+ await fs.mkdir(dir, { recursive: true, mode: 0o777 });
115
+ await fs.chmod(dir, 0o777);
116
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify(instance));
117
+ }
118
+ instances.push(instance);
119
+ patch.push({ op: "add", path: `/spec/${group}/${index}/env`, value: [
120
+ ...(container.env ?? []).filter(e => !changed.some(([key]) => key === e.name)),
121
+ ...changed.map(([name, value]) => ({ name, value })),
122
+ ] }, { op: "add", path: `/spec/${group}/${index}/volumeMounts`, value: mounts });
123
+ }
124
+ }
125
+ if (!instances.length) return [];
126
+ patch.push({ op: "add", path: "/spec/volumes", value: volumes });
127
+ patch.push({ op: "add", path: "/metadata/annotations", value: { ...pod.metadata?.annotations,
128
+ [ANNOTATION]: JSON.stringify({ admission: uid, instances: instances.map(i => i.id) }),
129
+ } });
130
+ return patch;
131
+ },
132
+ adapter(): CoverageAdapter {
133
+ return {
134
+ name: "k3s-containers",
135
+ // Include nested policy AND injected hooks in the warm identity. An
136
+ // adapter's functions would otherwise disappear from JSON.stringify.
137
+ toJSON() { return { adapter: "k3s-containers", images: declarations.map(image => ({
138
+ reference: image.reference,
139
+ config: applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage }),
140
+ })) }; },
141
+ async collect(ctx, baseline) {
142
+ const root = path.join(ctx.reportDir, ".pods");
143
+ let entries: string[];
144
+ try { entries = await fs.readdir(root); } catch (error: any) { if (error.code !== "ENOENT") throw error; entries = []; }
145
+ // Native adapters flush through the shared Unix sockets, including
146
+ // after Pod deletion. Only inventory or command adapters need the API.
147
+ let pods: Promise<CoveragePod[]> | undefined;
148
+ const findPod = async (id: string) => {
149
+ pods ??= ctx.exec("kubectl get pods -A -o json --request-timeout=10s")
150
+ .then(result => JSON.parse(result.stdout).items as CoveragePod[]);
151
+ return (await pods).find(p => {
152
+ const ids = p.metadata?.annotations?.[ANNOTATION];
153
+ return ids && (JSON.parse(ids).instances as string[]).includes(id);
154
+ });
155
+ };
156
+ const captures = new Map<string, ServiceCoverageCapture>();
157
+ const inventories = new Map<string, Set<string>>();
158
+ for (const declaration of declarations) captures.set(declaration.reference, { service: service(ctx.service, declaration.reference), reports: [] });
159
+ const collectInstance = async (id: string) => {
160
+ ctx.signal.throwIfAborted();
161
+ if (!/^[a-f0-9]{16}$/.test(id)) throw new Error("invalid coverage instance directory");
162
+ const dir = path.join(root, id);
163
+ const instance = JSON.parse(await fs.readFile(path.join(dir, ".instance.json"), "utf8")) as Instance;
164
+ const declaration = declarations.find(d => d.reference === instance.image);
165
+ if (!declaration) throw new Error(`coverage image no longer declared: ${instance.image}`);
166
+ const adapters = coverageAdapters(declaration.coverage);
167
+ const livePod = async () => {
168
+ const pod = await findPod(id);
169
+ const statuses = [...(pod?.status?.containerStatuses ?? []), ...(pod?.status?.initContainerStatuses ?? [])];
170
+ return statuses.some(status => status.name === instance.container && status.state?.running) ? pod : undefined;
171
+ };
172
+ for (const adapter of adapters) {
173
+ if (adapter.name === "node") {
174
+ try { await fs.access(path.join(dir, `.attached-${adapter.name}`)); }
175
+ catch { throw new Error(`coverage hook never attached to ${instance.namespace}/${instance.container} (${adapter.name})`); }
176
+ }
177
+ }
178
+ const captured = await captureCoverageTarget({
179
+ coverage: declaration.coverage, root: instance.root, baseline,
180
+ inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
181
+ counterKey: `${service(ctx.service, instance.image)}/${id}`,
182
+ context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
183
+ exec: async command => {
184
+ const pod = await livePod();
185
+ if (!pod?.metadata?.name) throw new Error(`coverage command requires live container ${instance.container}`);
186
+ return ctx.exec(`kubectl exec -n ${quote(instance.namespace)} ${quote(pod.metadata.name)} -c ${quote(instance.container)} -- sh -c ${quote(command)}`);
187
+ },
188
+ },
189
+ });
190
+ return { id, instance, captured };
191
+ };
192
+ // Bound simultaneous LLVM exports and API execs. Drain all workers
193
+ // even on error so no collector can race the following VM snapshot.
194
+ const pending = entries.sort();
195
+ const results = new Map<string, Awaited<ReturnType<typeof collectInstance>>>();
196
+ let cursor = 0;
197
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(8, pending.length) }, async () => {
198
+ while (cursor < pending.length) {
199
+ const id = pending[cursor++];
200
+ results.set(id, await collectInstance(id));
201
+ }
202
+ }));
203
+ const failure = workers.find(worker => worker.status === "rejected");
204
+ if (failure?.status === "rejected") throw failure.reason;
205
+ for (const id of pending) {
206
+ const { instance, captured } = results.get(id)!;
207
+ const aggregate = captures.get(instance.image)!;
208
+ if (captured.error) aggregate.error = [aggregate.error, `${instance.namespace}/${instance.container}: ${captured.error}`].filter(Boolean).join("; ");
209
+ if (aggregate.root && aggregate.root !== instance.root) {
210
+ aggregate.error = "coverage containers from one image have different working directories; use a common workingDir for this image's covered containers";
211
+ }
212
+ aggregate.root = instance.root;
213
+ for (const report of captured.reports) {
214
+ if (report.name === "inventory.units") {
215
+ let units = inventories.get(instance.image);
216
+ if (!units) inventories.set(instance.image, units = new Set());
217
+ for (const unit of report.content.split("\n")) if (unit) units.add(unit);
218
+ } else aggregate.reports.push({ ...report, name: `${id}/${report.name}` });
219
+ }
220
+ }
221
+ for (const [image, units] of inventories) captures.get(image)!.reports.push({ name: "inventory.units", content: [...units].sort().join("\n") });
222
+ for (const capture of captures.values()) {
223
+ if (!capture.reports.length && !capture.error) capture.reports.push({ name: "empty.lcov", content: "TN:\n" });
224
+ if (capture.reports.reduce((bytes, report) => bytes + Buffer.byteLength(report.content), 0) > COVERAGE_MAX_BYTES_PER_SERVICE) {
225
+ capture.reports = [];
226
+ capture.error = "Kubernetes image coverage exceeds the per-service report size limit";
227
+ }
228
+ }
229
+ return [...captures.values()];
230
+ },
231
+ } as CoverageAdapter;
232
+ },
233
+ };
234
+ }
@@ -14,7 +14,7 @@ interface Container {
14
14
  env?: { name: string; value?: string; valueFrom?: unknown }[];
15
15
  volumeMounts?: { name: string; mountPath: string; subPath?: string; readOnly?: boolean }[];
16
16
  }
17
- interface Pod {
17
+ export interface Pod {
18
18
  metadata?: { annotations?: Record<string, string> };
19
19
  spec?: {
20
20
  os?: { name: string };
@@ -22,7 +22,7 @@ interface Pod {
22
22
  containers?: Container[]; initContainers?: Container[]; ephemeralContainers?: Container[];
23
23
  };
24
24
  }
25
- interface Patch { op: "add"; path: string; value: unknown }
25
+ export interface Patch { op: "add"; path: string; value: unknown }
26
26
  export const TRUST_OPT_OUT = "spectest.dev/inject-ca";
27
27
 
28
28
  export function podTrustPatch(pod: Pod, configMap: string, ephemeral = false): Patch[] {
@@ -67,6 +67,8 @@ export interface TrustDependencies {
67
67
  /** Ensure this immutable ConfigMap exists in the requesting namespace. */
68
68
  ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
69
69
  apply(configuration: unknown): Promise<void>;
70
+ /** Component-owned pod instrumentation runs after trust patching. */
71
+ mutatePod?(pod: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string): Promise<Patch[]>;
70
72
  }
71
73
  const servers = new Map<string, Server>();
72
74
 
@@ -91,6 +93,16 @@ export async function installPodTrust(options: {
91
93
  res.writeHead(400).end("invalid Pod admission request"); return;
92
94
  }
93
95
  const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
96
+ if (deps.mutatePod) {
97
+ const pod = structuredClone(request.object);
98
+ for (const operation of patch) {
99
+ const keys = operation.path.slice(1).split("/");
100
+ const key = keys.pop()!;
101
+ const parent = keys.reduce((value: any, key) => value[key], pod as any);
102
+ if (key === "-") parent.push(operation.value); else parent[key] = operation.value;
103
+ }
104
+ patch.push(...await deps.mutatePod(pod, request.namespace, !!request.dryRun, request.subResource === "ephemeralcontainers", request.uid));
105
+ }
94
106
  // Dry runs return the same patch but perform no API writes.
95
107
  if (patch.length && !request.dryRun) await deps.ensureBundle(request.namespace, configMap, options.bundle);
96
108
  res.setHeader("Content-Type", "application/json");
@@ -1,6 +1,7 @@
1
1
  import { isIP } from "node:net";
2
2
  import { waitForK3sNodes } from "./k3s-nodes.js";
3
3
  import { installPodTrust } from "./k3s-trust.js";
4
+ import { k3sCoverage } from "./k3s-coverage.js";
4
5
  import { waitForAll } from "../harness/concurrency.js";
5
6
  import { CONTENT_BLOBS, REGISTRY_CONTENT, REGISTRY_DATA, REGISTRY_IMAGE, registryDirectory, seedRegistry } from "../harness/registry-seed.js";
6
7
  import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
@@ -1969,6 +1970,7 @@ export function k3s(opts: K3sOptions = {}) {
1969
1970
  function createK3sCluster(opts: K3sOptions, count: number) {
1970
1971
  const version = opts.version ?? DEFAULT_K3S_VERSION;
1971
1972
  const images = kubernetesImages(opts.images ?? []);
1973
+ const coverage = k3sCoverage(opts.images ?? []);
1972
1974
  const extra = opts.extraArgs ?? [];
1973
1975
  assertUsableExtraArgs(extra);
1974
1976
  const registryEnabled = opts.registry !== false;
@@ -2132,6 +2134,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2132
2134
  volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
2133
2135
  ? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
2134
2136
  : [{ target: CLUSTER_STORE_TARGET }]),
2137
+ ...(coverage.enabled ? [coverage.volume(cluster)] : []),
2135
2138
  ...(server && publishBuilds.length ? [
2136
2139
  { source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
2137
2140
  { source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
@@ -2140,6 +2143,7 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2140
2143
  files: [
2141
2144
  ...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
2142
2145
  ],
2146
+ ...(server && coverage.enabled ? { coverage: { adapters: [coverage.adapter()] } } : {}),
2143
2147
  // Agents start before the server and wait for its join token. The group
2144
2148
  // setup below gates readiness on every actual Kubernetes Node, including
2145
2149
  // the server, rather than treating container startup as cluster readiness.
@@ -2167,11 +2171,33 @@ function createK3sCluster(opts: K3sOptions, count: number) {
2167
2171
  const nodes = nodeNames(name);
2168
2172
  if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2169
2173
  const admission = (async () => {
2174
+ if (coverage.enabled && (!runtime?.caBundle || !caPresent())) throw new Error(`k3s(${name}): coverage injection requires the environment CA`);
2170
2175
  if (runtime?.caBundle && caPresent()) {
2171
2176
  const { cert, key } = await issueIngressCert([runtime.gateway]);
2172
2177
  await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2173
2178
  ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2174
2179
  }, {
2180
+ mutatePod: (pod, namespace, dryRun, ephemeral, uid) => coverage.mutate(name, pod, namespace, dryRun, ephemeral, uid, {
2181
+ async image(reference) {
2182
+ const result = await exec(name, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference]);
2183
+ if (result.exitCode !== 0) throw new Error(`coverage image metadata: ${result.stderr}`);
2184
+ const record = JSON.parse(result.stdout);
2185
+ const config = record.info?.imageSpec?.config;
2186
+ if (!config) throw new Error(`coverage image ${reference} has no OCI configuration`);
2187
+ return { aliases: record.status?.repoDigests ?? [], root: config.WorkingDir || "/",
2188
+ env: Object.fromEntries((config.Env ?? []).map((entry: string) => { const split = entry.indexOf("="); return [entry.slice(0, split), entry.slice(split + 1)]; })),
2189
+ };
2190
+ },
2191
+ async files(namespace, name, data) {
2192
+ try {
2193
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: { metadata: { name, namespace }, immutable: true, data } });
2194
+ } catch (error) {
2195
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name })).unwrap();
2196
+ if (!existing.immutable || Object.keys(existing.data ?? {}).length !== Object.keys(data).length ||
2197
+ Object.entries(data).some(([key, value]) => existing.data?.[key] !== value)) throw error;
2198
+ }
2199
+ },
2200
+ }),
2175
2201
  apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2176
2202
  ensureBundle: async (namespace, configMap, bundle) => {
2177
2203
  try {
@@ -0,0 +1,102 @@
1
+ // Copy to your test build as spectest-coverage.rs and include behind a coverage
2
+ // feature. Call start()? at the beginning of main in EACH instrumented binary.
3
+ // No Spectest service, compiler or network connection is needed at runtime.
4
+ use std::{ffi::CString, fs, io::{self, BufRead, Read, Write}, os::unix::net::UnixListener,
5
+ path::PathBuf, process::Command, sync::{Mutex, OnceLock}, time::{SystemTime, UNIX_EPOCH}};
6
+
7
+ unsafe extern "C" {
8
+ fn __llvm_profile_get_size_for_buffer() -> u64;
9
+ fn __llvm_profile_write_buffer(buffer: *mut u8) -> i32;
10
+ fn __llvm_profile_set_filename(filename: *const std::ffi::c_char);
11
+ fn atexit(callback: extern "C" fn()) -> i32;
12
+ }
13
+
14
+ struct Collector { dir: PathBuf, id: String, socket: PathBuf, binary: PathBuf, lock: Mutex<()> }
15
+ static COLLECTOR: OnceLock<Collector> = OnceLock::new();
16
+
17
+ fn failure(error: impl std::fmt::Display) {
18
+ if let Some(c) = COLLECTOR.get() {
19
+ let _ = fs::write(c.dir.join(format!("rust-error-{}.txt", c.id)), error.to_string());
20
+ }
21
+ }
22
+
23
+ fn tool(name: &str, default: &str, args: &[&std::ffi::OsStr]) -> io::Result<Vec<u8>> {
24
+ let output = Command::new(std::env::var_os(name).unwrap_or_else(|| default.into())).args(args).output()?;
25
+ if !output.status.success() {
26
+ return Err(io::Error::other(format!("{default}: {}", String::from_utf8_lossy(&output.stderr))));
27
+ }
28
+ Ok(output.stdout)
29
+ }
30
+
31
+ fn capture() -> io::Result<()> {
32
+ let c = COLLECTOR.get().expect("coverage initialized");
33
+ let _guard = c.lock.lock().map_err(|_| io::Error::other("coverage lock poisoned"))?;
34
+ let raw = c.dir.join(format!(".rust/{}.profraw", c.id));
35
+ let data = c.dir.join(format!(".rust/{}.profdata", c.id));
36
+ // A buffer snapshot overwrites this process's profile. write_file() with
37
+ // merge patterns would repeatedly add its inherited counters to itself.
38
+ let mut buffer = vec![0u8; unsafe { __llvm_profile_get_size_for_buffer() } as usize];
39
+ if unsafe { __llvm_profile_write_buffer(buffer.as_mut_ptr()) } != 0 {
40
+ return Err(io::Error::other("LLVM could not snapshot counters"));
41
+ }
42
+ fs::write(&raw, buffer)?;
43
+ tool("SPECTEST_LLVM_PROFDATA", "llvm-profdata", &["merge".as_ref(), "-sparse".as_ref(), raw.as_os_str(), "-o".as_ref(), data.as_os_str()])?;
44
+ let ignore = std::env::var("SPECTEST_RUST_IGNORE").unwrap_or_else(|_| "(/rustc/|/\\.cargo/registry/|/spectest-coverage\\.rs$)".into());
45
+ let report = tool("SPECTEST_LLVM_COV", "llvm-cov", &["export".as_ref(), "-format=lcov".as_ref(), "-instr-profile".as_ref(), data.as_os_str(),
46
+ c.binary.as_os_str(), format!("-ignore-filename-regex={ignore}").as_ref()])?;
47
+ if !report.starts_with(b"SF:") && !report.starts_with(b"TN:") {
48
+ return Err(io::Error::other("LLVM exported no LCOV source records"));
49
+ }
50
+ let mut report = String::from_utf8(report).map_err(io::Error::other)?;
51
+ if let Ok(root) = std::env::var("SPECTEST_RUST_BUILD_ROOT") {
52
+ let prefix = format!("SF:{}/", root.trim_end_matches('/'));
53
+ report = report.lines().map(|line| match line.strip_prefix(&prefix) {
54
+ Some(relative) => format!("SF:{relative}\n"), None => format!("{line}\n"),
55
+ }).collect();
56
+ }
57
+ let temporary = c.dir.join(format!(".rust/{}.lcov.tmp", c.id));
58
+ fs::write(&temporary, report)?;
59
+ fs::rename(temporary, c.dir.join(format!("rust-{}.lcov", c.id)))?;
60
+ Ok(())
61
+ }
62
+
63
+ extern "C" fn finish() {
64
+ if let Err(error) = capture() { failure(error); }
65
+ if let Some(c) = COLLECTOR.get() { let _ = fs::remove_file(&c.socket); }
66
+ }
67
+
68
+ /// Disabled when SPECTEST_COVERAGE_DIR is absent. Requires Unix and LLVM
69
+ /// instrumentation at link time; gate this module behind your coverage build.
70
+ pub fn start() -> io::Result<()> {
71
+ let Some(dir) = std::env::var_os("SPECTEST_COVERAGE_DIR") else { return Ok(()); };
72
+ if COLLECTOR.get().is_some() { return Ok(()); }
73
+ let dir = PathBuf::from(dir);
74
+ fs::create_dir_all(dir.join(".rust"))?;
75
+ let nonce = SystemTime::now().duration_since(UNIX_EPOCH).map_err(io::Error::other)?.as_nanos();
76
+ let id = format!("{:x}-{nonce:x}", std::process::id());
77
+ let socket = dir.join(format!(".rust-ctl-{id}"));
78
+ let listener = UnixListener::bind(&socket)?;
79
+ let binary = std::env::current_exe()?;
80
+ let exit_path = CString::new(dir.join(format!(".rust/{id}.exit.profraw")).as_os_str().as_encoded_bytes())?;
81
+ // The profiler may retain the filename until process exit.
82
+ unsafe { __llvm_profile_set_filename(exit_path.into_raw()); }
83
+ COLLECTOR.set(Collector { dir, id, socket, binary, lock: Mutex::new(()) }).map_err(|_| io::Error::other("coverage already initialized"))?;
84
+ if unsafe { atexit(finish) } != 0 { return Err(io::Error::other("could not register coverage exit handler")); }
85
+ // Establish a report before readiness; an uninstrumented/mismatched build
86
+ // must fail before it can supply an apparently empty baseline.
87
+ if let Err(error) = capture() { failure(&error); return Err(error); }
88
+ std::thread::Builder::new().name("spectest-coverage".into()).spawn(move || {
89
+ for connection in listener.incoming() {
90
+ let mut stream = match connection { Ok(stream) => stream, Err(error) => { failure(error); break; } };
91
+ let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(5)));
92
+ let mut request = String::new();
93
+ let result = io::BufReader::new(&stream).take(32).read_line(&mut request)
94
+ .and_then(|_| if request == "dump\n" { capture() } else { Err(io::Error::other("invalid coverage request")) });
95
+ match result {
96
+ Ok(()) => { let _ = stream.write_all(b"ok\n"); }
97
+ Err(error) => { failure(&error); let _ = writeln!(stream, "error {error}"); }
98
+ }
99
+ }
100
+ })?;
101
+ Ok(())
102
+ }