@specific.dev/spectest 0.90.3 → 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/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/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/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/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
package/src/daemon.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
1
2
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
|
|
2
3
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
3
4
|
// a container). Owns:
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
dnsName as makeDnsDecl,
|
|
36
37
|
isWildcard,
|
|
37
38
|
validateServiceImage,
|
|
39
|
+
normalizeServiceImage,
|
|
38
40
|
proxy as makeProxyDecl,
|
|
39
41
|
} from "./index.js";
|
|
40
42
|
import type { DnsTarget, LoweredIngress } from "./index.js";
|
|
@@ -154,6 +156,7 @@ import {
|
|
|
154
156
|
} from "./harness/ingress-table.js";
|
|
155
157
|
import { startTlsTerminator, type TlsTerminator } from "./harness/tls-terminator.js";
|
|
156
158
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
159
|
+
import { isOnStickyDisk } from "./harness/sticky-paths.js";
|
|
157
160
|
import {
|
|
158
161
|
BUILDKIT_CACHE_DIR,
|
|
159
162
|
BUILDKIT_CACHE_UNION,
|
|
@@ -239,7 +242,7 @@ import type {
|
|
|
239
242
|
ServiceConfig,
|
|
240
243
|
ServiceDefinition,
|
|
241
244
|
ServiceHandles,
|
|
242
|
-
|
|
245
|
+
NormalizedServiceImage,
|
|
243
246
|
SpectestContext,
|
|
244
247
|
Terminal,
|
|
245
248
|
TerminalOpts,
|
|
@@ -256,10 +259,21 @@ import type { InternalTerminal, TerminalFrameSink } from "./terminal.js";
|
|
|
256
259
|
* name (container name, image tag, ready-probe host). Widened to
|
|
257
260
|
* accept any helpers shape so a typed `setup({ helpers })` call from
|
|
258
261
|
* a component (e.g. K3sHelpers) lines up at the call site. */
|
|
259
|
-
type NamedService = ServiceDefinition<Record<string, unknown
|
|
262
|
+
type NamedService = Omit<ServiceDefinition<Record<string, unknown>>, "image"> & { name: string; image: NormalizedServiceImage };
|
|
260
263
|
|
|
261
264
|
function namedServices(cfg: EnvironmentConfig): NamedService[] {
|
|
262
|
-
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
|
|
265
|
+
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def, image: normalizeServiceImage(def.image) }));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Build tasks participate in image preparation, never container startup. */
|
|
269
|
+
function imageTasks(services: NamedService[]): NamedService[] {
|
|
270
|
+
const tasks = componentBuildTasks(services).map(task => ({ ...task, image: validateServiceImage(task.name, task.image) }));
|
|
271
|
+
const names = new Set(services.map(s => s.name));
|
|
272
|
+
for (const task of tasks) {
|
|
273
|
+
if (names.has(task.name)) throw new Error(`image task name conflicts with service: ${task.name}`);
|
|
274
|
+
names.add(task.name);
|
|
275
|
+
}
|
|
276
|
+
return [...services, ...tasks];
|
|
263
277
|
}
|
|
264
278
|
|
|
265
279
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
@@ -977,7 +991,7 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
|
|
|
977
991
|
// A directory on a cache disk is the same kind of thing: a nested
|
|
978
992
|
// runtime's containerd root (`k3s()`), kept as a cache by the
|
|
979
993
|
// lineage exactly as the container store one level up is.
|
|
980
|
-
const durable = host.startsWith("/var/cache/spectest/") || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
994
|
+
const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
981
995
|
if (vol.source?.startsWith("/") && !durable) {
|
|
982
996
|
await recordAbsoluteVolumeDir(host);
|
|
983
997
|
}
|
|
@@ -1365,6 +1379,18 @@ async function resolveDockerfileBuild(svc: NamedService): Promise<DockerfileBuil
|
|
|
1365
1379
|
async function prepareServiceImage(
|
|
1366
1380
|
svc: NamedService,
|
|
1367
1381
|
opts?: { dedup?: boolean },
|
|
1382
|
+
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1383
|
+
const result = await prepareServiceImageInner(svc, opts);
|
|
1384
|
+
if (svc.image.type === "dockerfile" && svc.image.reference) {
|
|
1385
|
+
const tagged = await docker(["tag", result.tag, svc.image.reference], 30_000);
|
|
1386
|
+
if (tagged.code !== 0) throw new Error(`cannot name built image ${svc.image.reference}: ${tagged.stderr}`);
|
|
1387
|
+
}
|
|
1388
|
+
return result;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
async function prepareServiceImageInner(
|
|
1392
|
+
svc: NamedService,
|
|
1393
|
+
opts?: { dedup?: boolean },
|
|
1368
1394
|
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
1369
1395
|
const tag = imageTag(svc.name);
|
|
1370
1396
|
if (svc.image.type === "registry") {
|
|
@@ -1414,7 +1440,7 @@ async function prepareServiceImage(
|
|
|
1414
1440
|
progressService(svc.name, { status: "failed" });
|
|
1415
1441
|
throw err;
|
|
1416
1442
|
}
|
|
1417
|
-
//
|
|
1443
|
+
// Reuse the same application image; runtime trust is added separately.
|
|
1418
1444
|
const tagr = await docker(["tag", first.tag, tag], 30_000);
|
|
1419
1445
|
if (tagr.code !== 0) {
|
|
1420
1446
|
throw new Error(`docker tag ${first.tag} ${tag} failed: ${tagr.stderr.trim()}`);
|
|
@@ -3175,7 +3201,8 @@ function specToNamedService(spec: RuntimeServiceSpec): NamedService {
|
|
|
3175
3201
|
// run here: the image is checked (a `path` Dockerfile and a `context`
|
|
3176
3202
|
// must exist), and coverage adapters get their configure step.
|
|
3177
3203
|
const svc = { ...(rest as ServiceConfig), image: validateServiceImage(name, rest.image) };
|
|
3178
|
-
|
|
3204
|
+
const configured = applyCoverageAdapters(name, svc);
|
|
3205
|
+
return { name, ...configured, image: normalizeServiceImage(configured.image) };
|
|
3179
3206
|
}
|
|
3180
3207
|
|
|
3181
3208
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
@@ -3507,12 +3534,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3507
3534
|
const bootStart = Date.now();
|
|
3508
3535
|
const cfg: EnvironmentConfig = requireLoaded().project.environment;
|
|
3509
3536
|
const services = namedServices(cfg);
|
|
3537
|
+
const tasks = imageTasks(services);
|
|
3538
|
+
const componentTasks = componentBuildTasks(services);
|
|
3510
3539
|
const timings = new Map<string, ServiceTiming>();
|
|
3511
3540
|
if (prepared && (prepared.version !== 1 || !prepared.images ||
|
|
3512
|
-
Object.keys(prepared.images).sort().join("\n") !==
|
|
3541
|
+
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
3513
3542
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
3514
3543
|
}
|
|
3515
|
-
progressInit(
|
|
3544
|
+
progressInit(tasks);
|
|
3516
3545
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
3517
3546
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
3518
3547
|
// text but different build-context content.
|
|
@@ -3535,7 +3564,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3535
3564
|
if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null) return;
|
|
3536
3565
|
await fs.writeFile(
|
|
3537
3566
|
path.join(WORKSPACE, ".dockerignore"),
|
|
3538
|
-
unionDockerignore(
|
|
3567
|
+
unionDockerignore(tasks),
|
|
3539
3568
|
);
|
|
3540
3569
|
})(),
|
|
3541
3570
|
]);
|
|
@@ -3572,11 +3601,14 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3572
3601
|
if (found.code !== 0 || found.stdout.trim() !== entry.image) {
|
|
3573
3602
|
throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
|
|
3574
3603
|
}
|
|
3575
|
-
|
|
3604
|
+
const tag = imageTag(svc.name);
|
|
3605
|
+
const tagged = await docker(["tag", entry.image, tag], 30_000);
|
|
3606
|
+
if (tagged.code !== 0) throw new Error(`cannot restore prepared image name for ${svc.name}: ${tagged.stderr}`);
|
|
3607
|
+
return { tag, buildSteps: entry.buildSteps };
|
|
3576
3608
|
};
|
|
3577
3609
|
const prep = new Map<string, Promise<void>>();
|
|
3578
3610
|
const prepOffsets = new Map<string, number>();
|
|
3579
|
-
for (const svc of
|
|
3611
|
+
for (const svc of tasks) {
|
|
3580
3612
|
const p = (async () => {
|
|
3581
3613
|
const t0 = Date.now();
|
|
3582
3614
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
@@ -3602,7 +3634,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3602
3634
|
// No application services, setup hooks, ingress, or runtime state in the builder.
|
|
3603
3635
|
await Promise.all(prep.values());
|
|
3604
3636
|
const images: PreparedImages["images"] = Object.create(null);
|
|
3605
|
-
for (const svc of
|
|
3637
|
+
for (const svc of tasks) {
|
|
3606
3638
|
const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
|
|
3607
3639
|
const image = out.stdout.trim();
|
|
3608
3640
|
if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
|
|
@@ -3654,6 +3686,8 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3654
3686
|
progressService(svc.name, { status: "probing", detail: "ready check" });
|
|
3655
3687
|
await waitForReady(svc);
|
|
3656
3688
|
const tSetup = Date.now();
|
|
3689
|
+
const builds = componentTasks.filter(task => task.owner === svc.name);
|
|
3690
|
+
await Promise.all(builds.map(task => prep.get(task.name)!));
|
|
3657
3691
|
if (svc.setup) {
|
|
3658
3692
|
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3659
3693
|
const helpers = await ensureHelpers(svc.name, svc);
|
|
@@ -3662,11 +3696,17 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
|
|
|
3662
3696
|
name: svc.name,
|
|
3663
3697
|
helpers,
|
|
3664
3698
|
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3699
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3700
|
+
images: builds.map(task => ({ source: tags.get(task.name)!, reference: task.reference })),
|
|
3701
|
+
gateway: await bridgeGatewayIp(),
|
|
3702
|
+
caBundle: await ensureCaBundle(),
|
|
3703
|
+
} },
|
|
3665
3704
|
});
|
|
3666
3705
|
} catch (err) {
|
|
3667
3706
|
throw await withContainerPostMortem(svc.name, err);
|
|
3668
3707
|
}
|
|
3669
3708
|
}
|
|
3709
|
+
for (const task of builds) progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
|
|
3670
3710
|
progressService(svc.name, { status: "ready", detail: undefined });
|
|
3671
3711
|
const ti = timings.get(svc.name);
|
|
3672
3712
|
if (ti) {
|
|
@@ -4223,9 +4263,10 @@ async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
|
|
|
4223
4263
|
async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
|
|
4224
4264
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
4225
4265
|
const sources: ReachSource[] = [];
|
|
4226
|
-
|
|
4266
|
+
const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
|
|
4267
|
+
for (const svc of imageTasks(all)) {
|
|
4227
4268
|
const src: ReachSource = {
|
|
4228
|
-
service: svc.name,
|
|
4269
|
+
service: owners.get(svc.name) ?? svc.name,
|
|
4229
4270
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
4230
4271
|
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
4231
4272
|
};
|
|
@@ -6720,7 +6761,7 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6720
6761
|
bootstrap: async (params) =>
|
|
6721
6762
|
exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
|
|
6722
6763
|
const prepared = params.prepared === undefined ? undefined : preparedImages(
|
|
6723
|
-
params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name),
|
|
6764
|
+
params.prepared, params.identity, imageTasks(namedServices(requireLoaded().project.environment)).map(s => s.name),
|
|
6724
6765
|
);
|
|
6725
6766
|
return { ok: true, timings: await bootstrap(prepared) };
|
|
6726
6767
|
}),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { canonicalImageReference, componentBuildTasks, kubernetesImages } from "./component-images";
|
|
3
|
+
import { validateServiceImage, type BuildImage } from "../index";
|
|
4
|
+
|
|
5
|
+
test("services and Kubernetes share all Dockerfile build options without a type discriminator", () => {
|
|
6
|
+
const image: BuildImage & { reference: string } = {
|
|
7
|
+
reference: "k8s.internal:5000/runtime:spectest", content: "FROM scratch AS test",
|
|
8
|
+
target: "test", context: ".", exclude: ["fixtures"], buildArgs: { MODE: "test" },
|
|
9
|
+
};
|
|
10
|
+
expect(validateServiceImage("app", { ...image, context: undefined })).toEqual({ ...image, context: undefined, type: "dockerfile" });
|
|
11
|
+
expect(validateServiceImage("redis", "redis:7.4")).toEqual({ type: "registry", reference: "redis:7.4" });
|
|
12
|
+
const parsed = kubernetesImages(["redis:7.4", image]);
|
|
13
|
+
expect(parsed.pulls).toEqual(["redis:7.4"]);
|
|
14
|
+
expect(parsed.builds).toEqual([{ reference: image.reference, image }]);
|
|
15
|
+
const tasks = componentBuildTasks([{ name: "cluster", image: "k3s:latest", imageBuilds: parsed.builds }]);
|
|
16
|
+
expect(tasks[0].image).toBe(image);
|
|
17
|
+
expect(tasks[0].owner).toBe("cluster");
|
|
18
|
+
expect(tasks[0].name).toMatch(/^spectest-image-[a-f0-9]+$/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("image identity validates references and rejects conflicting definitions", () => {
|
|
22
|
+
expect(canonicalImageReference("redis:7.4")).toBe("docker.io/library/redis:7.4");
|
|
23
|
+
expect(canonicalImageReference("docker.io/redis:7.4")).toBe("docker.io/library/redis:7.4");
|
|
24
|
+
expect(canonicalImageReference("company/api")).toBe("docker.io/company/api:latest");
|
|
25
|
+
expect(canonicalImageReference("localhost:5000/api")).toBe("localhost:5000/api:latest");
|
|
26
|
+
expect(kubernetesImages(["redis:7.4", "redis:7.4"]).pulls).toHaveLength(1);
|
|
27
|
+
for (const value of ["", "--help", "https://example.com/api", "has space", "bad:tag:extra"]) {
|
|
28
|
+
expect(() => kubernetesImages([value])).toThrow();
|
|
29
|
+
}
|
|
30
|
+
expect(() => kubernetesImages([{ content: "FROM scratch" } as never])).toThrow("reference");
|
|
31
|
+
expect(() => kubernetesImages([{ type: "registry", reference: "redis:7" } as never])).toThrow();
|
|
32
|
+
expect(() => kubernetesImages([{ reference: `api@sha256:${"a".repeat(64)}`, content: "FROM scratch" }])).toThrow("digest");
|
|
33
|
+
expect(() => kubernetesImages(["api:1", { reference: "docker.io/library/api:1", content: "FROM scratch" }])).toThrow("conflicting");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("build task identity survives list reordering but is scoped to the owning cluster", () => {
|
|
37
|
+
const a = { reference: "app:1", image: { content: "FROM scratch" } };
|
|
38
|
+
const b = { reference: "app:2", image: { content: "FROM scratch" } };
|
|
39
|
+
const tasks = (name: string, imageBuilds: typeof a[]) => componentBuildTasks([{ name, image: "k3s:1", imageBuilds }]);
|
|
40
|
+
expect(tasks("cluster", [a, b])[0].name).toBe(tasks("cluster", [b, a])[1].name);
|
|
41
|
+
expect(tasks("cluster", [a])[0].name).not.toBe(tasks("other", [a])[0].name);
|
|
42
|
+
expect(() => validateServiceImage("app", { content: "FROM scratch", buildArgs: { "bad-key": "x" } })).toThrow("ARG");
|
|
43
|
+
expect(() => validateServiceImage("app", { content: "FROM scratch", exclude: [1] as never })).toThrow("exclude");
|
|
44
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
|
|
3
|
+
|
|
4
|
+
export type KubernetesImage = string | (BuildImage & { reference: string });
|
|
5
|
+
|
|
6
|
+
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
7
|
+
export function canonicalImageReference(reference: string): string {
|
|
8
|
+
if (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)) {
|
|
9
|
+
throw new Error("image reference must be a non-empty image name without whitespace");
|
|
10
|
+
}
|
|
11
|
+
const slash = reference.indexOf("/");
|
|
12
|
+
const first = slash < 0 ? "" : reference.slice(0, slash);
|
|
13
|
+
let result = slash < 0 ? `docker.io/library/${reference}`
|
|
14
|
+
: first.includes(".") || first.includes(":") || first === "localhost" ? reference : `docker.io/${reference}`;
|
|
15
|
+
if (result.startsWith("index.docker.io/")) result = result.replace("index.docker.io/", "docker.io/");
|
|
16
|
+
if (result.startsWith("docker.io/") && !result.slice(10).includes("/")) result = `docker.io/library/${result.slice(10)}`;
|
|
17
|
+
const [name, digest] = result.split("@");
|
|
18
|
+
const tagAt = name.lastIndexOf(":") > name.lastIndexOf("/") ? name.lastIndexOf(":") : -1;
|
|
19
|
+
const repository = tagAt < 0 ? name : name.slice(0, tagAt);
|
|
20
|
+
if (!/^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$/.test(repository)
|
|
21
|
+
|| (tagAt >= 0 && !/^[\w][\w.-]{0,127}$/.test(name.slice(tagAt + 1)))
|
|
22
|
+
|| (result.includes("@") && (!digest || !/^sha256:[a-f0-9]{64}$/.test(digest) || result.split("@").length !== 2))) {
|
|
23
|
+
throw new Error(`invalid image reference: ${reference}`);
|
|
24
|
+
}
|
|
25
|
+
if (tagAt < 0 && !digest) result += ":latest";
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: string[]; builds: ComponentImageBuild[] } {
|
|
30
|
+
if (!Array.isArray(images)) throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
|
|
31
|
+
const pulls: string[] = [];
|
|
32
|
+
const builds: ComponentImageBuild[] = [];
|
|
33
|
+
const seen = new Map<string, string>();
|
|
34
|
+
for (const image of images) {
|
|
35
|
+
if (typeof image !== "string" && (!image || typeof image !== "object" || "type" in image || !image.reference)) {
|
|
36
|
+
throw new Error("k3s(): images expects registry strings or Dockerfile builds with a reference");
|
|
37
|
+
}
|
|
38
|
+
const reference = canonicalImageReference(typeof image === "string" ? image : image.reference);
|
|
39
|
+
if (typeof image !== "string" && reference.includes("@")) throw new Error("k3s(): a build reference must be an image name/tag, not a digest");
|
|
40
|
+
const identity = JSON.stringify(image);
|
|
41
|
+
const previous = seen.get(reference);
|
|
42
|
+
if (previous !== undefined) {
|
|
43
|
+
if (previous !== identity) throw new Error(`k3s(): conflicting images for ${reference}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
seen.set(reference, identity);
|
|
47
|
+
if (typeof image === "string") pulls.push(image);
|
|
48
|
+
else builds.push({ reference, image });
|
|
49
|
+
}
|
|
50
|
+
return { pulls, builds };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Stable internal task names, separate from the service graph. */
|
|
54
|
+
export function componentBuildTasks<T extends ServiceConfig & { name: string }>(services: readonly T[]) {
|
|
55
|
+
return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
|
|
56
|
+
name: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
|
|
57
|
+
owner: svc.name,
|
|
58
|
+
reference: build.reference,
|
|
59
|
+
image: build.image,
|
|
60
|
+
})));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Private handoff to a component's setup hook. Builds stay generic; the
|
|
64
|
+
* component decides how its runtime consumes them and establishes trust. */
|
|
65
|
+
export const COMPONENT_RUNTIME = Symbol.for("spectest.component-runtime");
|
|
66
|
+
export interface ComponentRuntime {
|
|
67
|
+
images: readonly { source: string; reference: string }[];
|
|
68
|
+
gateway: string;
|
|
69
|
+
caBundle: string | null;
|
|
70
|
+
}
|
|
71
|
+
export function componentRuntime(context: object): ComponentRuntime | undefined {
|
|
72
|
+
return (context as { [COMPONENT_RUNTIME]?: ComponentRuntime })[COMPONENT_RUNTIME];
|
|
73
|
+
}
|
|
@@ -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) {
|