@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
package/README.md
CHANGED
|
@@ -24,6 +24,60 @@ work; do not replace readiness with sleeps or arbitrary `.first()` selections.
|
|
|
24
24
|
SDK 0.123.0 uses native Playwright assertions while retaining Spectest's API and
|
|
25
25
|
recording. Ambiguous single-element selectors fail immediately; make them unique.
|
|
26
26
|
|
|
27
|
+
For terminal readiness, use `terminal.waitFor` directly. It retries the condition
|
|
28
|
+
and throws on timeout, so neither an outer `ctx.wait` nor an extra assertion is
|
|
29
|
+
needed to check that text appears:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
await terminal.waitFor("Rack is initialized", /Current rack status:\s*Initialized/, {
|
|
33
|
+
timeoutMs: 35 * 60_000,
|
|
34
|
+
intervalMs: 5_000,
|
|
35
|
+
failOn: [
|
|
36
|
+
{ description: "Setup was rejected", matcher: "Start Rack Setup Failed" },
|
|
37
|
+
{ description: "Initialization failed", matcher: "Initialization Failed" },
|
|
38
|
+
{ description: "Initialization panicked", matcher: "Initialization Panicked" },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
await terminal.press("Ctrl+C"); // Separate cleanup after the condition succeeds.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
SDK 0.129.0 records the string/regex matcher (or identifies a custom predicate),
|
|
45
|
+
timeout, and start/end positions for this wait. The dashboard shows its condition
|
|
46
|
+
and outcome and replays up to the result. Later inputs belong to their own steps;
|
|
47
|
+
the full terminal session can be opened separately. `ctx.wait` remains useful
|
|
48
|
+
when a retry must repeat actions, rather than just observe terminal state.
|
|
49
|
+
|
|
50
|
+
Terminal waits record matched, timed-out, failure-condition, predicate-error,
|
|
51
|
+
process-exit and cancelled outcomes. `failOn` checks the rendered screen before
|
|
52
|
+
checking success; string and regex conditions are recorded explicitly. For a
|
|
53
|
+
custom predicate, provide `condition: "Expected sled has a valid bootstrap IPv6 address"`
|
|
54
|
+
to explain the actual check separately from its step title. Async predicates are
|
|
55
|
+
awaited, and their rejection is recorded. A final matching screen succeeds even
|
|
56
|
+
if the process has exited; otherwise exit fails the wait after final output is
|
|
57
|
+
drained. No `Promise.race` with `terminal.exited` is needed.
|
|
58
|
+
|
|
59
|
+
Pass `signal: controller.signal` to cancel a wait without closing its terminal.
|
|
60
|
+
Cancellation stops SDK polling; it cannot forcibly stop work started inside a
|
|
61
|
+
custom predicate. `terminal.close({ timeoutMs: 10_000 })` cancels pending waits,
|
|
62
|
+
sends EOF, escalates to a kill, and bounds cleanup including output drain. The
|
|
63
|
+
default close deadline is 10 seconds; repeated calls share the same result or
|
|
64
|
+
failure. `terminal.exited` records the exit when observed after completion, not
|
|
65
|
+
when its promise is first read. A stalled output drain fails after 5 seconds
|
|
66
|
+
instead of hanging indefinitely. Test code should preserve its original failure
|
|
67
|
+
if diagnostic or cleanup operations also fail.
|
|
68
|
+
|
|
69
|
+
Browser navigation accepts public Playwright-style options, without a backend
|
|
70
|
+
cast or `pageOp` call:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
await browser.goto(consoleUrl, { timeout: 120_000, waitUntil: "domcontentloaded" });
|
|
74
|
+
await browser.reload({ timeout: 120_000 });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
These options also apply to `goBack` and `goForward`. Navigation deadlines do not
|
|
78
|
+
cancel a surrounding `ctx.poll`, and retrying a navigation after a visible error
|
|
79
|
+
is a recovery action that should remain visible in the test results.
|
|
80
|
+
|
|
27
81
|
Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
28
82
|
`pubsub`/`pubsubClient` exports and `/components/gcp` entry point are removed.
|
|
29
83
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
package/dist/browser.d.ts
CHANGED
|
@@ -124,6 +124,11 @@ export interface Mouse {
|
|
|
124
124
|
/** Wheel-scroll by a pixel delta. */
|
|
125
125
|
wheel(dx: number, dy: number): Promise<void>;
|
|
126
126
|
}
|
|
127
|
+
export interface NavigationOptions {
|
|
128
|
+
/** Playwright navigation timeout in milliseconds; 0 disables the deadline. */
|
|
129
|
+
timeout?: number;
|
|
130
|
+
waitUntil?: "load" | "domcontentloaded" | "networkidle" | "commit";
|
|
131
|
+
}
|
|
127
132
|
/**
|
|
128
133
|
* Headless browser session — Playwright `Page`-shaped. Select elements with
|
|
129
134
|
* the `getBy*`/`locator` roots (returning a {@link Locator}) and act on them;
|
|
@@ -136,14 +141,14 @@ export interface Browser {
|
|
|
136
141
|
url(): string;
|
|
137
142
|
/** Current page `<title>` (async, like Playwright's `page.title()`). */
|
|
138
143
|
title(): Promise<string>;
|
|
139
|
-
/** Navigate to a URL;
|
|
140
|
-
goto(url: string): Promise<void>;
|
|
144
|
+
/** Navigate to a URL; waits for the main frame load unless waitUntil is supplied. */
|
|
145
|
+
goto(url: string, options?: NavigationOptions): Promise<void>;
|
|
141
146
|
/** Navigate back in session history. */
|
|
142
|
-
goBack(): Promise<void>;
|
|
147
|
+
goBack(options?: NavigationOptions): Promise<void>;
|
|
143
148
|
/** Navigate forward in session history. */
|
|
144
|
-
goForward(): Promise<void>;
|
|
149
|
+
goForward(options?: NavigationOptions): Promise<void>;
|
|
145
150
|
/** Reload the current page. */
|
|
146
|
-
reload(): Promise<void>;
|
|
151
|
+
reload(options?: NavigationOptions): Promise<void>;
|
|
147
152
|
readonly keyboard: Keyboard;
|
|
148
153
|
readonly mouse: Mouse;
|
|
149
154
|
/** Root CSS query. */
|
|
@@ -363,6 +368,8 @@ export declare function acquirePersistentDesktop(endpoint: string, windowIndex:
|
|
|
363
368
|
desktopChrome?: DesktopChrome;
|
|
364
369
|
}>;
|
|
365
370
|
export interface RecordableFields {
|
|
371
|
+
timeout: number;
|
|
372
|
+
waitUntil: NonNullable<NavigationOptions["waitUntil"]>;
|
|
366
373
|
target: RecordedBrowserLocatorPart[];
|
|
367
374
|
destinationTarget: RecordedBrowserLocatorPart[];
|
|
368
375
|
url: string;
|
package/dist/browser.js
CHANGED
|
@@ -1491,11 +1491,11 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1491
1491
|
keyboard,
|
|
1492
1492
|
mouse,
|
|
1493
1493
|
touchscreen,
|
|
1494
|
-
goto(url) {
|
|
1494
|
+
goto(url, options) {
|
|
1495
1495
|
recorder?.noteNavigation?.(url);
|
|
1496
|
-
return instrumented("goto", { url }, async () => {
|
|
1496
|
+
return instrumented("goto", { url, ...options }, async () => {
|
|
1497
1497
|
try {
|
|
1498
|
-
await holder.page.goto(url, { waitUntil: "load" });
|
|
1498
|
+
await holder.page.goto(url, { waitUntil: "load", ...options });
|
|
1499
1499
|
}
|
|
1500
1500
|
catch (err) {
|
|
1501
1501
|
// Restored-renderer DNS bug (see `rebuildView`): only when the
|
|
@@ -1508,23 +1508,23 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1508
1508
|
if (!(await daemonResolves(url)))
|
|
1509
1509
|
throw err;
|
|
1510
1510
|
await rebuildView(holder);
|
|
1511
|
-
await holder.page.goto(url, { waitUntil: "load" });
|
|
1511
|
+
await holder.page.goto(url, { waitUntil: "load", ...options });
|
|
1512
1512
|
}
|
|
1513
1513
|
});
|
|
1514
1514
|
},
|
|
1515
|
-
goBack() {
|
|
1516
|
-
return instrumented("goBack", {}, async () => {
|
|
1517
|
-
await holder.page.goBack();
|
|
1515
|
+
goBack(options) {
|
|
1516
|
+
return instrumented("goBack", { ...options }, async () => {
|
|
1517
|
+
await holder.page.goBack(options);
|
|
1518
1518
|
});
|
|
1519
1519
|
},
|
|
1520
|
-
goForward() {
|
|
1521
|
-
return instrumented("goForward", {}, async () => {
|
|
1522
|
-
await holder.page.goForward();
|
|
1520
|
+
goForward(options) {
|
|
1521
|
+
return instrumented("goForward", { ...options }, async () => {
|
|
1522
|
+
await holder.page.goForward(options);
|
|
1523
1523
|
});
|
|
1524
1524
|
},
|
|
1525
|
-
reload() {
|
|
1526
|
-
return instrumented("reload", {}, async () => {
|
|
1527
|
-
await holder.page.reload();
|
|
1525
|
+
reload(options) {
|
|
1526
|
+
return instrumented("reload", { ...options }, async () => {
|
|
1527
|
+
await holder.page.reload(options);
|
|
1528
1528
|
});
|
|
1529
1529
|
},
|
|
1530
1530
|
locator: (css) => makeLocator(backend, strategy, { steps: [{ m: "locator", args: [css] }] }),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type CoverageAdapter } from "../coverage.js";
|
|
2
|
+
import { type KubernetesImage } from "../harness/component-images.js";
|
|
3
|
+
import type { Patch, Pod } from "./k3s-trust.js";
|
|
4
|
+
interface ImageInfo {
|
|
5
|
+
aliases: string[];
|
|
6
|
+
root: string;
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
export interface K3sCoverageDependencies {
|
|
10
|
+
image(reference: string): Promise<ImageInfo>;
|
|
11
|
+
files(namespace: string, name: string, data: Record<string, string>): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
|
|
14
|
+
export declare function k3sCoverage(images: readonly KubernetesImage[]): {
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
volume(cluster: string): {
|
|
17
|
+
source: string;
|
|
18
|
+
target: string;
|
|
19
|
+
reachExempt: boolean;
|
|
20
|
+
};
|
|
21
|
+
mutate(cluster: string, podInput: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string, deps: K3sCoverageDependencies): Promise<Patch[]>;
|
|
22
|
+
adapter(): CoverageAdapter;
|
|
23
|
+
};
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { applyCoverageAdapters, coverageAdapters } from "../coverage.js";
|
|
5
|
+
import { coverageHostDir, COVERAGE_MAX_BYTES_PER_SERVICE } from "../harness/coverage.js";
|
|
6
|
+
import { captureCoverageTarget } from "../harness/coverage-target.js";
|
|
7
|
+
import { canonicalImageReference } from "../harness/component-images.js";
|
|
8
|
+
import { WORKSPACE } from "../project-files.js";
|
|
9
|
+
const MOUNT = "/spectest/pod-coverage";
|
|
10
|
+
const ANNOTATION = "spectest.dev/coverage";
|
|
11
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
12
|
+
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
13
|
+
/** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
|
|
14
|
+
export function k3sCoverage(images) {
|
|
15
|
+
const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
|
|
16
|
+
reference: canonicalImageReference(image.reference), coverage: image.coverage,
|
|
17
|
+
}]);
|
|
18
|
+
for (const image of declarations) {
|
|
19
|
+
const adapters = coverageAdapters(image.coverage);
|
|
20
|
+
if (adapters.some(adapter => adapter.load || adapter.collect))
|
|
21
|
+
throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
|
|
22
|
+
applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
|
|
23
|
+
}
|
|
24
|
+
const metadata = new Map();
|
|
25
|
+
const service = (cluster, reference) => `${cluster}/image-${hash(reference)}`;
|
|
26
|
+
return {
|
|
27
|
+
enabled: declarations.length > 0,
|
|
28
|
+
volume(cluster) {
|
|
29
|
+
return { source: coverageHostDir(WORKSPACE, cluster), target: MOUNT, reachExempt: true };
|
|
30
|
+
},
|
|
31
|
+
async mutate(cluster, podInput, namespace, dryRun, ephemeral, uid, deps) {
|
|
32
|
+
if (!declarations.length || ephemeral)
|
|
33
|
+
return [];
|
|
34
|
+
const pod = podInput;
|
|
35
|
+
if (!pod.spec)
|
|
36
|
+
return [];
|
|
37
|
+
const existing = pod.metadata?.annotations?.[ANNOTATION];
|
|
38
|
+
if (existing) {
|
|
39
|
+
if (JSON.parse(existing).admission === uid)
|
|
40
|
+
return [];
|
|
41
|
+
throw new Error("remove inherited spectest.dev/coverage annotations before creating another Pod");
|
|
42
|
+
}
|
|
43
|
+
const patch = [];
|
|
44
|
+
const volumes = [...(pod.spec.volumes ?? [])];
|
|
45
|
+
const instances = [];
|
|
46
|
+
const namedVolume = (stem, volume) => {
|
|
47
|
+
let name = stem;
|
|
48
|
+
for (let i = 1; volumes.some(v => v.name === name); i++)
|
|
49
|
+
name = `${stem}-${i}`;
|
|
50
|
+
volumes.push({ name, ...volume });
|
|
51
|
+
return name;
|
|
52
|
+
};
|
|
53
|
+
for (const group of ["initContainers", "containers"]) {
|
|
54
|
+
for (const [index, container] of (pod.spec[group] ?? []).entries()) {
|
|
55
|
+
const requested = canonicalImageReference(container.image);
|
|
56
|
+
let declaration = declarations.find(image => image.reference === requested);
|
|
57
|
+
// Tangent deploys the prepared image by digest. Resolve aliases from
|
|
58
|
+
// CRI's actual image record, never by stripping a repository tag.
|
|
59
|
+
if (!declaration && requested.includes("@")) {
|
|
60
|
+
for (const candidate of declarations) {
|
|
61
|
+
let info = metadata.get(candidate.reference);
|
|
62
|
+
if (!info)
|
|
63
|
+
metadata.set(candidate.reference, info = await deps.image(candidate.reference));
|
|
64
|
+
if (info.aliases.includes(requested)) {
|
|
65
|
+
declaration = candidate;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!declaration)
|
|
71
|
+
continue;
|
|
72
|
+
let info = metadata.get(declaration.reference);
|
|
73
|
+
if (!info)
|
|
74
|
+
metadata.set(declaration.reference, info = await deps.image(declaration.reference));
|
|
75
|
+
const id = hash(`${uid}/${group}/${container.name}`);
|
|
76
|
+
const name = service(cluster, declaration.reference);
|
|
77
|
+
const env = { ...info.env };
|
|
78
|
+
for (const entry of container.env ?? [])
|
|
79
|
+
if (entry.value !== undefined)
|
|
80
|
+
env[entry.name] = entry.value;
|
|
81
|
+
const configured = applyCoverageAdapters(name, { image: { type: "registry", reference: container.image }, env, coverage: declaration.coverage });
|
|
82
|
+
if (Object.keys(configured).some(key => !["image", "env", "coverage", "files"].includes(key))) {
|
|
83
|
+
throw new Error("Kubernetes coverage adapters may inject env and files only; configure the workload's command and mounts in its manifest");
|
|
84
|
+
}
|
|
85
|
+
const changed = Object.entries(configured.env ?? {}).filter(([key, value]) => env[key] !== value);
|
|
86
|
+
for (const [key] of changed)
|
|
87
|
+
if (container.env?.some(e => e.name === key && e.valueFrom))
|
|
88
|
+
throw new Error(`cannot inject coverage into dynamic ${key}`);
|
|
89
|
+
const mounts = [...(container.volumeMounts ?? [])];
|
|
90
|
+
const addMount = (mountPath, mount) => {
|
|
91
|
+
if (mounts.some(m => mountPath === m.mountPath || mountPath.startsWith(m.mountPath.replace(/\/$/, "") + "/")))
|
|
92
|
+
throw new Error(`coverage mount conflicts with ${mountPath}`);
|
|
93
|
+
mounts.push({ mountPath, ...mount });
|
|
94
|
+
};
|
|
95
|
+
const volume = namedVolume(`spectest-cov-${id}`, { hostPath: { path: `${MOUNT}/.pods/${id}`, type: "Directory" } });
|
|
96
|
+
addMount("/spectest/coverage", { name: volume });
|
|
97
|
+
const files = configured.files ?? [];
|
|
98
|
+
if (files.some(file => file.user !== undefined || file.group !== undefined || (file.mode !== undefined && !["444", "0444", "644", "0644"].includes(file.mode)))) {
|
|
99
|
+
throw new Error("Kubernetes coverage hook files must be world-readable and need no custom ownership");
|
|
100
|
+
}
|
|
101
|
+
if (files.length) {
|
|
102
|
+
const data = Object.fromEntries(files.map((file, index) => [`file${index}`, file.content]));
|
|
103
|
+
const configMap = `spectest-coverage-${hash(JSON.stringify(data))}`;
|
|
104
|
+
if (!dryRun)
|
|
105
|
+
await deps.files(namespace, configMap, data);
|
|
106
|
+
const fileVolume = namedVolume(`spectest-hooks-${id}`, { configMap: { name: configMap, defaultMode: 0o444 } });
|
|
107
|
+
files.forEach((file, index) => addMount(file.path, { name: fileVolume, subPath: `file${index}`, readOnly: true }));
|
|
108
|
+
}
|
|
109
|
+
const instance = { id, image: declaration.reference, namespace, container: container.name, root: container.workingDir || info.root || "/" };
|
|
110
|
+
if (!dryRun) {
|
|
111
|
+
const dir = path.join(coverageHostDir(WORKSPACE, cluster), ".pods", id);
|
|
112
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o777 });
|
|
113
|
+
await fs.chmod(dir, 0o777);
|
|
114
|
+
await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify(instance));
|
|
115
|
+
}
|
|
116
|
+
instances.push(instance);
|
|
117
|
+
patch.push({ op: "add", path: `/spec/${group}/${index}/env`, value: [
|
|
118
|
+
...(container.env ?? []).filter(e => !changed.some(([key]) => key === e.name)),
|
|
119
|
+
...changed.map(([name, value]) => ({ name, value })),
|
|
120
|
+
] }, { op: "add", path: `/spec/${group}/${index}/volumeMounts`, value: mounts });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!instances.length)
|
|
124
|
+
return [];
|
|
125
|
+
patch.push({ op: "add", path: "/spec/volumes", value: volumes });
|
|
126
|
+
patch.push({ op: "add", path: "/metadata/annotations", value: { ...pod.metadata?.annotations,
|
|
127
|
+
[ANNOTATION]: JSON.stringify({ admission: uid, instances: instances.map(i => i.id) }),
|
|
128
|
+
} });
|
|
129
|
+
return patch;
|
|
130
|
+
},
|
|
131
|
+
adapter() {
|
|
132
|
+
return {
|
|
133
|
+
name: "k3s-containers",
|
|
134
|
+
// Include nested policy AND injected hooks in the warm identity. An
|
|
135
|
+
// adapter's functions would otherwise disappear from JSON.stringify.
|
|
136
|
+
toJSON() {
|
|
137
|
+
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
|
+
},
|
|
142
|
+
async collect(ctx, baseline) {
|
|
143
|
+
const root = path.join(ctx.reportDir, ".pods");
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = await fs.readdir(root);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.code !== "ENOENT")
|
|
150
|
+
throw error;
|
|
151
|
+
entries = [];
|
|
152
|
+
}
|
|
153
|
+
// Native adapters flush through the shared Unix sockets, including
|
|
154
|
+
// after Pod deletion. Only inventory or command adapters need the API.
|
|
155
|
+
let pods;
|
|
156
|
+
const findPod = async (id) => {
|
|
157
|
+
pods ??= ctx.exec("kubectl get pods -A -o json --request-timeout=10s")
|
|
158
|
+
.then(result => JSON.parse(result.stdout).items);
|
|
159
|
+
return (await pods).find(p => {
|
|
160
|
+
const ids = p.metadata?.annotations?.[ANNOTATION];
|
|
161
|
+
return ids && JSON.parse(ids).instances.includes(id);
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
const captures = new Map();
|
|
165
|
+
const inventories = new Map();
|
|
166
|
+
for (const declaration of declarations)
|
|
167
|
+
captures.set(declaration.reference, { service: service(ctx.service, declaration.reference), reports: [] });
|
|
168
|
+
const collectInstance = async (id) => {
|
|
169
|
+
ctx.signal.throwIfAborted();
|
|
170
|
+
if (!/^[a-f0-9]{16}$/.test(id))
|
|
171
|
+
throw new Error("invalid coverage instance directory");
|
|
172
|
+
const dir = path.join(root, id);
|
|
173
|
+
const instance = JSON.parse(await fs.readFile(path.join(dir, ".instance.json"), "utf8"));
|
|
174
|
+
const declaration = declarations.find(d => d.reference === instance.image);
|
|
175
|
+
if (!declaration)
|
|
176
|
+
throw new Error(`coverage image no longer declared: ${instance.image}`);
|
|
177
|
+
const adapters = coverageAdapters(declaration.coverage);
|
|
178
|
+
const livePod = async () => {
|
|
179
|
+
const pod = await findPod(id);
|
|
180
|
+
const statuses = [...(pod?.status?.containerStatuses ?? []), ...(pod?.status?.initContainerStatuses ?? [])];
|
|
181
|
+
return statuses.some(status => status.name === instance.container && status.state?.running) ? pod : undefined;
|
|
182
|
+
};
|
|
183
|
+
for (const adapter of adapters) {
|
|
184
|
+
if (adapter.name === "node") {
|
|
185
|
+
try {
|
|
186
|
+
await fs.access(path.join(dir, `.attached-${adapter.name}`));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new Error(`coverage hook never attached to ${instance.namespace}/${instance.container} (${adapter.name})`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const captured = await captureCoverageTarget({
|
|
194
|
+
coverage: declaration.coverage, root: instance.root, baseline,
|
|
195
|
+
inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
|
|
196
|
+
counterKey: `${service(ctx.service, instance.image)}/${id}`,
|
|
197
|
+
context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
|
|
198
|
+
exec: async (command) => {
|
|
199
|
+
const pod = await livePod();
|
|
200
|
+
if (!pod?.metadata?.name)
|
|
201
|
+
throw new Error(`coverage command requires live container ${instance.container}`);
|
|
202
|
+
return ctx.exec(`kubectl exec -n ${quote(instance.namespace)} ${quote(pod.metadata.name)} -c ${quote(instance.container)} -- sh -c ${quote(command)}`);
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
return { id, instance, captured };
|
|
207
|
+
};
|
|
208
|
+
// Bound simultaneous LLVM exports and API execs. Drain all workers
|
|
209
|
+
// even on error so no collector can race the following VM snapshot.
|
|
210
|
+
const pending = entries.sort();
|
|
211
|
+
const results = new Map();
|
|
212
|
+
let cursor = 0;
|
|
213
|
+
const workers = await Promise.allSettled(Array.from({ length: Math.min(8, pending.length) }, async () => {
|
|
214
|
+
while (cursor < pending.length) {
|
|
215
|
+
const id = pending[cursor++];
|
|
216
|
+
results.set(id, await collectInstance(id));
|
|
217
|
+
}
|
|
218
|
+
}));
|
|
219
|
+
const failure = workers.find(worker => worker.status === "rejected");
|
|
220
|
+
if (failure?.status === "rejected")
|
|
221
|
+
throw failure.reason;
|
|
222
|
+
for (const id of pending) {
|
|
223
|
+
const { instance, captured } = results.get(id);
|
|
224
|
+
const aggregate = captures.get(instance.image);
|
|
225
|
+
if (captured.error)
|
|
226
|
+
aggregate.error = [aggregate.error, `${instance.namespace}/${instance.container}: ${captured.error}`].filter(Boolean).join("; ");
|
|
227
|
+
if (aggregate.root && aggregate.root !== instance.root) {
|
|
228
|
+
aggregate.error = "coverage containers from one image have different working directories; use a common workingDir for this image's covered containers";
|
|
229
|
+
}
|
|
230
|
+
aggregate.root = instance.root;
|
|
231
|
+
for (const report of captured.reports) {
|
|
232
|
+
if (report.name === "inventory.units") {
|
|
233
|
+
let units = inventories.get(instance.image);
|
|
234
|
+
if (!units)
|
|
235
|
+
inventories.set(instance.image, units = new Set());
|
|
236
|
+
for (const unit of report.content.split("\n"))
|
|
237
|
+
if (unit)
|
|
238
|
+
units.add(unit);
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
aggregate.reports.push({ ...report, name: `${id}/${report.name}` });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const [image, units] of inventories)
|
|
245
|
+
captures.get(image).reports.push({ name: "inventory.units", content: [...units].sort().join("\n") });
|
|
246
|
+
for (const capture of captures.values()) {
|
|
247
|
+
if (!capture.reports.length && !capture.error)
|
|
248
|
+
capture.reports.push({ name: "empty.lcov", content: "TN:\n" });
|
|
249
|
+
if (capture.reports.reduce((bytes, report) => bytes + Buffer.byteLength(report.content), 0) > COVERAGE_MAX_BYTES_PER_SERVICE) {
|
|
250
|
+
capture.reports = [];
|
|
251
|
+
capture.error = "Kubernetes image coverage exceeds the per-service report size limit";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [...captures.values()];
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -12,7 +12,7 @@ interface Container {
|
|
|
12
12
|
readOnly?: boolean;
|
|
13
13
|
}[];
|
|
14
14
|
}
|
|
15
|
-
interface Pod {
|
|
15
|
+
export interface Pod {
|
|
16
16
|
metadata?: {
|
|
17
17
|
annotations?: Record<string, string>;
|
|
18
18
|
};
|
|
@@ -32,7 +32,7 @@ interface Pod {
|
|
|
32
32
|
ephemeralContainers?: Container[];
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
-
interface Patch {
|
|
35
|
+
export interface Patch {
|
|
36
36
|
op: "add";
|
|
37
37
|
path: string;
|
|
38
38
|
value: unknown;
|
|
@@ -43,6 +43,8 @@ export interface TrustDependencies {
|
|
|
43
43
|
/** Ensure this immutable ConfigMap exists in the requesting namespace. */
|
|
44
44
|
ensureBundle(namespace: string, name: string, bundle: string): Promise<void>;
|
|
45
45
|
apply(configuration: unknown): Promise<void>;
|
|
46
|
+
/** Component-owned pod instrumentation runs after trust patching. */
|
|
47
|
+
mutatePod?(pod: Pod, namespace: string, dryRun: boolean, ephemeral: boolean, uid: string): Promise<Patch[]>;
|
|
46
48
|
}
|
|
47
49
|
export declare function installPodTrust(options: {
|
|
48
50
|
name: string;
|
|
@@ -75,6 +75,19 @@ export async function installPodTrust(options, deps) {
|
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
77
|
const patch = podTrustPatch(request.object, configMap, request.subResource === "ephemeralcontainers");
|
|
78
|
+
if (deps.mutatePod) {
|
|
79
|
+
const pod = structuredClone(request.object);
|
|
80
|
+
for (const operation of patch) {
|
|
81
|
+
const keys = operation.path.slice(1).split("/");
|
|
82
|
+
const key = keys.pop();
|
|
83
|
+
const parent = keys.reduce((value, key) => value[key], pod);
|
|
84
|
+
if (key === "-")
|
|
85
|
+
parent.push(operation.value);
|
|
86
|
+
else
|
|
87
|
+
parent[key] = operation.value;
|
|
88
|
+
}
|
|
89
|
+
patch.push(...await deps.mutatePod(pod, request.namespace, !!request.dryRun, request.subResource === "ephemeralcontainers", request.uid));
|
|
90
|
+
}
|
|
78
91
|
// Dry runs return the same patch but perform no API writes.
|
|
79
92
|
if (patch.length && !request.dryRun)
|
|
80
93
|
await deps.ensureBundle(request.namespace, configMap, options.bundle);
|
package/dist/components/k3s.js
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";
|
|
@@ -1335,6 +1336,7 @@ export function k3s(opts = {}) {
|
|
|
1335
1336
|
function createK3sCluster(opts, count) {
|
|
1336
1337
|
const version = opts.version ?? DEFAULT_K3S_VERSION;
|
|
1337
1338
|
const images = kubernetesImages(opts.images ?? []);
|
|
1339
|
+
const coverage = k3sCoverage(opts.images ?? []);
|
|
1338
1340
|
const extra = opts.extraArgs ?? [];
|
|
1339
1341
|
assertUsableExtraArgs(extra);
|
|
1340
1342
|
const registryEnabled = opts.registry !== false;
|
|
@@ -1497,6 +1499,7 @@ function createK3sCluster(opts, count) {
|
|
|
1497
1499
|
volumes: [{ name: `spectest-k3s-${cluster}-join`, target: JOIN_DIRECTORY }, ...(sticky
|
|
1498
1500
|
? [{ source: `${stickyKubernetesDirectory(SELF_SERVICE_TOKEN, version)}/containerd`, target: CLUSTER_STORE_TARGET }]
|
|
1499
1501
|
: [{ target: CLUSTER_STORE_TARGET }]),
|
|
1502
|
+
...(coverage.enabled ? [coverage.volume(cluster)] : []),
|
|
1500
1503
|
...(server && publishBuilds.length ? [
|
|
1501
1504
|
{ source: registryDirectory(SELF_SERVICE_TOKEN), target: REGISTRY_DATA },
|
|
1502
1505
|
{ source: contentDirectory, target: REGISTRY_CONTENT, readOnly: true },
|
|
@@ -1505,6 +1508,7 @@ function createK3sCluster(opts, count) {
|
|
|
1505
1508
|
files: [
|
|
1506
1509
|
...(registriesYaml ? [{ path: "/etc/rancher/k3s/registries.yaml", content: registriesYaml }] : []),
|
|
1507
1510
|
],
|
|
1511
|
+
...(server && coverage.enabled ? { coverage: { adapters: [coverage.adapter()] } } : {}),
|
|
1508
1512
|
// Agents start before the server and wait for its join token. The group
|
|
1509
1513
|
// setup below gates readiness on every actual Kubernetes Node, including
|
|
1510
1514
|
// the server, rather than treating container startup as cluster readiness.
|
|
@@ -1533,11 +1537,38 @@ function createK3sCluster(opts, count) {
|
|
|
1533
1537
|
if (images.builds.length && !runtime)
|
|
1534
1538
|
throw new Error(`k3s(${name}): prepared image inputs are missing`);
|
|
1535
1539
|
const admission = (async () => {
|
|
1540
|
+
if (coverage.enabled && (!runtime?.caBundle || !caPresent()))
|
|
1541
|
+
throw new Error(`k3s(${name}): coverage injection requires the environment CA`);
|
|
1536
1542
|
if (runtime?.caBundle && caPresent()) {
|
|
1537
1543
|
const { cert, key } = await issueIngressCert([runtime.gateway]);
|
|
1538
1544
|
await installPodTrust({ name, gateway: runtime.gateway, cert, key,
|
|
1539
1545
|
ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
|
|
1540
1546
|
}, {
|
|
1547
|
+
mutatePod: (pod, namespace, dryRun, ephemeral, uid) => coverage.mutate(name, pod, namespace, dryRun, ephemeral, uid, {
|
|
1548
|
+
async image(reference) {
|
|
1549
|
+
const result = await exec(name, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference]);
|
|
1550
|
+
if (result.exitCode !== 0)
|
|
1551
|
+
throw new Error(`coverage image metadata: ${result.stderr}`);
|
|
1552
|
+
const record = JSON.parse(result.stdout);
|
|
1553
|
+
const config = record.info?.imageSpec?.config;
|
|
1554
|
+
if (!config)
|
|
1555
|
+
throw new Error(`coverage image ${reference} has no OCI configuration`);
|
|
1556
|
+
return { aliases: record.status?.repoDigests ?? [], root: config.WorkingDir || "/",
|
|
1557
|
+
env: Object.fromEntries((config.Env ?? []).map((entry) => { const split = entry.indexOf("="); return [entry.slice(0, split), entry.slice(split + 1)]; })),
|
|
1558
|
+
};
|
|
1559
|
+
},
|
|
1560
|
+
async files(namespace, name, data) {
|
|
1561
|
+
try {
|
|
1562
|
+
await helpers.client.core.createNamespacedConfigMap({ namespace, body: { metadata: { name, namespace }, immutable: true, data } });
|
|
1563
|
+
}
|
|
1564
|
+
catch (error) {
|
|
1565
|
+
const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name })).unwrap();
|
|
1566
|
+
if (!existing.immutable || Object.keys(existing.data ?? {}).length !== Object.keys(data).length ||
|
|
1567
|
+
Object.entries(data).some(([key, value]) => existing.data?.[key] !== value))
|
|
1568
|
+
throw error;
|
|
1569
|
+
}
|
|
1570
|
+
},
|
|
1571
|
+
}),
|
|
1541
1572
|
apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
|
|
1542
1573
|
ensureBundle: async (namespace, configMap, bundle) => {
|
|
1543
1574
|
try {
|