@specific.dev/spectest 0.115.1 → 0.116.1
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 +44 -0
- package/dist/components/replay-websocket.d.ts +2 -3
- package/dist/components/replay-websocket.js +19 -185
- package/dist/components/replayFake.d.ts +2 -53
- package/dist/components/replayFake.js +7 -348
- package/dist/daemon.js +4 -1
- package/dist/fake-upstream-websocket.d.ts +27 -0
- package/dist/fake-upstream-websocket.js +184 -0
- package/dist/fake-upstream.d.ts +114 -0
- package/dist/fake-upstream.js +348 -0
- package/dist/harness/fake-websocket.js +6 -3
- package/dist/harness/live-fake.d.ts +3 -0
- package/dist/harness/live-fake.js +66 -0
- package/dist/harness/sticky-consumer.d.ts +11 -3
- package/dist/harness/sticky-consumer.js +61 -38
- package/dist/harness/sticky-image-retention.d.ts +15 -0
- package/dist/harness/sticky-image-retention.js +32 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +3 -0
- package/dist/record-secrets.d.ts +1 -1
- package/dist/record-secrets.js +3 -3
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +6 -2
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +25 -145
- package/src/components/replayFake.ts +8 -459
- package/src/daemon.ts +3 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -0
- package/src/harness/fake-websocket.ts +6 -3
- package/src/harness/live-fake.test.ts +227 -0
- package/src/harness/live-fake.ts +62 -0
- package/src/harness/sticky-consumer.test.ts +93 -23
- package/src/harness/sticky-consumer.ts +62 -40
- package/src/harness/sticky-image-retention.test.ts +59 -0
- package/src/harness/sticky-image-retention.ts +43 -0
- package/src/index.ts +19 -1
- package/src/record-secrets.ts +3 -3
- package/src/replay-mode.ts +7 -2
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
|
|
8
|
+
|
|
9
|
+
// Isolate DNS/transport mocks, platform secrets and recorder globals. All egress
|
|
10
|
+
// goes to a local stub; no real provider or credential is needed.
|
|
11
|
+
test("whole-fake live HTTP uses the environment gate, brokers secrets and never captures", async () => {
|
|
12
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-live-http-"));
|
|
13
|
+
try {
|
|
14
|
+
await mkdir(join(root, "spectest"));
|
|
15
|
+
await writeFile(join(root, "spectest/index.ts"), `
|
|
16
|
+
import { defineEnvironment, defineFake } from ${moduleUrl("../index.ts")};
|
|
17
|
+
export default defineEnvironment({ name: "live", services: {}, fakes: {
|
|
18
|
+
live: defineFake({ name: "live", hostnames: ["api.test"], handler: () => new Response(),
|
|
19
|
+
live: { inject: [{ headers: { authorization: "Bearer {{secret:TOKEN}}" } }],
|
|
20
|
+
sign: { type: "awsSigv4", accessKeyId: "ID", secretAccessKey: "KEY", sessionToken: "SESSION" } },
|
|
21
|
+
secretRefs: ["LEGACY", "TOKEN"],
|
|
22
|
+
}),
|
|
23
|
+
} }).project({});
|
|
24
|
+
`);
|
|
25
|
+
const script = `
|
|
26
|
+
import { strict as assert } from "node:assert";
|
|
27
|
+
import { EventEmitter } from "node:events";
|
|
28
|
+
import { test, mock } from "bun:test";
|
|
29
|
+
import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
|
|
30
|
+
test("live HTTP", async () => {
|
|
31
|
+
let dns = 0, requests = [];
|
|
32
|
+
mock.module("node:dgram", () => ({ createSocket() {
|
|
33
|
+
dns++;
|
|
34
|
+
const socket = new EventEmitter(); socket.close = () => {};
|
|
35
|
+
socket.send = (query, _port, _host, done) => { done(); queueMicrotask(() => socket.emit("message", dnsPacket.encode({
|
|
36
|
+
type: "response", id: 1, answers: [{ type: "A", name: dnsPacket.decode(query).questions[0].name, ttl: 60, data: "127.0.0.1" }],
|
|
37
|
+
}))); }; return socket;
|
|
38
|
+
} }));
|
|
39
|
+
mock.module("node:https", () => ({ request(options, callback) {
|
|
40
|
+
requests.push(options);
|
|
41
|
+
const req = new EventEmitter(); let body = Buffer.alloc(0);
|
|
42
|
+
req.write = chunk => { body = Buffer.concat([body, chunk]); };
|
|
43
|
+
req.end = () => queueMicrotask(() => {
|
|
44
|
+
if (options.path === "/broken") { req.emit("error", new Error("upstream unavailable")); return; }
|
|
45
|
+
const res = new EventEmitter(); res.statusCode = options.path === "/empty" ? 204 : options.path === "/denied" ? 401 : 200;
|
|
46
|
+
res.headers = { "content-type": "application/octet-stream", "set-cookie": ["session=real; Path=/", "other=value; Expires=Wed, 21 Oct 2037 07:28:00 GMT"], "content-length": "999" };
|
|
47
|
+
callback(res); res.emit("data", body.length ? body : Buffer.from("real transcript")); res.emit("end");
|
|
48
|
+
}); return req;
|
|
49
|
+
} }));
|
|
50
|
+
const { defineFake } = await import(${moduleUrl("../index.ts")});
|
|
51
|
+
const { METHODS } = await import(${moduleUrl("../daemon.ts")});
|
|
52
|
+
const { dispatchFake } = await import(${moduleUrl("./fake-websocket.ts")});
|
|
53
|
+
const { setReplayEnvironmentMode } = await import(${moduleUrl("../replay-mode.ts")});
|
|
54
|
+
const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
|
|
55
|
+
const { startRecording, stopRecording, pauseRecording, resumeRecording } = await import(${moduleUrl("../recorder.ts")});
|
|
56
|
+
await METHODS.load({});
|
|
57
|
+
assert.deepEqual((await METHODS.recordSecretRefs({})).refs.sort(), ["ID", "KEY", "LEGACY", "SESSION", "TOKEN"]);
|
|
58
|
+
const state = { calls: 0 };
|
|
59
|
+
const def = defineFake({ name: "speech", hostnames: ["api.test", "speech.test"],
|
|
60
|
+
handler: () => { state.calls++; return new Response("scripted"); },
|
|
61
|
+
websocket: () => { state.calls++; return new Response("scripted websocket", { status: 403 }); },
|
|
62
|
+
helpers: () => ({ count: () => state.calls }),
|
|
63
|
+
live: { inject: [{ headers: { authorization: "Bearer {{secret:TOKEN}}" } }] },
|
|
64
|
+
});
|
|
65
|
+
const helpers = def.helpers({ name: def.name, state, ctx: {} });
|
|
66
|
+
const call = (host = "api.test", path = "/transcribe", options = {}) => dispatchFake(new Request("https://" + host + path, options), {}, def, state, {});
|
|
67
|
+
setRecordSecrets({ TOKEN: "real-key" });
|
|
68
|
+
async function checkScripted() {
|
|
69
|
+
assert.equal(await (await call()).text(), "scripted");
|
|
70
|
+
assert.equal((await call("speech.test", "/socket", { headers: { upgrade: "websocket" } })).status, 403);
|
|
71
|
+
assert.equal(dns, 0); assert.equal(requests.length, 0);
|
|
72
|
+
}
|
|
73
|
+
await checkScripted(); // baseline startup
|
|
74
|
+
startRecording(); await checkScripted(); pauseRecording(); await checkScripted();
|
|
75
|
+
resumeRecording(); stopRecording(); await checkScripted(); // between tests
|
|
76
|
+
setReplayEnvironmentMode("interactive");
|
|
77
|
+
const calls = helpers.count();
|
|
78
|
+
startRecording();
|
|
79
|
+
for (const host of def.hostnames) {
|
|
80
|
+
const response = await call(host, "/transcribe?lang=sv&word=a%20b", {
|
|
81
|
+
method: "POST", body: new Uint8Array([0, 128, 255]), headers: { authorization: "Bearer dummy" },
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(new Uint8Array(await response.arrayBuffer()), new Uint8Array([0, 128, 255]));
|
|
84
|
+
assert.deepEqual(response.headers.getSetCookie(), ["session=real; Path=/", "other=value; Expires=Wed, 21 Oct 2037 07:28:00 GMT"]);
|
|
85
|
+
assert.equal(response.headers.get("content-length"), null);
|
|
86
|
+
const sent = requests.at(-1);
|
|
87
|
+
assert.equal(sent.host, "127.0.0.1"); assert.equal(sent.servername, host);
|
|
88
|
+
assert.equal(sent.headers.host, host); assert.equal(sent.headers.authorization, "Bearer real-key");
|
|
89
|
+
assert.equal(sent.path, "/transcribe?lang=sv&word=a%20b"); assert.equal(sent.method, "POST");
|
|
90
|
+
}
|
|
91
|
+
assert.equal(await (await call()).text(), "real transcript");
|
|
92
|
+
assert.deepEqual(stopRecording(), []);
|
|
93
|
+
const compressed = new Uint8Array([31, 139, 8, 0]);
|
|
94
|
+
await call("api.test", "/compressed", { method: "POST", body: compressed, headers: { "content-encoding": "gzip" } });
|
|
95
|
+
assert.equal(requests.at(-1).headers["content-encoding"], "gzip");
|
|
96
|
+
assert.equal(helpers.count(), calls);
|
|
97
|
+
assert.equal((await call("api.test", "/empty")).status, 204);
|
|
98
|
+
assert.equal((await call("api.test", "/denied")).status, 401);
|
|
99
|
+
assert.equal((await call("api.test", "/broken")).status, 599);
|
|
100
|
+
assert.equal(await (await call("api.test", "/transcribe", { method: "HEAD" })).text(), "");
|
|
101
|
+
let before = dns;
|
|
102
|
+
assert.equal((await call("outside.test")).status, 500); assert.equal(dns, before);
|
|
103
|
+
setRecordSecrets({});
|
|
104
|
+
assert.equal((await call()).status, 599); assert.equal(dns, before);
|
|
105
|
+
// Missing credentials never change routing or fall back to the fake.
|
|
106
|
+
assert.equal(helpers.count(), calls);
|
|
107
|
+
def.live = {};
|
|
108
|
+
assert.equal(await (await call()).text(), "real transcript");
|
|
109
|
+
def.live = { sign: { type: "awsSigv4", accessKeyId: "ID", secretAccessKey: "KEY" } };
|
|
110
|
+
def.hostnames = ["s3.eu-north-1.amazonaws.com"];
|
|
111
|
+
setRecordSecrets({ ID: "real-id", KEY: "real-signing-key" });
|
|
112
|
+
await call(def.hostnames[0]);
|
|
113
|
+
assert.ok(requests.at(-1).headers.authorization.startsWith("AWS4-HMAC-SHA256 Credential=real-id/"));
|
|
114
|
+
delete def.live;
|
|
115
|
+
before = dns; assert.equal(await (await call()).text(), "scripted"); assert.equal(dns, before);
|
|
116
|
+
def.live = {};
|
|
117
|
+
setReplayEnvironmentMode("test");
|
|
118
|
+
before = dns; await call(); assert.equal(dns, before);
|
|
119
|
+
});
|
|
120
|
+
`;
|
|
121
|
+
const file = join(root, "http.test.ts"); await writeFile(file, script);
|
|
122
|
+
const child = Bun.spawn([process.execPath, "test", file], {
|
|
123
|
+
env: { ...process.env, SPECTEST_APP_DIR: root }, stdout: "pipe", stderr: "pipe",
|
|
124
|
+
});
|
|
125
|
+
const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
126
|
+
expect(exit, stdout + stderr).toBe(0);
|
|
127
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
128
|
+
}, 20_000);
|
|
129
|
+
|
|
130
|
+
// Real TLS and ingress sockets. Only DNS and the upstream port are redirected.
|
|
131
|
+
test("live WSS forwards binary/text without recording and cleans up both sides", async () => {
|
|
132
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-live-ws-"));
|
|
133
|
+
try {
|
|
134
|
+
const key = join(root, "key.pem"), cert = join(root, "cert.pem");
|
|
135
|
+
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key, "-out", cert,
|
|
136
|
+
"-days", "1", "-subj", "/CN=speech.test", "-addext", "subjectAltName=DNS:speech.test,DNS:api.test"], { stdio: "ignore" });
|
|
137
|
+
const script = `
|
|
138
|
+
import { test, mock } from "bun:test";
|
|
139
|
+
import { strict as assert } from "node:assert";
|
|
140
|
+
import { EventEmitter, once } from "node:events";
|
|
141
|
+
import { createServer } from "node:https";
|
|
142
|
+
import { readFileSync } from "node:fs";
|
|
143
|
+
import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
|
|
144
|
+
import WS from ${JSON.stringify(import.meta.resolve("ws").replace(/wrapper\.mjs$/, "index.js"))};
|
|
145
|
+
test("live WSS", async () => {
|
|
146
|
+
mock.module("node:dgram", () => ({ createSocket() {
|
|
147
|
+
const socket = new EventEmitter(); socket.close = () => {};
|
|
148
|
+
socket.send = (_query, _port, _host, done) => { done(); queueMicrotask(() => socket.emit("message", dnsPacket.encode({
|
|
149
|
+
type: "response", id: 1, answers: [{ type: "A", name: "speech.test", ttl: 60, data: "127.0.0.1" }],
|
|
150
|
+
}))); }; return socket;
|
|
151
|
+
} }));
|
|
152
|
+
const upstream = createServer({ key: readFileSync(${JSON.stringify(key)}), cert: readFileSync(${JSON.stringify(cert)}) });
|
|
153
|
+
const wss = new WS.WebSocketServer({ server: upstream, handleProtocols: () => "voice" });
|
|
154
|
+
upstream.listen(0, "127.0.0.1"); await once(upstream, "listening");
|
|
155
|
+
const { forwardWebSocket } = await import(${moduleUrl("../fake-upstream-websocket.ts")});
|
|
156
|
+
mock.module(${moduleUrl("../fake-upstream-websocket.ts")}, () => ({ forwardWebSocket(args) {
|
|
157
|
+
assert.equal(args.observe, undefined); // no recorder, matcher or capture callback
|
|
158
|
+
assert.equal(args.url, "wss://" + args.host + "/live?lang=sv");
|
|
159
|
+
const url = new URL(args.url); url.port = String(upstream.address().port);
|
|
160
|
+
return forwardWebSocket({ ...args, url: url.href });
|
|
161
|
+
} }));
|
|
162
|
+
const { defineFake } = await import(${moduleUrl("../index.ts")});
|
|
163
|
+
const { setReplayEnvironmentMode } = await import(${moduleUrl("../replay-mode.ts")});
|
|
164
|
+
const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
|
|
165
|
+
const { startRecording, stopRecording } = await import(${moduleUrl("../recorder.ts")});
|
|
166
|
+
const { startIngressServer } = await import(${moduleUrl("./ingress-server.ts")});
|
|
167
|
+
const { dispatchFake } = await import(${moduleUrl("./fake-websocket.ts")});
|
|
168
|
+
setReplayEnvironmentMode("interactive"); setRecordSecrets({ TOKEN: "real-key" });
|
|
169
|
+
const state = { calls: 0 };
|
|
170
|
+
// Deliberately no scripted WebSocket handler: live routing still upgrades.
|
|
171
|
+
const def = defineFake({ name: "speech", hostnames: ["api.test", "speech.test"],
|
|
172
|
+
handler: () => { state.calls++; return new Response("scripted"); },
|
|
173
|
+
live: { inject: [{ headers: { authorization: "Bearer {{secret:TOKEN}}" } }] },
|
|
174
|
+
});
|
|
175
|
+
const ingress = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, def, state, {}) });
|
|
176
|
+
let connections = 0;
|
|
177
|
+
wss.on("connection", (socket, req) => {
|
|
178
|
+
connections++; assert.equal(req.headers.authorization, "Bearer real-key");
|
|
179
|
+
assert.equal(req.url, "/live?lang=sv");
|
|
180
|
+
socket.send("real transcript");
|
|
181
|
+
socket.on("message", (data, binary) => {
|
|
182
|
+
if (!binary && data.toString() === "finish") socket.close(4003, "provider done");
|
|
183
|
+
else socket.send(data, { binary });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
startRecording();
|
|
188
|
+
for (const host of def.hostnames) {
|
|
189
|
+
const client = new WS("ws://127.0.0.1:" + ingress.port + "/live?lang=sv", ["voice"], {
|
|
190
|
+
headers: { host, authorization: "Bearer dummy" },
|
|
191
|
+
});
|
|
192
|
+
const received = []; client.on("message", (data, binary) => received.push({ data: Buffer.from(data), binary }));
|
|
193
|
+
await once(client, "open"); assert.equal(client.protocol, "voice");
|
|
194
|
+
client.send('{"model":"test"}'); client.send(new Uint8Array([0, 128, 255]));
|
|
195
|
+
for (let i = 0; received.length < 3 && i < 200; i++) await Bun.sleep(5);
|
|
196
|
+
assert.deepEqual(received, [
|
|
197
|
+
{ data: Buffer.from("real transcript"), binary: false },
|
|
198
|
+
{ data: Buffer.from('{"model":"test"}'), binary: false },
|
|
199
|
+
{ data: Buffer.from([0, 128, 255]), binary: true },
|
|
200
|
+
]);
|
|
201
|
+
const closed = once(client, "close");
|
|
202
|
+
if (host === "api.test") client.close(4002, "client done"); else client.send("finish");
|
|
203
|
+
const [code, reason] = await closed;
|
|
204
|
+
assert.equal(code, host === "api.test" ? 4002 : 4003);
|
|
205
|
+
assert.equal(reason.toString(), host === "api.test" ? "client done" : "provider done");
|
|
206
|
+
}
|
|
207
|
+
assert.equal(connections, 2); assert.equal(state.calls, 0);
|
|
208
|
+
assert.deepEqual(stopRecording(), []);
|
|
209
|
+
for (let i = 0; wss.clients.size && i < 100; i++) await Bun.sleep(5);
|
|
210
|
+
assert.equal(wss.clients.size, 0);
|
|
211
|
+
setRecordSecrets({});
|
|
212
|
+
const response = await dispatchFake(new Request("https://speech.test/live", { headers: { upgrade: "websocket" } }), {}, def, state, {});
|
|
213
|
+
assert.equal(response.status, 500); assert.equal(connections, 2); assert.equal(state.calls, 0);
|
|
214
|
+
def.live.sign = { type: "awsSigv4", accessKeyId: "ID", secretAccessKey: "KEY" };
|
|
215
|
+
const signed = await dispatchFake(new Request("https://speech.test/live", { headers: { upgrade: "websocket" } }), {}, def, state, {});
|
|
216
|
+
assert.match(await signed.text(), /SigV4 WebSocket signing is not supported/);
|
|
217
|
+
} finally {
|
|
218
|
+
ingress.stop(true); for (const socket of wss.clients) socket.terminate(); wss.close(); upstream.close();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
`;
|
|
222
|
+
const file = join(root, "ws.test.ts"); await writeFile(file, script);
|
|
223
|
+
const child = Bun.spawn([process.execPath, "test", file], { env: { ...process.env, NODE_EXTRA_CA_CERTS: cert }, stdout: "pipe", stderr: "pipe" });
|
|
224
|
+
const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
|
|
225
|
+
expect(exit, stdout + stderr).toBe(0);
|
|
226
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
227
|
+
}, 20_000);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { FakeDefinition } from "../index.js";
|
|
2
|
+
import {
|
|
3
|
+
brokerHeaders, forwardHttp, resolveExternalA, ruleMatches, STRIP_FORWARD_HEADERS,
|
|
4
|
+
} from "../fake-upstream.js";
|
|
5
|
+
import { forwardWebSocket } from "../fake-upstream-websocket.js";
|
|
6
|
+
import { getWebSocketLifetime } from "./fake-websocket-lifetime.js";
|
|
7
|
+
import { reportBackgroundFailure } from "./background-failures.js";
|
|
8
|
+
import { isInteractiveEnvironment } from "../replay-mode.js";
|
|
9
|
+
|
|
10
|
+
/** Select at request time, after the baseline has become interactive. */
|
|
11
|
+
export function liveFakeHandlers(def: FakeDefinition): Pick<FakeDefinition, "handler" | "websocket"> {
|
|
12
|
+
function target(req: Request): string {
|
|
13
|
+
if (!isInteractiveEnvironment()) throw new Error("live forwarding requires an interactive environment");
|
|
14
|
+
const url = new URL(req.url);
|
|
15
|
+
if (!def.hostnames.some(host => host.toLowerCase() === url.hostname.toLowerCase())) {
|
|
16
|
+
throw new Error(`fake ${def.name}: upstream hostname is not declared on this fake`);
|
|
17
|
+
}
|
|
18
|
+
return url.hostname;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
async handler(req) {
|
|
23
|
+
const host = target(req);
|
|
24
|
+
const bytes = new Uint8Array(await req.arrayBuffer());
|
|
25
|
+
const result = await forwardHttp(req, host, def.live!, bytes, `fake(${def.name})`);
|
|
26
|
+
if (result instanceof Response) return result;
|
|
27
|
+
const { upstream } = result;
|
|
28
|
+
const headers = new Headers();
|
|
29
|
+
for (const [key, value] of Object.entries(upstream.headers)) {
|
|
30
|
+
if (key !== "set-cookie" && !STRIP_FORWARD_HEADERS.has(key)) headers.set(key, value);
|
|
31
|
+
}
|
|
32
|
+
for (const cookie of upstream.cookies ?? []) headers.append("set-cookie", cookie);
|
|
33
|
+
return new Response(req.method === "HEAD" || [204, 205, 304].includes(upstream.status)
|
|
34
|
+
? null : upstream.bytes as unknown as BodyInit, { status: upstream.status, headers });
|
|
35
|
+
},
|
|
36
|
+
async websocket(req) {
|
|
37
|
+
const host = target(req);
|
|
38
|
+
const opts = def.live!;
|
|
39
|
+
if (opts.sign) throw new Error(`fake(${def.name}): SigV4 WebSocket signing is not supported`);
|
|
40
|
+
const url = new URL(req.url);
|
|
41
|
+
const rule = opts.inject?.find(rule => ruleMatches(rule.match, {
|
|
42
|
+
method: req.method, path: url.pathname, query: url.searchParams, headers: req.headers,
|
|
43
|
+
}));
|
|
44
|
+
const brokered = rule ? brokerHeaders(rule) : { headers: {}, missing: [] };
|
|
45
|
+
if (brokered.missing.length) throw new Error(`fake(${def.name}): missing WebSocket secrets ${brokered.missing.join(", ")}`);
|
|
46
|
+
const ips = await resolveExternalA(host);
|
|
47
|
+
if (!ips.length) throw new Error(`fake(${def.name}): could not resolve real upstream ${host}`);
|
|
48
|
+
const headers: Record<string, string> = {};
|
|
49
|
+
req.headers.forEach((value, key) => {
|
|
50
|
+
if (!STRIP_FORWARD_HEADERS.has(key) && !key.startsWith("sec-websocket-")) headers[key] = value;
|
|
51
|
+
});
|
|
52
|
+
Object.assign(headers, brokered.headers);
|
|
53
|
+
const { handlers } = await forwardWebSocket({
|
|
54
|
+
url: `wss://${host}${url.pathname}${url.search}`, ip: ips[0], host, headers,
|
|
55
|
+
protocols: (req.headers.get("sec-websocket-protocol") ?? "").split(",").map(p => p.trim()).filter(Boolean),
|
|
56
|
+
label: `fake(${def.name}) WebSocket`, lifetime: getWebSocketLifetime(req),
|
|
57
|
+
finished(_session, error) { if (error) reportBackgroundFailure(error); },
|
|
58
|
+
});
|
|
59
|
+
return handlers;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -1,55 +1,127 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache } from "./sticky-consumer";
|
|
2
|
+
import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache, stickyBuildkitConfig } from "./sticky-consumer";
|
|
3
|
+
|
|
4
|
+
import type { StickyImageRetention } from "./sticky-image-retention";
|
|
5
|
+
|
|
6
|
+
const now = Date.UTC(2026, 9, 13);
|
|
7
|
+
const retention = (images: StickyImageRetention["images"] = [], protectedImages: string[] = []): StickyImageRetention =>
|
|
8
|
+
({ images, protectedImages: new Set(protectedImages), containers: [], observedAt: now });
|
|
3
9
|
|
|
4
10
|
const GiB = 1024 ** 3;
|
|
5
11
|
const config = { path: "/var/lib/spectest-sticky", capacityBytes: 256 * GiB,
|
|
6
|
-
headroomBytes: 32 * GiB,
|
|
12
|
+
headroomBytes: 32 * GiB,
|
|
7
13
|
compatibility: STICKY_COMPATIBILITY };
|
|
8
14
|
|
|
9
|
-
test("sticky consumer validates
|
|
15
|
+
test("sticky consumer validates disk capacity and required free space", () => {
|
|
10
16
|
expect(() => validateStickyConfig(config)).not.toThrow();
|
|
11
|
-
//
|
|
17
|
+
// Retention has no per-consumer size caps.
|
|
12
18
|
expect(() => validateStickyConfig({ ...config, capacityBytes: 512 * GiB })).not.toThrow();
|
|
13
19
|
for (const override of [{ path: "/" }, { compatibility: "legacy" }, { headroomBytes: 0 },
|
|
14
20
|
{ headroomBytes: config.capacityBytes }, { capacityBytes: Number.NaN },
|
|
15
|
-
{
|
|
21
|
+
{ headroomBytes: Number.NaN }, { headroomBytes: -1 }, { capacityBytes: 0 }]) {
|
|
16
22
|
expect(() => validateStickyConfig({ ...config, ...override })).toThrow();
|
|
17
23
|
}
|
|
18
24
|
});
|
|
19
25
|
|
|
20
|
-
test("
|
|
21
|
-
|
|
26
|
+
test("automatic BuildKit GC has one free-space policy without default cache-size caps", () => {
|
|
27
|
+
const worker = (Bun.TOML.parse(stickyBuildkitConfig(config)) as any).worker.containerd;
|
|
28
|
+
expect(worker.gc).toBe(true);
|
|
29
|
+
expect(worker.gcpolicy).toEqual([{ all: true, maxUsedSpace: config.capacityBytes, minFreeSpace: 32 * GiB }]);
|
|
30
|
+
expect(worker.maxUsedSpace).toBeUndefined();
|
|
31
|
+
// Older controllers still send targets; they must not reinstate size GC.
|
|
32
|
+
expect(stickyBuildkitConfig({ ...config, cacheTargetBytes: 64 * GiB, buildkitTargetBytes: 32 * GiB }))
|
|
33
|
+
.toBe(stickyBuildkitConfig(config));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Listen-sized and larger caches retain recent build state while there is headroom", async () => {
|
|
37
|
+
for (const used of [77, 160, 219]) {
|
|
38
|
+
const calls: string[][] = [];
|
|
39
|
+
const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
|
|
40
|
+
// Real filesystem capacity excludes ext4 overhead. Legacy wire values
|
|
41
|
+
// must not turn 77 GiB of healthy cache into a 64 GiB pressure event.
|
|
42
|
+
await collectStickyCache({ ...config, cacheTargetBytes: 64 * GiB, buildkitTargetBytes: 32 * GiB },
|
|
43
|
+
retention([{ id: "postgres", lastUsed: now }]), run,
|
|
44
|
+
async () => ({ totalBytes: 251 * GiB, freeBytes: (251 - used) * GiB }));
|
|
45
|
+
expect(calls.some(c => c.includes("--free-storage") || c.includes("--keep-storage"))).toBe(false);
|
|
46
|
+
expect(calls.some(c => c[0] === "docker")).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("pressure reclamation stops when the free-space reserve is restored", async () => {
|
|
51
|
+
let free = 20 * GiB;
|
|
52
|
+
const calls: string[][] = [];
|
|
53
|
+
const run = async (command: string, args: string[]) => {
|
|
54
|
+
calls.push([command, ...args]);
|
|
55
|
+
if (args.includes("--free-storage")) free = 33 * GiB;
|
|
56
|
+
return "";
|
|
57
|
+
};
|
|
58
|
+
await collectStickyCache(config, retention([{ id: "unused", lastUsed: now }]), run,
|
|
59
|
+
async () => ({ totalBytes: 251 * GiB, freeBytes: free }));
|
|
60
|
+
expect(calls.filter(c => c.includes("--free-storage"))).toHaveLength(1);
|
|
61
|
+
expect(calls.some(c => c[0] === "docker")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("filesystem pressure removes least recently used Docker images when BuildKit pruning frees nothing", async () => {
|
|
65
|
+
let used = 236 * GiB;
|
|
22
66
|
const calls: string[][] = [];
|
|
23
67
|
const run = async (command: string, args: string[]) => {
|
|
24
68
|
calls.push([command, ...args]);
|
|
25
|
-
if (command === "docker" && args[1] === "ls") return "new\nold\n";
|
|
26
|
-
if (command === "docker" && args[1] === "inspect") return JSON.stringify([
|
|
27
|
-
{ Id: "new", Created: "2026-09-29T10:00:00Z" }, { Id: "old", Created: "2026-09-28T10:00:00Z" }]);
|
|
28
69
|
if (command === "docker" && args[1] === "rm") used -= 40 * GiB;
|
|
29
70
|
return ""; // Model Harmony: BuildKit's own GC reports no reclaimable data.
|
|
30
71
|
};
|
|
31
|
-
await collectStickyCache(config,
|
|
72
|
+
await collectStickyCache(config, retention([{ id: "new", lastUsed: now }, { id: "old", lastUsed: now - 1000 }]),
|
|
73
|
+
run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }));
|
|
32
74
|
expect(calls.filter(c => c[2] === "rm")).toEqual([["docker", "image", "rm", "-f", "old"]]);
|
|
33
75
|
const prune = calls.find(c => c.includes("--free-storage"))!;
|
|
34
|
-
expect(prune
|
|
35
|
-
expect(prune[prune.indexOf("--free-storage") + 1]).toBe(String(Math.ceil(
|
|
76
|
+
expect(prune.includes("--keep-storage")).toBe(false);
|
|
77
|
+
expect(prune[prune.indexOf("--free-storage") + 1]).toBe(String(Math.ceil(32 * GiB / 1e6)));
|
|
36
78
|
});
|
|
37
79
|
|
|
38
|
-
test("healthy cache keeps
|
|
80
|
+
test("healthy cache keeps recently used images and a cache that cannot shrink is not published", async () => {
|
|
39
81
|
const calls: string[][] = [];
|
|
40
82
|
const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
|
|
41
|
-
await collectStickyCache(config,
|
|
83
|
+
await collectStickyCache(config, retention([{ id: "postgres-built-in-june", lastUsed: now }]),
|
|
84
|
+
run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
|
|
42
85
|
expect(calls.some(c => c[2] === "rm" || c[2] === "inspect")).toBe(false);
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
expect(calls.some(c => c[0] === "docker" && c.includes("prune"))).toBe(false);
|
|
87
|
+
await expect(collectStickyCache(config, retention(), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 5 * GiB })))
|
|
88
|
+
.rejects.toThrow("cannot reclaim required write headroom");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("seven-day expiry uses our last observation and respects image protection", async () => {
|
|
92
|
+
const removed: string[] = [];
|
|
93
|
+
const run = async (command: string, args: string[]) => {
|
|
94
|
+
if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
|
|
95
|
+
return "";
|
|
96
|
+
};
|
|
97
|
+
await collectStickyCache(config, retention([
|
|
98
|
+
{ id: "unused-for-eight-days", lastUsed: now - 8 * 86400_000 },
|
|
99
|
+
{ id: "used-today", lastUsed: now },
|
|
100
|
+
{ id: "protected", lastUsed: 0 },
|
|
101
|
+
], ["protected"]), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
|
|
102
|
+
expect(removed).toEqual(["unused-for-eight-days"]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("pressure cannot evict the images used by the environment being published", async () => {
|
|
106
|
+
const removed: string[] = [];
|
|
107
|
+
const run = async (command: string, args: string[]) => {
|
|
108
|
+
if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
|
|
109
|
+
return "";
|
|
110
|
+
};
|
|
111
|
+
await expect(collectStickyCache(config, retention([
|
|
112
|
+
{ id: "postgres", lastUsed: now },
|
|
113
|
+
{ id: "unused", lastUsed: now - 1000 },
|
|
114
|
+
], ["postgres"]), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 20 * GiB })))
|
|
115
|
+
.rejects.toThrow("cannot reclaim required write headroom");
|
|
116
|
+
expect(removed).toEqual(["unused"]);
|
|
45
117
|
});
|
|
46
118
|
|
|
47
119
|
test("nested Kubernetes stores share the filesystem budget and are pruned before recent Docker images", async () => {
|
|
48
|
-
let used =
|
|
120
|
+
let used = 236 * GiB;
|
|
49
121
|
const calls: string[][] = [];
|
|
50
122
|
let evictions = 0;
|
|
51
123
|
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 }), {
|
|
124
|
+
await collectStickyCache(config, retention(), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }), {
|
|
53
125
|
protectedImages: new Set(), close: async () => {},
|
|
54
126
|
pruneOldest: async () => { used -= 40 * GiB; evictions++; return true; },
|
|
55
127
|
});
|
|
@@ -60,13 +132,11 @@ test("nested Kubernetes stores share the filesystem budget and are pruned before
|
|
|
60
132
|
test("GC does not remove an image while it supplies the Kubernetes maintenance daemon", async () => {
|
|
61
133
|
const removed: string[] = [];
|
|
62
134
|
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
135
|
if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
|
|
67
136
|
return "";
|
|
68
137
|
};
|
|
69
|
-
await collectStickyCache(config,
|
|
138
|
+
await collectStickyCache(config, retention([{ id: "runtime", lastUsed: 0 }, { id: "old", lastUsed: now }]),
|
|
139
|
+
run, async () => ({ totalBytes: 256 * GiB, freeBytes: (removed.length ? 220 : 20) * GiB }), {
|
|
70
140
|
protectedImages: new Set(["runtime"]), close: async () => {}, pruneOldest: async () => false,
|
|
71
141
|
});
|
|
72
142
|
expect(removed).toEqual(["old"]);
|
|
@@ -4,6 +4,7 @@ import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
import { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
6
6
|
import { openKubernetesCacheMaintenance, type KubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
7
|
+
import { recordStickyImageUse, type StickyImageRetention } from "./sticky-image-retention.js";
|
|
7
8
|
|
|
8
9
|
export const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
9
10
|
export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
@@ -12,8 +13,9 @@ export interface StickyConsumerConfig {
|
|
|
12
13
|
path: string;
|
|
13
14
|
capacityBytes: number;
|
|
14
15
|
headroomBytes: number;
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
/** Legacy controller fields, accepted for compatibility but not enforced. */
|
|
17
|
+
cacheTargetBytes?: number;
|
|
18
|
+
buildkitTargetBytes?: number;
|
|
17
19
|
compatibility: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -38,16 +40,42 @@ export function validateStickyConfig(config: StickyConsumerConfig): void {
|
|
|
38
40
|
if (config.path !== "/var/lib/spectest-sticky" || config.compatibility !== STICKY_COMPATIBILITY ||
|
|
39
41
|
!Number.isSafeInteger(config.capacityBytes) || config.capacityBytes <= 0 ||
|
|
40
42
|
!Number.isSafeInteger(config.headroomBytes) || config.headroomBytes < 16 * 1024 * 1024 ||
|
|
41
|
-
|
|
42
|
-
!Number.isSafeInteger(config.buildkitTargetBytes) || config.buildkitTargetBytes <= 0 ||
|
|
43
|
-
config.buildkitTargetBytes > config.cacheTargetBytes ||
|
|
44
|
-
config.cacheTargetBytes + config.headroomBytes >= config.capacityBytes) {
|
|
43
|
+
config.headroomBytes >= config.capacityBytes) {
|
|
45
44
|
throw new Error("unsupported sticky consumer profile or budget");
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
|
|
48
|
+
/** Explicit policy avoids BuildKit's default size caps (including its separate
|
|
49
|
+
* cache-mount/source cap). Use physical capacity as the nonbinding max: with
|
|
50
|
+
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
51
|
+
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
52
|
+
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
53
|
+
export function stickyBuildkitConfig(config: StickyConsumerConfig): string {
|
|
54
|
+
validateStickyConfig(config);
|
|
55
|
+
return `root = "${config.path}/buildkit"
|
|
56
|
+
[grpc]
|
|
57
|
+
address = ["tcp://127.0.0.1:1234"]
|
|
58
|
+
[worker.oci]
|
|
59
|
+
enabled = false
|
|
60
|
+
[worker.containerd]
|
|
61
|
+
enabled = true
|
|
62
|
+
address = "/run/containerd/containerd.sock"
|
|
63
|
+
namespace = "moby"
|
|
64
|
+
snapshotter = "overlayfs"
|
|
65
|
+
gc = true
|
|
66
|
+
[[worker.containerd.gcpolicy]]
|
|
67
|
+
all = true
|
|
68
|
+
maxUsedSpace = ${config.capacityBytes}
|
|
69
|
+
minFreeSpace = ${config.headroomBytes}
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
export async function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void> {
|
|
50
74
|
validateStickyConfig(config);
|
|
75
|
+
// Do not persist obsolete caps supplied by an older controller. Maintenance
|
|
76
|
+
// also ignores them when restoring a snapshot written by an older SDK.
|
|
77
|
+
config = { path: config.path, capacityBytes: config.capacityBytes,
|
|
78
|
+
headroomBytes: config.headroomBytes, compatibility: config.compatibility };
|
|
51
79
|
await exec("mountpoint", ["-q", config.path]);
|
|
52
80
|
// The base must have stopped these before its RAM was captured. Refuse an
|
|
53
81
|
// unexpected writer rather than racing it for the standard daemon sockets.
|
|
@@ -73,21 +101,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
73
101
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
74
102
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
75
103
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
76
|
-
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`,
|
|
77
|
-
[grpc]
|
|
78
|
-
address = ["tcp://127.0.0.1:1234"]
|
|
79
|
-
[worker.oci]
|
|
80
|
-
enabled = false
|
|
81
|
-
[worker.containerd]
|
|
82
|
-
enabled = true
|
|
83
|
-
address = "/run/containerd/containerd.sock"
|
|
84
|
-
namespace = "moby"
|
|
85
|
-
snapshotter = "overlayfs"
|
|
86
|
-
gc = true
|
|
87
|
-
reservedSpace = "1GB"
|
|
88
|
-
maxUsedSpace = ${config.buildkitTargetBytes}
|
|
89
|
-
minFreeSpace = ${config.headroomBytes}
|
|
90
|
-
`);
|
|
104
|
+
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
|
|
91
105
|
const start = async (unit: string, program: string, args: string[]) => {
|
|
92
106
|
await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
|
|
93
107
|
// Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
|
|
@@ -113,6 +127,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
113
127
|
*/
|
|
114
128
|
export async function collectStickyCache(
|
|
115
129
|
config: StickyConsumerConfig,
|
|
130
|
+
retention: StickyImageRetention,
|
|
116
131
|
run: typeof exec = exec,
|
|
117
132
|
usage = async (): Promise<{ freeBytes: number; totalBytes: number }> => {
|
|
118
133
|
const fs = await statfs(config.path);
|
|
@@ -123,16 +138,24 @@ export async function collectStickyCache(
|
|
|
123
138
|
validateStickyConfig(config);
|
|
124
139
|
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
125
140
|
await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
141
|
+
// Docker's until filter and Created field refer to the publisher's build
|
|
142
|
+
// date, not our use. Never prune a just-used image merely because it is old.
|
|
143
|
+
const protectedImage = (id: string) => retention.protectedImages.has(id) || kubernetes?.protectedImages.has(id);
|
|
144
|
+
const images = [...retention.images].sort((a, b) => a.lastUsed - b.lastUsed || a.id.localeCompare(b.id));
|
|
145
|
+
const expired = new Set<string>();
|
|
146
|
+
for (const image of images) {
|
|
147
|
+
if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 7 * 24 * 60 * 60 * 1000) continue;
|
|
148
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
149
|
+
expired.add(image.id);
|
|
150
|
+
}
|
|
151
|
+
const withinTarget = (fs: { freeBytes: number }) => fs.freeBytes >= config.headroomBytes;
|
|
129
152
|
let fs = await usage();
|
|
130
|
-
|
|
131
|
-
//
|
|
153
|
+
if (withinTarget(fs)) return;
|
|
154
|
+
// All consumers share this filesystem. Reclaim only the missing headroom;
|
|
155
|
+
// a per-consumer cap evicts useful cache even when the disk is mostly empty.
|
|
132
156
|
const pruneBuildkit = async () => {
|
|
133
|
-
await run("buildctl", [...buildctl, "--all", "--
|
|
134
|
-
String(Math.
|
|
135
|
-
String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
|
|
157
|
+
await run("buildctl", [...buildctl, "--all", "--free-storage",
|
|
158
|
+
String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
|
|
136
159
|
fs = await usage();
|
|
137
160
|
};
|
|
138
161
|
await pruneBuildkit();
|
|
@@ -141,21 +164,19 @@ export async function collectStickyCache(
|
|
|
141
164
|
fs = await usage();
|
|
142
165
|
if (withinTarget(fs)) return;
|
|
143
166
|
}
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
|
|
147
|
-
const images: { Id: string; Created: string }[] = ids.length
|
|
148
|
-
? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
|
|
149
|
-
images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
|
|
167
|
+
// Under pressure, evict least recently used images, including within the
|
|
168
|
+
// age window, but preserve this environment's images for the next build.
|
|
150
169
|
for (const image of images) {
|
|
151
|
-
if (
|
|
152
|
-
await run("docker", ["image", "rm", "-f", image.
|
|
170
|
+
if (expired.has(image.id) || protectedImage(image.id)) continue;
|
|
171
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
172
|
+
fs = await usage();
|
|
173
|
+
if (withinTarget(fs)) return;
|
|
153
174
|
await pruneBuildkit();
|
|
154
175
|
if (withinTarget(fs)) return;
|
|
155
176
|
}
|
|
156
177
|
// Containerd GC may lag reference removal; allow bounded additional passes.
|
|
157
178
|
for (let pass = 0; pass < 3 && !withinTarget(fs); pass++) await pruneBuildkit();
|
|
158
|
-
if (!withinTarget(fs)) throw new Error("sticky cache cannot
|
|
179
|
+
if (!withinTarget(fs)) throw new Error("sticky cache cannot reclaim required write headroom");
|
|
159
180
|
}
|
|
160
181
|
|
|
161
182
|
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
@@ -163,12 +184,13 @@ export async function prepareStickyConsumer(artifact = false): Promise<{ prepare
|
|
|
163
184
|
const config: StickyConsumerConfig = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
|
|
164
185
|
validateStickyConfig(config);
|
|
165
186
|
if (!artifact) {
|
|
166
|
-
const
|
|
187
|
+
const retention = await recordStickyImageUse(config.path, exec);
|
|
188
|
+
const { containers } = retention;
|
|
167
189
|
if (containers.length) await exec("docker", ["rm", "-f", ...containers]);
|
|
168
190
|
const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
|
|
169
191
|
if (volumes.length) await exec("docker", ["volume", "rm", ...volumes]);
|
|
170
192
|
const kubernetes = await openKubernetesCacheMaintenance(exec);
|
|
171
|
-
try { await collectStickyCache(config, exec, undefined, kubernetes); }
|
|
193
|
+
try { await collectStickyCache(config, retention, exec, undefined, kubernetes); }
|
|
172
194
|
finally { await kubernetes.close(); }
|
|
173
195
|
}
|
|
174
196
|
const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
|