@specific.dev/spectest 0.127.0 → 0.129.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/browser.d.ts +12 -5
- package/dist/browser.js +13 -13
- package/dist/components/k3s-coverage.d.ts +24 -0
- package/dist/components/k3s-coverage.js +259 -0
- package/dist/components/k3s-trust.d.ts +4 -2
- package/dist/components/k3s-trust.js +13 -0
- package/dist/components/k3s.js +31 -0
- package/dist/coverage/rust.rs +102 -0
- package/dist/coverage-bun.d.ts +10 -0
- package/dist/coverage-bun.js +32 -0
- package/dist/coverage-rust.d.ts +23 -0
- package/dist/coverage-rust.js +71 -0
- package/dist/coverage.d.ts +11 -5
- package/dist/coverage.js +70 -13
- package/dist/daemon.js +27 -2
- package/dist/harness/component-images.d.ts +1 -0
- package/dist/harness/component-images.js +4 -2
- package/dist/harness/coverage-target.d.ts +10 -0
- package/dist/harness/coverage-target.js +54 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +10 -1
- package/dist/recorder.d.ts +29 -2
- package/dist/terminal.d.ts +31 -17
- package/dist/terminal.js +261 -105
- package/package.json +1 -1
- package/src/browser-navigation.test.ts +36 -0
- package/src/browser.ts +26 -18
- package/src/components/k3s-coverage.test.ts +160 -0
- package/src/components/k3s-coverage.ts +234 -0
- package/src/components/k3s-trust.ts +14 -2
- package/src/components/k3s.ts +26 -0
- package/src/coverage/rust.rs +102 -0
- package/src/coverage-bun.test.ts +103 -0
- package/src/coverage-bun.ts +40 -0
- package/src/coverage-rust.test.ts +74 -0
- package/src/coverage-rust.ts +79 -0
- package/src/coverage.test.ts +4 -4
- package/src/coverage.ts +74 -13
- package/src/daemon.ts +22 -2
- package/src/harness/component-images.ts +5 -2
- package/src/harness/coverage-target.ts +55 -0
- package/src/index.ts +12 -3
- package/src/recorder.ts +22 -2
- package/src/terminal.test.ts +324 -0
- package/src/terminal.ts +269 -119
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { openBrowser } from "./browser.js";
|
|
3
|
+
import { startRecording, stopRecording } from "./recorder.js";
|
|
4
|
+
|
|
5
|
+
const available = !!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome"));
|
|
6
|
+
|
|
7
|
+
test.skipIf(!available)("public navigation options control goto/reload and remain visible in recordings", async () => {
|
|
8
|
+
// The document commits immediately but load waits for an image. This
|
|
9
|
+
// distinguishes waitUntil behavior from simply extending a deadline.
|
|
10
|
+
const server = Bun.serve({port:0,hostname:"127.0.0.1", async fetch(request) {
|
|
11
|
+
if (new URL(request.url).pathname === "/slow.png") {
|
|
12
|
+
await Bun.sleep(500);
|
|
13
|
+
return new Response("", {status:404});
|
|
14
|
+
}
|
|
15
|
+
return new Response('<title>Navigation options</title><img src="/slow.png">',
|
|
16
|
+
{headers:{"content-type":"text/html","cache-control":"no-store"}});
|
|
17
|
+
}});
|
|
18
|
+
const browser = await openBrowser({recorder:{sessionId:"navigation",recordStep() {}}});
|
|
19
|
+
startRecording();
|
|
20
|
+
try {
|
|
21
|
+
await browser.goto(server.url.href, {waitUntil:"domcontentloaded",timeout:2000});
|
|
22
|
+
expect(await browser.title()).toBe("Navigation options");
|
|
23
|
+
await expect(browser.reload({waitUntil:"load",timeout:50})).rejects.toThrow(/Timeout/);
|
|
24
|
+
await browser.reload({waitUntil:"load",timeout:2000});
|
|
25
|
+
const events = stopRecording().filter(e => e.kind === "browser");
|
|
26
|
+
expect(events.filter(e => e.action === "goto" || e.action === "reload")).toMatchObject([
|
|
27
|
+
{action:"goto",waitUntil:"domcontentloaded",timeout:2000},
|
|
28
|
+
{action:"reload",waitUntil:"load",timeout:50,error:expect.any(String)},
|
|
29
|
+
{action:"reload",waitUntil:"load",timeout:2000},
|
|
30
|
+
]);
|
|
31
|
+
} finally {
|
|
32
|
+
stopRecording();
|
|
33
|
+
await browser.close();
|
|
34
|
+
server.stop(true);
|
|
35
|
+
}
|
|
36
|
+
}, 15000);
|
package/src/browser.ts
CHANGED
|
@@ -208,6 +208,12 @@ export interface Mouse {
|
|
|
208
208
|
wheel(dx: number, dy: number): Promise<void>;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
export interface NavigationOptions {
|
|
212
|
+
/** Playwright navigation timeout in milliseconds; 0 disables the deadline. */
|
|
213
|
+
timeout?: number;
|
|
214
|
+
waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
|
|
215
|
+
}
|
|
216
|
+
|
|
211
217
|
/**
|
|
212
218
|
* Headless browser session — Playwright `Page`-shaped. Select elements with
|
|
213
219
|
* the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
|
|
@@ -220,14 +226,14 @@ export interface Browser {
|
|
|
220
226
|
url(): string;
|
|
221
227
|
/** Current page `<title>` (async, like Playwright's `page.title()`). */
|
|
222
228
|
title(): Promise<string>;
|
|
223
|
-
/** Navigate to a URL;
|
|
224
|
-
goto(url: string): Promise<void>;
|
|
229
|
+
/** Navigate to a URL; waits for the main frame load unless waitUntil is supplied. */
|
|
230
|
+
goto(url: string, options?: NavigationOptions): Promise<void>;
|
|
225
231
|
/** Navigate back in session history. */
|
|
226
|
-
goBack(): Promise<void>;
|
|
232
|
+
goBack(options?: NavigationOptions): Promise<void>;
|
|
227
233
|
/** Navigate forward in session history. */
|
|
228
|
-
goForward(): Promise<void>;
|
|
234
|
+
goForward(options?: NavigationOptions): Promise<void>;
|
|
229
235
|
/** Reload the current page. */
|
|
230
|
-
reload(): Promise<void>;
|
|
236
|
+
reload(options?: NavigationOptions): Promise<void>;
|
|
231
237
|
|
|
232
238
|
readonly keyboard: Keyboard;
|
|
233
239
|
readonly mouse: Mouse;
|
|
@@ -1995,11 +2001,11 @@ function buildBackend(
|
|
|
1995
2001
|
keyboard,
|
|
1996
2002
|
mouse,
|
|
1997
2003
|
touchscreen,
|
|
1998
|
-
goto(url) {
|
|
2004
|
+
goto(url, options) {
|
|
1999
2005
|
recorder?.noteNavigation?.(url);
|
|
2000
|
-
return instrumented("goto", { url }, async () => {
|
|
2006
|
+
return instrumented("goto", { url, ...options }, async () => {
|
|
2001
2007
|
try {
|
|
2002
|
-
await holder.page.goto(url, { waitUntil: "load" });
|
|
2008
|
+
await holder.page.goto(url, { waitUntil: "load", ...options });
|
|
2003
2009
|
} catch (err) {
|
|
2004
2010
|
// Restored-renderer DNS bug (see `rebuildView`): only when the
|
|
2005
2011
|
// view is persistent (so it may have lived through a snapshot
|
|
@@ -2009,23 +2015,23 @@ function buildBackend(
|
|
|
2009
2015
|
if (!buildOpts.persistent || !isNameNotResolved(err)) throw err;
|
|
2010
2016
|
if (!(await daemonResolves(url))) throw err;
|
|
2011
2017
|
await rebuildView(holder);
|
|
2012
|
-
await holder.page.goto(url, { waitUntil: "load" });
|
|
2018
|
+
await holder.page.goto(url, { waitUntil: "load", ...options });
|
|
2013
2019
|
}
|
|
2014
2020
|
});
|
|
2015
2021
|
},
|
|
2016
|
-
goBack() {
|
|
2017
|
-
return instrumented("goBack", {}, async () => {
|
|
2018
|
-
await holder.page.goBack();
|
|
2022
|
+
goBack(options) {
|
|
2023
|
+
return instrumented("goBack", { ...options }, async () => {
|
|
2024
|
+
await holder.page.goBack(options);
|
|
2019
2025
|
});
|
|
2020
2026
|
},
|
|
2021
|
-
goForward() {
|
|
2022
|
-
return instrumented("goForward", {}, async () => {
|
|
2023
|
-
await holder.page.goForward();
|
|
2027
|
+
goForward(options) {
|
|
2028
|
+
return instrumented("goForward", { ...options }, async () => {
|
|
2029
|
+
await holder.page.goForward(options);
|
|
2024
2030
|
});
|
|
2025
2031
|
},
|
|
2026
|
-
reload() {
|
|
2027
|
-
return instrumented("reload", {}, async () => {
|
|
2028
|
-
await holder.page.reload();
|
|
2032
|
+
reload(options) {
|
|
2033
|
+
return instrumented("reload", { ...options }, async () => {
|
|
2034
|
+
await holder.page.reload(options);
|
|
2029
2035
|
});
|
|
2030
2036
|
},
|
|
2031
2037
|
|
|
@@ -2325,6 +2331,8 @@ function buildBackend(
|
|
|
2325
2331
|
}
|
|
2326
2332
|
|
|
2327
2333
|
export interface RecordableFields {
|
|
2334
|
+
timeout: number;
|
|
2335
|
+
waitUntil: NonNullable<NavigationOptions["waitUntil"]>;
|
|
2328
2336
|
target: RecordedBrowserLocatorPart[];
|
|
2329
2337
|
destinationTarget: RecordedBrowserLocatorPart[];
|
|
2330
2338
|
url: string;
|
|
@@ -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");
|
package/src/components/k3s.ts
CHANGED
|
@@ -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 {
|