@specific.dev/spectest 0.90.2 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-coverage.d.ts +5 -5
- package/dist/browser-coverage.js +11 -14
- package/dist/components/expo.d.ts +2 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/k3s-trust.d.ts +57 -0
- package/dist/components/k3s-trust.js +125 -0
- package/dist/components/k3s.d.ts +17 -5
- package/dist/components/k3s.js +77 -11
- package/dist/coverage.d.ts +1 -1
- package/dist/coverage.js +3 -3
- package/dist/daemon.js +53 -14
- package/dist/harness/component-images.d.ts +31 -0
- package/dist/harness/component-images.js +69 -0
- package/dist/harness/k3s-image-loader.d.ts +5 -0
- package/dist/harness/k3s-image-loader.js +150 -0
- package/dist/harness/k3s-prepull.d.ts +7 -0
- package/dist/harness/k3s-prepull.js +39 -0
- package/dist/harness/sticky-consumer.d.ts +3 -3
- package/dist/harness/sticky-consumer.js +19 -4
- package/dist/harness/sticky-kubernetes.d.ts +15 -0
- package/dist/harness/sticky-kubernetes.js +163 -0
- package/dist/harness/sticky-paths.d.ts +5 -0
- package/dist/harness/sticky-paths.js +18 -0
- package/dist/index.d.ts +32 -2
- package/dist/index.js +25 -2
- package/package.json +1 -1
- package/src/browser-coverage.ts +11 -13
- package/src/components/index.ts +1 -0
- package/src/components/k3s-trust.test.ts +81 -0
- package/src/components/k3s-trust.ts +135 -0
- package/src/components/k3s.ts +81 -10
- package/src/coverage.test.ts +50 -0
- package/src/coverage.ts +3 -3
- package/src/daemon.ts +56 -15
- package/src/harness/component-images.test.ts +44 -0
- package/src/harness/component-images.ts +73 -0
- package/src/harness/k3s-image-loader.ts +129 -0
- package/src/harness/k3s-prepull.test.ts +47 -0
- package/src/harness/k3s-prepull.ts +36 -0
- package/src/harness/sticky-consumer.test.ts +28 -0
- package/src/harness/sticky-consumer.ts +14 -3
- package/src/harness/sticky-kubernetes.test.ts +88 -0
- package/src/harness/sticky-kubernetes.ts +165 -0
- package/src/harness/sticky-paths.ts +18 -0
- package/src/index.ts +48 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** A short-lived, read-only OCI endpoint over the host containerd content store.
|
|
2
|
+
* k3s fetches only missing blobs and unpacks only missing snapshots. No Docker
|
|
3
|
+
* archive, staging registry, or second persistent copy of application blobs. */
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import type { SpectestContext } from "../index.js";
|
|
9
|
+
import { canonicalImageReference } from "./component-images.js";
|
|
10
|
+
import { waitForK3sRuntime } from "./k3s-prepull.js";
|
|
11
|
+
|
|
12
|
+
interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
|
|
13
|
+
interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
|
|
14
|
+
const digest = (bytes: Uint8Array) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
15
|
+
const descriptor = (bytes: Uint8Array, mediaType: string): Descriptor => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
|
|
18
|
+
|
|
19
|
+
async function hostContent(id: string): Promise<Buffer> {
|
|
20
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id)) throw new Error(`invalid content digest ${id}`);
|
|
21
|
+
const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
|
|
22
|
+
if (digest(result.stdout) !== id) throw new Error(`corrupt image metadata ${id}`);
|
|
23
|
+
return result.stdout;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Preserve the original index/manifest bytes and all application descriptors. */
|
|
27
|
+
async function hostImage(source: string) {
|
|
28
|
+
const name = canonicalImageReference(source);
|
|
29
|
+
const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
|
|
30
|
+
const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
|
|
31
|
+
if (!found) throw new Error(`built image ${name} is missing from the VM container store`);
|
|
32
|
+
const metadata = new Map<string, Buffer>();
|
|
33
|
+
const allowed = new Map<string, Descriptor>();
|
|
34
|
+
let configDigest: string | undefined;
|
|
35
|
+
async function visit(id: string, selected: boolean, depth = 0): Promise<Descriptor> {
|
|
36
|
+
if (depth > 4) throw new Error(`built image ${name} has a recursive manifest index`);
|
|
37
|
+
const bytes = await hostContent(id);
|
|
38
|
+
const obj = JSON.parse(bytes.toString());
|
|
39
|
+
const desc = descriptor(bytes, obj.mediaType);
|
|
40
|
+
metadata.set(id, bytes);
|
|
41
|
+
allowed.set(id, desc);
|
|
42
|
+
if (obj.config && Array.isArray(obj.layers)) {
|
|
43
|
+
const manifest = obj as Manifest;
|
|
44
|
+
for (const blob of [manifest.config, ...manifest.layers]) allowed.set(blob.digest, blob);
|
|
45
|
+
if (selected) configDigest = manifest.config.digest;
|
|
46
|
+
} else if (Array.isArray(obj.manifests)) {
|
|
47
|
+
for (const entry of obj.manifests as Descriptor[]) {
|
|
48
|
+
const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
|
|
49
|
+
await visit(entry.digest, selected && matches, depth + 1);
|
|
50
|
+
}
|
|
51
|
+
} else throw new Error(`built image ${name} has invalid image metadata`);
|
|
52
|
+
return desc;
|
|
53
|
+
}
|
|
54
|
+
const target = await visit(found, true);
|
|
55
|
+
if (!configDigest) throw new Error(`built image ${name} has no image for this VM's platform`);
|
|
56
|
+
return { target, metadata, allowed, configDigest };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function loadK3sBuildImages(
|
|
60
|
+
service: string, images: readonly { source: string; reference: string }[],
|
|
61
|
+
gateway: string, exec: SpectestContext["exec"],
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
if (!images.length) return;
|
|
64
|
+
await waitForK3sRuntime(service, exec);
|
|
65
|
+
const checked = async (args: string[], timeoutMs = 30_000) => {
|
|
66
|
+
const result = await exec(service, args, { timeoutMs });
|
|
67
|
+
if (result.exitCode !== 0) throw new Error(`k3s(${service}): image load failed: ${result.stderr || result.stdout}`);
|
|
68
|
+
return result.stdout;
|
|
69
|
+
};
|
|
70
|
+
const config = await checked(["cat", "/var/lib/rancher/k3s/agent/etc/containerd/config.toml"]);
|
|
71
|
+
const snapshotter = config.match(/^\s*snapshotter\s*=\s*['"]([^'"]+)['"]/m)?.[1];
|
|
72
|
+
if (!snapshotter) throw new Error(`k3s(${service}): cannot determine kubelet's snapshotter`);
|
|
73
|
+
const ctr = ["ctr", "--address", "/run/k3s/containerd/containerd.sock", "--namespace", "k8s.io"];
|
|
74
|
+
const help = await checked([...ctr, "images", "pull", "--help"]);
|
|
75
|
+
const local = help.includes("--local") ? ["--local"] : [];
|
|
76
|
+
for (const image of images) {
|
|
77
|
+
const reference = canonicalImageReference(image.reference);
|
|
78
|
+
const { target, metadata: blobs, allowed, configDigest } = await hostImage(image.source);
|
|
79
|
+
const streams = new Set<ReturnType<typeof spawn>>();
|
|
80
|
+
let transferred = 0;
|
|
81
|
+
const server = createServer((req, res) => {
|
|
82
|
+
const pathname = new URL(req.url ?? "/", "http://local").pathname;
|
|
83
|
+
const match = pathname.match(/^\/v2\/.+\/(manifests|blobs)\/([^/]+)$/);
|
|
84
|
+
if (!["GET", "HEAD"].includes(req.method ?? "") || !match) { res.writeHead(404).end(); return; }
|
|
85
|
+
const id = match[1] === "manifests" && !match[2].startsWith("sha256:") ? target.digest : match[2];
|
|
86
|
+
const desc = allowed.get(id);
|
|
87
|
+
if (!desc) { res.writeHead(404).end(); return; }
|
|
88
|
+
res.writeHead(200, { "Content-Type": desc.mediaType, "Content-Length": desc.size, "Docker-Content-Digest": id });
|
|
89
|
+
if (req.method === "HEAD") { res.end(); return; }
|
|
90
|
+
transferred += desc.size;
|
|
91
|
+
const bytes = blobs.get(id);
|
|
92
|
+
if (bytes) { res.end(bytes); return; }
|
|
93
|
+
const proc = spawn("ctr", [...HOST_CTR, "content", "get", id], { stdio: ["ignore", "pipe", "ignore"] });
|
|
94
|
+
streams.add(proc);
|
|
95
|
+
proc.stdout!.pipe(res);
|
|
96
|
+
proc.on("error", err => res.destroy(err));
|
|
97
|
+
proc.on("exit", code => { streams.delete(proc); if (code !== 0) res.destroy(new Error(`content read failed: ${id}`)); });
|
|
98
|
+
res.on("close", () => { if (proc.exitCode === null) proc.kill(); });
|
|
99
|
+
});
|
|
100
|
+
const directory = `/run/spectest-image-load-${createHash("sha256").update(reference).digest("hex").slice(0, 16)}`;
|
|
101
|
+
try {
|
|
102
|
+
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(0, gateway, resolve); });
|
|
103
|
+
const port = (server.address() as { port: number }).port;
|
|
104
|
+
// A private hosts directory scopes this override to this single ctr call;
|
|
105
|
+
// it cannot change the cluster's configured registries or pull policy.
|
|
106
|
+
const hosts = `server = "http://${gateway}:${port}"\n`;
|
|
107
|
+
await checked(["sh", "-c", 'mkdir -p "$1/_default"; printf "%s" "$2" > "$1/_default/hosts.toml"', "sh", directory, hosts]);
|
|
108
|
+
await checked([...ctr, "images", "pull", ...local, "--hosts-dir", directory,
|
|
109
|
+
"--snapshotter", snapshotter, "--label", "io.cri-containerd.image=managed", reference], 600_000);
|
|
110
|
+
const repo = reference.slice(0, reference.lastIndexOf(":"));
|
|
111
|
+
await checked([...ctr, "images", "tag", "--force", reference, `${repo}@${target.digest}`]);
|
|
112
|
+
// CRI learns about ctr image updates asynchronously. Confirm the current
|
|
113
|
+
// build's config, not merely that an older tag already exists.
|
|
114
|
+
const deadline = Date.now() + 30_000;
|
|
115
|
+
for (;;) {
|
|
116
|
+
const result = await exec(service, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference], { timeoutMs: 5_000 });
|
|
117
|
+
if (result.exitCode === 0 && JSON.parse(result.stdout).status?.id === configDigest) break;
|
|
118
|
+
if (Date.now() >= deadline) throw new Error(`k3s(${service}): current build is not visible to kubelet: ${reference}`);
|
|
119
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
120
|
+
}
|
|
121
|
+
console.log(`[k3s-images] ${service}: ${reference} @ ${target.digest}, transferred ${transferred} bytes`);
|
|
122
|
+
} finally {
|
|
123
|
+
for (const proc of streams) proc.kill();
|
|
124
|
+
server.closeAllConnections();
|
|
125
|
+
await new Promise<void>(resolve => server.close(() => resolve()));
|
|
126
|
+
await exec(service, ["rm", "-rf", directory], { timeoutMs: 5_000 }).catch(() => undefined);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { SpectestContext } from "../index.js";
|
|
3
|
+
import { prePullK3sImages } from "./k3s-prepull";
|
|
4
|
+
|
|
5
|
+
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
6
|
+
const digest = `registry.test/app@sha256:${"a".repeat(64)}`;
|
|
7
|
+
const mockExec = (fn: (args: string[]) => Promise<{ exitCode: number; stdout?: string; stderr?: string }>) =>
|
|
8
|
+
(async (_name: string, args: string[]) => ({ stdout: "", stderr: "", ...await fn(args) })) as SpectestContext["exec"];
|
|
9
|
+
|
|
10
|
+
test("pre-pulls reuse immutable cached images, but resolve tags on every fresh setup", async () => {
|
|
11
|
+
const calls: string[][] = [];
|
|
12
|
+
await prePullK3sImages("cluster", [digest, "registry.test/app:latest"], mockExec(async args => {
|
|
13
|
+
calls.push(args); return { exitCode: 0 };
|
|
14
|
+
}));
|
|
15
|
+
expect(calls).toEqual([[...cri, "info"], [...cri, "inspecti", digest], [...cri, "pull", "registry.test/app:latest"]]);
|
|
16
|
+
calls.length = 0;
|
|
17
|
+
await prePullK3sImages("cluster", [digest], mockExec(async args => {
|
|
18
|
+
calls.push(args); return { exitCode: args[3] === "inspecti" ? 1 : 0 };
|
|
19
|
+
}));
|
|
20
|
+
expect(calls.at(-1)).toEqual([...cri, "pull", digest]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("failed pre-pulls fail setup after outstanding pulls finish, with bounded concurrency", async () => {
|
|
24
|
+
let active = 0, peak = 0, finished = 0;
|
|
25
|
+
const work = prePullK3sImages("cluster", ["a:1", "b:1", "c:1"], mockExec(async args => {
|
|
26
|
+
if (args.at(-1) === "info") return { exitCode: 0 };
|
|
27
|
+
peak = Math.max(peak, ++active);
|
|
28
|
+
await Bun.sleep(args.at(-1) === "a:1" ? 1 : 20);
|
|
29
|
+
--active; ++finished;
|
|
30
|
+
return { exitCode: args.at(-1) === "a:1" ? 1 : 0, stderr: "registry unavailable" };
|
|
31
|
+
}));
|
|
32
|
+
await expect(work).rejects.toThrow("pre-pull failed for a:1: registry unavailable");
|
|
33
|
+
expect(active).toBe(0);
|
|
34
|
+
expect(peak).toBe(2);
|
|
35
|
+
expect(finished).toBe(3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("pre-pulls wait for the runtime even when the API server is already ready", async () => {
|
|
39
|
+
const calls: string[] = [];
|
|
40
|
+
let ready = false;
|
|
41
|
+
await prePullK3sImages("cluster", ["app:1"], mockExec(async args => {
|
|
42
|
+
calls.push(args[3]!);
|
|
43
|
+
if (args[3] === "info" && !ready) { ready = true; return { exitCode: 1, stderr: "connection refused" }; }
|
|
44
|
+
return { exitCode: 0 };
|
|
45
|
+
}));
|
|
46
|
+
expect(calls).toEqual(["info", "info", "pull"]);
|
|
47
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SpectestContext } from "../index.js";
|
|
2
|
+
|
|
3
|
+
/** CRI PullImage waits for unpacking and uses k3s's registry configuration.
|
|
4
|
+
* Always resolve tags on a fresh setup. Immutable digests can be used offline.
|
|
5
|
+
* Drain both workers on failure so no pulls outlive the setup hook.
|
|
6
|
+
*/
|
|
7
|
+
export async function prePullK3sImages(name: string, images: readonly string[], exec: SpectestContext["exec"]): Promise<void> {
|
|
8
|
+
if (!images.length) return;
|
|
9
|
+
await waitForK3sRuntime(name, exec);
|
|
10
|
+
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
11
|
+
const queue = [...images];
|
|
12
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(2, queue.length) }, async () => {
|
|
13
|
+
for (let image = queue.shift(); image !== undefined; image = queue.shift()) {
|
|
14
|
+
if (/@sha256:[a-f0-9]{64}$/.test(image)) {
|
|
15
|
+
const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
|
|
16
|
+
if (existing.exitCode === 0) continue;
|
|
17
|
+
}
|
|
18
|
+
const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
|
|
19
|
+
if (result.exitCode !== 0) throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
|
|
20
|
+
}
|
|
21
|
+
}));
|
|
22
|
+
const failed = results.find(result => result.status === "rejected");
|
|
23
|
+
if (failed?.status === "rejected") throw failed.reason;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function waitForK3sRuntime(name: string, exec: SpectestContext["exec"]): Promise<void> {
|
|
27
|
+
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
28
|
+
// /readyz belongs to the API server and can precede the node runtime.
|
|
29
|
+
const deadline = Date.now() + 60_000;
|
|
30
|
+
for (;;) {
|
|
31
|
+
const result = await exec(name, [...cri, "info"], { timeoutMs: 5_000 });
|
|
32
|
+
if (result.exitCode === 0) break;
|
|
33
|
+
if (Date.now() >= deadline) throw new Error(`k3s(${name}): containerd did not become ready for pre-pulls: ${result.stderr || result.stdout}`);
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -43,3 +43,31 @@ test("healthy cache keeps recent images and a cache that cannot shrink is not pu
|
|
|
43
43
|
await expect(collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 5 * GiB })))
|
|
44
44
|
.rejects.toThrow("cannot meet filesystem retention target");
|
|
45
45
|
});
|
|
46
|
+
|
|
47
|
+
test("nested Kubernetes stores share the filesystem budget and are pruned before recent Docker images", async () => {
|
|
48
|
+
let used = 100 * GiB;
|
|
49
|
+
const calls: string[][] = [];
|
|
50
|
+
let evictions = 0;
|
|
51
|
+
const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
|
|
52
|
+
await collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }), {
|
|
53
|
+
protectedImages: new Set(), close: async () => {},
|
|
54
|
+
pruneOldest: async () => { used -= 40 * GiB; evictions++; return true; },
|
|
55
|
+
});
|
|
56
|
+
expect(evictions).toBe(1);
|
|
57
|
+
expect(calls.some(c => c[0] === "docker" && c[1] === "image" && c[2] === "ls")).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("GC does not remove an image while it supplies the Kubernetes maintenance daemon", async () => {
|
|
61
|
+
const removed: string[] = [];
|
|
62
|
+
const run = async (command: string, args: string[]) => {
|
|
63
|
+
if (command === "docker" && args[1] === "ls") return "runtime\nold";
|
|
64
|
+
if (command === "docker" && args[1] === "inspect") return JSON.stringify([
|
|
65
|
+
{ Id: "runtime", Created: "2020" }, { Id: "old", Created: "2021" }]);
|
|
66
|
+
if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
|
|
67
|
+
return "";
|
|
68
|
+
};
|
|
69
|
+
await collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: (removed.length ? 220 : 150) * GiB }), {
|
|
70
|
+
protectedImages: new Set(["runtime"]), close: async () => {}, pruneOldest: async () => false,
|
|
71
|
+
});
|
|
72
|
+
expect(removed).toEqual(["old"]);
|
|
73
|
+
});
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
/** Docker
|
|
1
|
+
/** Docker, BuildKit and nested Kubernetes policy for the generic sticky disk. */
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
|
|
4
4
|
|
|
5
|
+
import { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
6
|
+
import { openKubernetesCacheMaintenance, type KubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
7
|
+
|
|
5
8
|
export const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
6
9
|
export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
7
|
-
export
|
|
10
|
+
export { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
8
11
|
export interface StickyConsumerConfig {
|
|
9
12
|
path: string;
|
|
10
13
|
capacityBytes: number;
|
|
@@ -115,6 +118,7 @@ export async function collectStickyCache(
|
|
|
115
118
|
const fs = await statfs(config.path);
|
|
116
119
|
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
|
|
117
120
|
},
|
|
121
|
+
kubernetes?: KubernetesCacheMaintenance,
|
|
118
122
|
): Promise<void> {
|
|
119
123
|
validateStickyConfig(config);
|
|
120
124
|
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
@@ -133,6 +137,10 @@ export async function collectStickyCache(
|
|
|
133
137
|
};
|
|
134
138
|
await pruneBuildkit();
|
|
135
139
|
if (withinTarget(fs)) return;
|
|
140
|
+
while (await kubernetes?.pruneOldest()) {
|
|
141
|
+
fs = await usage();
|
|
142
|
+
if (withinTarget(fs)) return;
|
|
143
|
+
}
|
|
136
144
|
// Pressure can be entirely in recent Docker images. Drop oldest images
|
|
137
145
|
// first, even within the age window, until the whole filesystem fits.
|
|
138
146
|
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
|
|
@@ -140,6 +148,7 @@ export async function collectStickyCache(
|
|
|
140
148
|
? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
|
|
141
149
|
images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
|
|
142
150
|
for (const image of images) {
|
|
151
|
+
if (kubernetes?.protectedImages.has(image.Id)) continue;
|
|
143
152
|
await run("docker", ["image", "rm", "-f", image.Id], 120_000);
|
|
144
153
|
await pruneBuildkit();
|
|
145
154
|
if (withinTarget(fs)) return;
|
|
@@ -158,7 +167,9 @@ export async function prepareStickyConsumer(artifact = false): Promise<{ prepare
|
|
|
158
167
|
if (containers.length) await exec("docker", ["rm", "-f", ...containers]);
|
|
159
168
|
const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
|
|
160
169
|
if (volumes.length) await exec("docker", ["volume", "rm", ...volumes]);
|
|
161
|
-
await
|
|
170
|
+
const kubernetes = await openKubernetesCacheMaintenance(exec);
|
|
171
|
+
try { await collectStickyCache(config, exec, undefined, kubernetes); }
|
|
172
|
+
finally { await kubernetes.close(); }
|
|
162
173
|
}
|
|
163
174
|
const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
|
|
164
175
|
for (const unit of units) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { openKubernetesCacheMaintenance, stickyKubernetesDirectory, type CacheCommand } from "./sticky-kubernetes";
|
|
6
|
+
import { isOnStickyDisk, stickyDiskPathSync, STICKY_ROOT } from "./sticky-paths";
|
|
7
|
+
|
|
8
|
+
const roots: string[] = [];
|
|
9
|
+
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
|
10
|
+
async function temporary() { const root = await mkdtemp(join(tmpdir(), "sticky-k8s-test-")); roots.push(root); return root; }
|
|
11
|
+
const image = `sha256:${"a".repeat(64)}`;
|
|
12
|
+
async function store(root: string, service: string, age = 0) {
|
|
13
|
+
const dir = `${root}/${service}/0123456789abcdef`;
|
|
14
|
+
await mkdir(dir, { recursive: true });
|
|
15
|
+
await writeFile(`${dir}/store.json`, JSON.stringify({ schema: 1, image, updatedAt: Date.now() - age }));
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
function runner(calls: string[][]): CacheCommand {
|
|
19
|
+
return async (program, args) => {
|
|
20
|
+
calls.push([program, ...args]);
|
|
21
|
+
if (program === "docker" && args[0] === "inspect") return JSON.stringify({ ExitCode: 0, OOMKilled: false });
|
|
22
|
+
return "";
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("sticky volume preservation requires an active manifest and a path inside the mount", async () => {
|
|
27
|
+
const manifest = `${await temporary()}/consumer.json`;
|
|
28
|
+
expect(stickyDiskPathSync(manifest)).toBeNull();
|
|
29
|
+
await writeFile(manifest, JSON.stringify({ path: STICKY_ROOT }));
|
|
30
|
+
expect(stickyDiskPathSync(manifest)).toBe(STICKY_ROOT);
|
|
31
|
+
expect(isOnStickyDisk(`${STICKY_ROOT}/kubernetes/cluster`, STICKY_ROOT)).toBe(true);
|
|
32
|
+
for (const path of [`${STICKY_ROOT}-other/store`, `${STICKY_ROOT}/../other`, "/workspace/data"]) {
|
|
33
|
+
expect(isOnStickyDisk(path, STICKY_ROOT)).toBe(false);
|
|
34
|
+
}
|
|
35
|
+
expect(isOnStickyDisk(`${STICKY_ROOT}/kubernetes/cluster`, null)).toBe(false);
|
|
36
|
+
await writeFile(manifest, JSON.stringify({ path: "/" }));
|
|
37
|
+
expect(stickyDiskPathSync(manifest)).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("Kubernetes stores are isolated by service and k3s version", () => {
|
|
41
|
+
const original = stickyKubernetesDirectory("cluster", "v1.33.5-k3s1");
|
|
42
|
+
expect(stickyKubernetesDirectory("cluster", "v1.32.1-k3s1")).not.toBe(original);
|
|
43
|
+
expect(stickyKubernetesDirectory("second", "v1.33.5-k3s1")).not.toBe(original);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("maintenance prunes oldest stores and releases runtime-image protection only after the last writer stops", async () => {
|
|
47
|
+
const root = await temporary();
|
|
48
|
+
const old = await store(root, "old", 1000);
|
|
49
|
+
const recent = await store(root, "recent");
|
|
50
|
+
const calls: string[][] = [];
|
|
51
|
+
const cache = await openKubernetesCacheMaintenance(runner(calls), root);
|
|
52
|
+
expect(cache.protectedImages.has(image)).toBe(true);
|
|
53
|
+
expect(await cache.pruneOldest()).toBe(true);
|
|
54
|
+
expect(calls).toContainEqual(["rm", "-rf", old]);
|
|
55
|
+
expect(cache.protectedImages.has(image)).toBe(true);
|
|
56
|
+
expect(await cache.pruneOldest()).toBe(true);
|
|
57
|
+
expect(calls).toContainEqual(["rm", "-rf", recent]);
|
|
58
|
+
expect(cache.protectedImages.size).toBe(0);
|
|
59
|
+
expect(await cache.pruneOldest()).toBe(false);
|
|
60
|
+
await cache.close();
|
|
61
|
+
expect(calls.filter(c => c[0] === "docker" && c[1] === "stop")).toHaveLength(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("failed maintenance startup closes every started store and refuses publication", async () => {
|
|
65
|
+
const root = await temporary();
|
|
66
|
+
await store(root, "first"); await store(root, "second");
|
|
67
|
+
const calls: string[][] = [];
|
|
68
|
+
const run = runner(calls);
|
|
69
|
+
let checks = 0;
|
|
70
|
+
await expect(openKubernetesCacheMaintenance(async (program, args, timeout) => {
|
|
71
|
+
const result = await run(program, args, timeout);
|
|
72
|
+
if (args[0] === "exec" && ++checks === 2) throw new Error("containerd cannot open store");
|
|
73
|
+
return result;
|
|
74
|
+
}, root)).rejects.toThrow("Kubernetes cache preparation failed");
|
|
75
|
+
expect(calls.filter(c => c[0] === "docker" && c[1] === "stop")).toHaveLength(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("unclean nested containerd shutdown rejects sealing instead of publishing its store", async () => {
|
|
79
|
+
const root = await temporary(); await store(root, "cluster");
|
|
80
|
+
const calls: string[][] = [];
|
|
81
|
+
const run = runner(calls);
|
|
82
|
+
const cache = await openKubernetesCacheMaintenance(async (program, args, timeout) => {
|
|
83
|
+
const result = await run(program, args, timeout);
|
|
84
|
+
return args[0] === "inspect" ? JSON.stringify({ ExitCode: 137, OOMKilled: false }) : result;
|
|
85
|
+
}, root);
|
|
86
|
+
await expect(cache.close()).rejects.toThrow("Kubernetes cache shutdown failed");
|
|
87
|
+
expect(calls.some(c => c[0] === "rm")).toBe(false);
|
|
88
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** Kubernetes owns a separate containerd store; Docker/BuildKit GC cannot
|
|
2
|
+
* collect it. Maintenance runs only on the isolated publication clone. */
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { STICKY_ROOT } from "./sticky-paths.js";
|
|
8
|
+
|
|
9
|
+
export type CacheCommand = (program: string, args: string[], timeout?: number) => Promise<string>;
|
|
10
|
+
const STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
|
|
11
|
+
const SOCKET = "/run/spectest-cache.sock";
|
|
12
|
+
const ROOT = `${STICKY_ROOT}/kubernetes`;
|
|
13
|
+
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
|
|
14
|
+
const SERVICE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
15
|
+
interface StoreDescriptor { schema: 1; image: string; updatedAt: number }
|
|
16
|
+
|
|
17
|
+
export function stickyKubernetesDirectory(service: string, version: string): string {
|
|
18
|
+
// Version changes must not open old metadata with a different containerd.
|
|
19
|
+
const layout = createHash("sha256").update(`overlayfs-v1:${version}`).digest("hex").slice(0, 16);
|
|
20
|
+
return `${ROOT}/${service}/${layout}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const runCommand: CacheCommand = async (program, args, timeout = 30_000) =>
|
|
24
|
+
(await promisify(execFile)(program, args, { timeout })).stdout.trim();
|
|
25
|
+
|
|
26
|
+
export async function registerStickyKubernetesStore(
|
|
27
|
+
service: string, version: string, run: CacheCommand = runCommand,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
if (!SERVICE_NAME.test(service)) throw new Error(`invalid Kubernetes cache service name: ${service}`);
|
|
30
|
+
// Respect custom Dockerfile overrides of the component's image.
|
|
31
|
+
const image = (await run("docker", ["inspect", "--format", "{{.Image}}", service])).trim();
|
|
32
|
+
if (!IMAGE_ID.test(image)) throw new Error(`cannot identify Kubernetes runtime image for ${service}`);
|
|
33
|
+
const directory = stickyKubernetesDirectory(service, version);
|
|
34
|
+
await mkdir(directory, { recursive: true });
|
|
35
|
+
const temporary = `${directory}/store.${randomUUID()}.tmp`;
|
|
36
|
+
await writeFile(temporary, JSON.stringify({ schema: 1, image, updatedAt: Date.now() } satisfies StoreDescriptor));
|
|
37
|
+
await rename(temporary, `${directory}/store.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Standalone containerd never starts CRI/kubelet or any inherited workload.
|
|
41
|
+
* Native API deletion preserves content and committed image snapshots while
|
|
42
|
+
* releasing sandbox, container, lease and writable snapshot references. */
|
|
43
|
+
export const KUBERNETES_CACHE_MAINTENANCE_SH = `set -eu
|
|
44
|
+
exec 2>&1
|
|
45
|
+
cat > /run/cache.toml <<'CONFIG'
|
|
46
|
+
version = 2
|
|
47
|
+
disabled_plugins = ["io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "io.containerd.cri.v1.images"]
|
|
48
|
+
CONFIG
|
|
49
|
+
containerd --config /run/cache.toml --root ${STORE_TARGET} --state /run/cache-state --address ${SOCKET} > /run/cache.log 2>&1 &
|
|
50
|
+
pid=$!
|
|
51
|
+
stop() { kill "$pid"; wait "$pid"; }
|
|
52
|
+
trap 'stop; exit 0' TERM INT
|
|
53
|
+
trap 'kill "$pid" 2>/dev/null || true' EXIT
|
|
54
|
+
c() { ctr --address ${SOCKET} --namespace k8s.io "$@"; }
|
|
55
|
+
i=0
|
|
56
|
+
until c version >/dev/null 2>&1; do
|
|
57
|
+
kill -0 "$pid" || { cat /run/cache.log; exit 1; }
|
|
58
|
+
i=$((i+1)); [ "$i" -lt 300 ] || { cat /run/cache.log; exit 1; }
|
|
59
|
+
sleep 0.1
|
|
60
|
+
done
|
|
61
|
+
ids=$(c containers ls -q)
|
|
62
|
+
if [ -n "$ids" ]; then c containers rm $ids; fi
|
|
63
|
+
# 1.7 has no sandbox store; 2.x does.
|
|
64
|
+
case "$(containerd --version)" in
|
|
65
|
+
*v1.*) ;;
|
|
66
|
+
*) sandboxes=$(c sandboxes ls)
|
|
67
|
+
ids=$(printf '%s\\n' "$sandboxes" | awk 'NR > 1 {print $1}')
|
|
68
|
+
if [ -n "$ids" ]; then c sandboxes rm --force $ids; fi ;;
|
|
69
|
+
esac
|
|
70
|
+
ids=$(c leases ls -q)
|
|
71
|
+
if [ -n "$ids" ]; then c leases rm $ids; fi
|
|
72
|
+
# Remove children before parents, and tolerate asynchronous GC winning a race.
|
|
73
|
+
previous=''
|
|
74
|
+
while :; do
|
|
75
|
+
snapshots=$(c snapshots --snapshotter overlayfs ls)
|
|
76
|
+
ids=$(printf '%s\\n' "$snapshots" | awk 'NR > 1 && $NF != "Committed" {print $1}')
|
|
77
|
+
[ -n "$ids" ] || break
|
|
78
|
+
[ "$ids" != "$previous" ] || { echo 'cannot clean Kubernetes writable snapshots' >&2; exit 1; }
|
|
79
|
+
previous="$ids"
|
|
80
|
+
for id in $ids; do c snapshots --snapshotter overlayfs rm "$id" >/dev/null 2>&1 || true; done
|
|
81
|
+
done
|
|
82
|
+
touch /run/cache-ready
|
|
83
|
+
while :; do sleep 1 & wait $!; done
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
interface Store { directory: string; descriptor: StoreDescriptor; container: string; active: boolean }
|
|
87
|
+
export interface KubernetesCacheMaintenance {
|
|
88
|
+
/** Outer images needed by live maintenance containers must not be evicted. */
|
|
89
|
+
protectedImages: Set<string>;
|
|
90
|
+
/** Evicts one complete image store, oldest service/version first. */
|
|
91
|
+
pruneOldest(): Promise<boolean>;
|
|
92
|
+
close(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function openKubernetesCacheMaintenance(run: CacheCommand, root = ROOT): Promise<KubernetesCacheMaintenance> {
|
|
96
|
+
const stores: Store[] = [];
|
|
97
|
+
const protectedImages = new Set<string>();
|
|
98
|
+
const closeStore = async (store: Store) => {
|
|
99
|
+
if (!store.active) return;
|
|
100
|
+
await run("docker", ["stop", "--time", "30", store.container], 45_000);
|
|
101
|
+
const status = JSON.parse(await run("docker", ["inspect", "--format", "{{json .State}}", store.container]));
|
|
102
|
+
if (status.ExitCode !== 0 || status.OOMKilled) throw new Error(`unclean Kubernetes cache shutdown: ${store.container}`);
|
|
103
|
+
await run("docker", ["rm", store.container]);
|
|
104
|
+
store.active = false;
|
|
105
|
+
if (!stores.some(other => other.active && other.descriptor.image === store.descriptor.image)) protectedImages.delete(store.descriptor.image);
|
|
106
|
+
// These contain runtime state, not image content or unpacked layers.
|
|
107
|
+
await run("rm", ["-rf", ...["io.containerd.runtime.v2.task", "io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "tmpmounts"]
|
|
108
|
+
.map(child => `${store.directory}/containerd/${child}`)]);
|
|
109
|
+
};
|
|
110
|
+
const close = async () => {
|
|
111
|
+
const failures: unknown[] = [];
|
|
112
|
+
for (const store of stores) {
|
|
113
|
+
try { await closeStore(store); } catch (error) { failures.push(error); }
|
|
114
|
+
}
|
|
115
|
+
if (failures.length) throw new AggregateError(failures, "Kubernetes cache shutdown failed");
|
|
116
|
+
};
|
|
117
|
+
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
|
118
|
+
if (error.code === "ENOENT") return [];
|
|
119
|
+
throw error;
|
|
120
|
+
});
|
|
121
|
+
try {
|
|
122
|
+
for (const service of entries) {
|
|
123
|
+
if (!service.isDirectory() || !SERVICE_NAME.test(service.name)) continue;
|
|
124
|
+
for (const layout of await readdir(`${root}/${service.name}`, { withFileTypes: true })) {
|
|
125
|
+
if (!layout.isDirectory() || !/^[a-f0-9]{16}$/.test(layout.name)) continue;
|
|
126
|
+
const directory = `${root}/${service.name}/${layout.name}`;
|
|
127
|
+
const descriptor: StoreDescriptor = JSON.parse(await readFile(`${directory}/store.json`, "utf8"));
|
|
128
|
+
if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
|
|
129
|
+
throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
|
|
130
|
+
}
|
|
131
|
+
const container = `spectest-k8s-cache-${randomUUID()}`;
|
|
132
|
+
await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
|
|
133
|
+
"--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
|
|
134
|
+
"--entrypoint", "/bin/sh", descriptor.image, "-c", KUBERNETES_CACHE_MAINTENANCE_SH]);
|
|
135
|
+
stores.push({ directory, descriptor, container, active: true });
|
|
136
|
+
protectedImages.add(descriptor.image);
|
|
137
|
+
try {
|
|
138
|
+
await run("docker", ["exec", container, "sh", "-c", "i=0; until [ -f /run/cache-ready ]; do i=$((i+1)); [ $i -lt 900 ] || exit 1; sleep 0.1; done"], 100_000);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const logs = await run("docker", ["logs", "--tail", "40", container]).catch(() => "");
|
|
141
|
+
throw new Error(`Kubernetes cache preparation failed for ${directory}: ${logs}`, { cause: error });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
stores.sort((a, b) => a.descriptor.updatedAt - b.descriptor.updatedAt || a.directory.localeCompare(b.directory));
|
|
146
|
+
let next = 0;
|
|
147
|
+
const pruneOldest = async () => {
|
|
148
|
+
const store = stores[next++];
|
|
149
|
+
if (!store) return false;
|
|
150
|
+
await run("docker", ["exec", store.container, "sh", "-ec",
|
|
151
|
+
`ids=$(ctr -a ${SOCKET} -n k8s.io images ls -q); if [ -n "$ids" ]; then ctr -a ${SOCKET} -n k8s.io images rm --sync $ids; fi`], 120_000);
|
|
152
|
+
await closeStore(store);
|
|
153
|
+
// No writer remains. Discard the now-empty cache and its descriptor so
|
|
154
|
+
// obsolete k3s binaries can also be collected by the outer Docker GC.
|
|
155
|
+
await run("rm", ["-rf", store.directory]);
|
|
156
|
+
return true;
|
|
157
|
+
};
|
|
158
|
+
// Abandoned services/versions should not occupy the disk indefinitely.
|
|
159
|
+
while (stores[next] && stores[next]!.descriptor.updatedAt < Date.now() - 7 * 24 * 60 * 60 * 1000) await pruneOldest();
|
|
160
|
+
return { protectedImages, pruneOldest, close };
|
|
161
|
+
} catch (error) {
|
|
162
|
+
await close().catch(() => {});
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const STICKY_ROOT = "/var/lib/spectest-sticky";
|
|
5
|
+
export const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
6
|
+
|
|
7
|
+
/** Written only after the supervisor mounts this project's private disk. */
|
|
8
|
+
export function stickyDiskPathSync(manifest = `${STICKY_RUNTIME}/consumer.json`): string | null {
|
|
9
|
+
try {
|
|
10
|
+
const config = JSON.parse(readFileSync(manifest, "utf8"));
|
|
11
|
+
return config.path === STICKY_ROOT ? STICKY_ROOT : null;
|
|
12
|
+
} catch { return null; }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isOnStickyDisk(path: string, root = stickyDiskPathSync()): boolean {
|
|
16
|
+
const absolute = resolve(path);
|
|
17
|
+
return root !== null && (absolute === root || absolute.startsWith(`${root}/`));
|
|
18
|
+
}
|