@specific.dev/spectest 0.90.3 → 0.91.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/dist/android-step.js +2 -2
- 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 +126 -53
- package/dist/daemon.js +71 -28
- package/dist/harness/component-images.d.ts +34 -0
- package/dist/harness/component-images.js +69 -0
- package/dist/harness/concurrency.d.ts +2 -0
- package/dist/harness/concurrency.js +9 -0
- package/dist/harness/k3s-image-loader.d.ts +3 -0
- package/dist/harness/k3s-image-loader.js +152 -0
- package/dist/harness/k3s-prepull.d.ts +8 -0
- package/dist/harness/k3s-prepull.js +35 -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/android-step.test.ts +3 -3
- package/src/android-step.ts +2 -2
- package/src/android.test.ts +3 -3
- package/src/components/index.ts +1 -0
- package/src/components/k3s-concurrency.test.ts +60 -0
- package/src/components/k3s-trust.test.ts +81 -0
- package/src/components/k3s-trust.ts +135 -0
- package/src/components/k3s.ts +131 -51
- package/src/daemon.ts +72 -27
- package/src/harness/component-images.test.ts +44 -0
- package/src/harness/component-images.ts +79 -0
- package/src/harness/concurrency.ts +8 -0
- package/src/harness/k3s-image-loader.ts +131 -0
- package/src/harness/k3s-prepull.test.ts +57 -0
- package/src/harness/k3s-prepull.ts +33 -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/dist/daemon.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
|
|
2
|
+
import { waitForAll } from "./harness/concurrency.js";
|
|
1
3
|
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
2
4
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
3
5
|
// a container). Owns:
|
|
@@ -24,7 +26,7 @@ import net from "node:net";
|
|
|
24
26
|
import path from "node:path";
|
|
25
27
|
import { pathToFileURL } from "node:url";
|
|
26
28
|
import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
27
|
-
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
29
|
+
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, normalizeServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
28
30
|
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, READS_REPORT, } from "./harness/coverage.js";
|
|
29
31
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
30
32
|
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
@@ -57,6 +59,7 @@ import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectC
|
|
|
57
59
|
import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
|
|
58
60
|
import { startTlsTerminator } from "./harness/tls-terminator.js";
|
|
59
61
|
import { runContainerArgs } from "./harness/container-run.js";
|
|
62
|
+
import { isOnStickyDisk } from "./harness/sticky-paths.js";
|
|
60
63
|
import { BUILDKIT_CACHE_DIR, BUILDKIT_CACHE_UNION, IMAGE_CACHE_MANIFEST, imageCachePathsSync, isOnImageCache, mergeCacheIndex, } from "./harness/image-cache.js";
|
|
61
64
|
import { assertAbsolute, certificateHostnames, defaultKeyMode, expandServiceToken, isNoopChown, mountFlag, needsIdTables, numericId, resolveChownIds, } from "./harness/file-mounts.js";
|
|
62
65
|
import { badRequest, conflict, notFound, requireString, } from "./harness/methods.js";
|
|
@@ -70,7 +73,18 @@ import { deepUnwrap, readRaw, wrap } from "./inspect.js";
|
|
|
70
73
|
import { setRecordSecrets } from "./record-secrets.js";
|
|
71
74
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
72
75
|
function namedServices(cfg) {
|
|
73
|
-
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def }));
|
|
76
|
+
return Object.entries(cfg.services).map(([name, def]) => ({ name, ...def, image: normalizeServiceImage(def.image) }));
|
|
77
|
+
}
|
|
78
|
+
/** Build tasks participate in image preparation, never container startup. */
|
|
79
|
+
function imageTasks(services) {
|
|
80
|
+
const tasks = componentBuildTasks(services).map(task => ({ ...task, image: validateServiceImage(task.name, task.image) }));
|
|
81
|
+
const names = new Set(services.map(s => s.name));
|
|
82
|
+
for (const task of tasks) {
|
|
83
|
+
if (names.has(task.name))
|
|
84
|
+
throw new Error(`image task name conflicts with service: ${task.name}`);
|
|
85
|
+
names.add(task.name);
|
|
86
|
+
}
|
|
87
|
+
return [...services, ...tasks];
|
|
74
88
|
}
|
|
75
89
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
76
90
|
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
@@ -677,7 +691,7 @@ async function ensureVolumes(svc) {
|
|
|
677
691
|
// A directory on a cache disk is the same kind of thing: a nested
|
|
678
692
|
// runtime's containerd root (`k3s()`), kept as a cache by the
|
|
679
693
|
// lineage exactly as the container store one level up is.
|
|
680
|
-
const durable = host.startsWith("/var/cache/spectest/") || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
694
|
+
const durable = host.startsWith("/var/cache/spectest/") || isOnStickyDisk(host) || isOnImageCache(host, imageCachePathsSync(IMAGE_CACHE_MANIFEST));
|
|
681
695
|
if (vol.source?.startsWith("/") && !durable) {
|
|
682
696
|
await recordAbsoluteVolumeDir(host);
|
|
683
697
|
}
|
|
@@ -1013,6 +1027,15 @@ async function resolveDockerfileBuild(svc) {
|
|
|
1013
1027
|
};
|
|
1014
1028
|
}
|
|
1015
1029
|
async function prepareServiceImage(svc, opts) {
|
|
1030
|
+
const result = await prepareServiceImageInner(svc, opts);
|
|
1031
|
+
if (svc.image.type === "dockerfile" && svc.image.reference) {
|
|
1032
|
+
const tagged = await docker(["tag", result.tag, svc.image.reference], 30_000);
|
|
1033
|
+
if (tagged.code !== 0)
|
|
1034
|
+
throw new Error(`cannot name built image ${svc.image.reference}: ${tagged.stderr}`);
|
|
1035
|
+
}
|
|
1036
|
+
return result;
|
|
1037
|
+
}
|
|
1038
|
+
async function prepareServiceImageInner(svc, opts) {
|
|
1016
1039
|
const tag = imageTag(svc.name);
|
|
1017
1040
|
if (svc.image.type === "registry") {
|
|
1018
1041
|
const ref = svc.image.reference;
|
|
@@ -1062,7 +1085,7 @@ async function prepareServiceImage(svc, opts) {
|
|
|
1062
1085
|
progressService(svc.name, { status: "failed" });
|
|
1063
1086
|
throw err;
|
|
1064
1087
|
}
|
|
1065
|
-
//
|
|
1088
|
+
// Reuse the same application image; runtime trust is added separately.
|
|
1066
1089
|
const tagr = await docker(["tag", first.tag, tag], 30_000);
|
|
1067
1090
|
if (tagr.code !== 0) {
|
|
1068
1091
|
throw new Error(`docker tag ${first.tag} ${tag} failed: ${tagr.stderr.trim()}`);
|
|
@@ -2640,7 +2663,8 @@ function specToNamedService(spec) {
|
|
|
2640
2663
|
// run here: the image is checked (a `path` Dockerfile and a `context`
|
|
2641
2664
|
// must exist), and coverage adapters get their configure step.
|
|
2642
2665
|
const svc = { ...rest, image: validateServiceImage(name, rest.image) };
|
|
2643
|
-
|
|
2666
|
+
const configured = applyCoverageAdapters(name, svc);
|
|
2667
|
+
return { name, ...configured, image: normalizeServiceImage(configured.image) };
|
|
2644
2668
|
}
|
|
2645
2669
|
/** Implementation behind `ctx.startService` / a fake's `ctx.startService`.
|
|
2646
2670
|
* Prepares the image (pulling on first use through the host cache), runs
|
|
@@ -2931,12 +2955,14 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2931
2955
|
const bootStart = Date.now();
|
|
2932
2956
|
const cfg = requireLoaded().project.environment;
|
|
2933
2957
|
const services = namedServices(cfg);
|
|
2958
|
+
const tasks = imageTasks(services);
|
|
2959
|
+
const componentTasks = componentBuildTasks(services);
|
|
2934
2960
|
const timings = new Map();
|
|
2935
2961
|
if (prepared && (prepared.version !== 1 || !prepared.images ||
|
|
2936
|
-
Object.keys(prepared.images).sort().join("\n") !==
|
|
2962
|
+
Object.keys(prepared.images).sort().join("\n") !== tasks.map(s => s.name).sort().join("\n"))) {
|
|
2937
2963
|
throw new Error("prepared image manifest does not match the environment's services");
|
|
2938
2964
|
}
|
|
2939
|
-
progressInit(
|
|
2965
|
+
progressInit(tasks);
|
|
2940
2966
|
// Build dedup is only valid within one workspace generation — a fresh
|
|
2941
2967
|
// bootstrap may follow a workspace re-upload with the same dockerfile
|
|
2942
2968
|
// text but different build-context content.
|
|
@@ -2957,7 +2983,7 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2957
2983
|
// that reads it.
|
|
2958
2984
|
if ((await readIgnoreFile(path.join(WORKSPACE, ".dockerignore"))) !== null)
|
|
2959
2985
|
return;
|
|
2960
|
-
await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(
|
|
2986
|
+
await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(tasks));
|
|
2961
2987
|
})(),
|
|
2962
2988
|
]);
|
|
2963
2989
|
// Image prep is DECOUPLED from container start: every service's image
|
|
@@ -2993,11 +3019,15 @@ async function bootstrapInner(prepared, identity) {
|
|
|
2993
3019
|
if (found.code !== 0 || found.stdout.trim() !== entry.image) {
|
|
2994
3020
|
throw new Error(`prepared image missing for ${svc.name}: ${entry.image}`);
|
|
2995
3021
|
}
|
|
2996
|
-
|
|
3022
|
+
const tag = imageTag(svc.name);
|
|
3023
|
+
const tagged = await docker(["tag", entry.image, tag], 30_000);
|
|
3024
|
+
if (tagged.code !== 0)
|
|
3025
|
+
throw new Error(`cannot restore prepared image name for ${svc.name}: ${tagged.stderr}`);
|
|
3026
|
+
return { tag, buildSteps: entry.buildSteps };
|
|
2997
3027
|
};
|
|
2998
3028
|
const prep = new Map();
|
|
2999
3029
|
const prepOffsets = new Map();
|
|
3000
|
-
for (const svc of
|
|
3030
|
+
for (const svc of tasks) {
|
|
3001
3031
|
const p = (async () => {
|
|
3002
3032
|
const t0 = Date.now();
|
|
3003
3033
|
prepOffsets.set(svc.name, Date.now() - bootStart);
|
|
@@ -3022,7 +3052,7 @@ async function bootstrapInner(prepared, identity) {
|
|
|
3022
3052
|
// No application services, setup hooks, ingress, or runtime state in the builder.
|
|
3023
3053
|
await Promise.all(prep.values());
|
|
3024
3054
|
const images = Object.create(null);
|
|
3025
|
-
for (const svc of
|
|
3055
|
+
for (const svc of tasks) {
|
|
3026
3056
|
const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)], 30_000);
|
|
3027
3057
|
const image = out.stdout.trim();
|
|
3028
3058
|
if (out.code !== 0 || !/^sha256:[a-f0-9]{64}$/.test(image)) {
|
|
@@ -3073,20 +3103,32 @@ async function bootstrapInner(prepared, identity) {
|
|
|
3073
3103
|
progressService(svc.name, { status: "probing", detail: "ready check" });
|
|
3074
3104
|
await waitForReady(svc);
|
|
3075
3105
|
const tSetup = Date.now();
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
helpers,
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3106
|
+
const builds = componentTasks.filter(task => task.owner === svc.name);
|
|
3107
|
+
// Setup can overlap pulls/infrastructure with builds, and consume each
|
|
3108
|
+
// image as it becomes ready. Still drain every build if setup fails.
|
|
3109
|
+
await waitForAll([...builds.map(task => prep.get(task.name)), (async () => {
|
|
3110
|
+
if (svc.setup) {
|
|
3111
|
+
progressService(svc.name, { status: "probing", detail: "running setup" });
|
|
3112
|
+
const helpers = await ensureHelpers(svc.name, svc);
|
|
3113
|
+
try {
|
|
3114
|
+
await svc.setup({
|
|
3115
|
+
name: svc.name,
|
|
3116
|
+
helpers,
|
|
3117
|
+
...(await spectestContext({ service: svc.name, includeSelf: true })),
|
|
3118
|
+
...{ [COMPONENT_RUNTIME]: {
|
|
3119
|
+
images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
|
|
3120
|
+
gateway: await bridgeGatewayIp(),
|
|
3121
|
+
caBundle: await ensureCaBundle(),
|
|
3122
|
+
} },
|
|
3123
|
+
});
|
|
3124
|
+
}
|
|
3125
|
+
catch (err) {
|
|
3126
|
+
throw await withContainerPostMortem(svc.name, err);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
})()]);
|
|
3130
|
+
for (const task of builds)
|
|
3131
|
+
progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
|
|
3090
3132
|
progressService(svc.name, { status: "ready", detail: undefined });
|
|
3091
3133
|
const ti = timings.get(svc.name);
|
|
3092
3134
|
if (ti) {
|
|
@@ -3545,9 +3587,10 @@ async function computeProjectReach(all) {
|
|
|
3545
3587
|
async function computeProjectReachInner(all) {
|
|
3546
3588
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
3547
3589
|
const sources = [];
|
|
3548
|
-
|
|
3590
|
+
const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
|
|
3591
|
+
for (const svc of imageTasks(all)) {
|
|
3549
3592
|
const src = {
|
|
3550
|
-
service: svc.name,
|
|
3593
|
+
service: owners.get(svc.name) ?? svc.name,
|
|
3551
3594
|
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
3552
3595
|
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
3553
3596
|
};
|
|
@@ -5611,7 +5654,7 @@ export function harnessMethods(state) {
|
|
|
5611
5654
|
}
|
|
5612
5655
|
}),
|
|
5613
5656
|
bootstrap: async (params) => exclusive(state, "inFlightBootstrap", "bootstrap already in progress", async () => {
|
|
5614
|
-
const prepared = params.prepared === undefined ? undefined : preparedImages(params.prepared, params.identity, namedServices(requireLoaded().project.environment).map(s => s.name));
|
|
5657
|
+
const prepared = params.prepared === undefined ? undefined : preparedImages(params.prepared, params.identity, imageTasks(namedServices(requireLoaded().project.environment)).map(s => s.name));
|
|
5615
5658
|
return { ok: true, timings: await bootstrap(prepared) };
|
|
5616
5659
|
}),
|
|
5617
5660
|
projectSetup: async () => exclusive(state, "inFlightProjectSetup", "project-setup already in progress", () => runProjectSetup()),
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
|
|
2
|
+
export type KubernetesImage = string | (BuildImage & {
|
|
3
|
+
reference: string;
|
|
4
|
+
});
|
|
5
|
+
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
6
|
+
export declare function canonicalImageReference(reference: string): string;
|
|
7
|
+
export declare function kubernetesImages(images: readonly KubernetesImage[]): {
|
|
8
|
+
pulls: string[];
|
|
9
|
+
builds: ComponentImageBuild[];
|
|
10
|
+
};
|
|
11
|
+
/** Stable internal task names, separate from the service graph. */
|
|
12
|
+
export declare function componentBuildTasks<T extends ServiceConfig & {
|
|
13
|
+
name: string;
|
|
14
|
+
}>(services: readonly T[]): {
|
|
15
|
+
name: string;
|
|
16
|
+
owner: string;
|
|
17
|
+
reference: string;
|
|
18
|
+
image: BuildImage;
|
|
19
|
+
}[];
|
|
20
|
+
/** Private handoff to a component's setup hook. Builds stay generic; the
|
|
21
|
+
* component decides how its runtime consumes them and establishes trust. */
|
|
22
|
+
export declare const COMPONENT_RUNTIME: unique symbol;
|
|
23
|
+
export interface PreparedComponentImage {
|
|
24
|
+
source: string;
|
|
25
|
+
reference: string;
|
|
26
|
+
/** A component can load each result as soon as its own build finishes. */
|
|
27
|
+
ready?: Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface ComponentRuntime {
|
|
30
|
+
images: readonly PreparedComponentImage[];
|
|
31
|
+
gateway: string;
|
|
32
|
+
caBundle: string | null;
|
|
33
|
+
}
|
|
34
|
+
export declare function componentRuntime(context: object): ComponentRuntime | undefined;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/** containerd/CRI use fully qualified names, including the default tag. */
|
|
3
|
+
export function canonicalImageReference(reference) {
|
|
4
|
+
if (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)) {
|
|
5
|
+
throw new Error("image reference must be a non-empty image name without whitespace");
|
|
6
|
+
}
|
|
7
|
+
const slash = reference.indexOf("/");
|
|
8
|
+
const first = slash < 0 ? "" : reference.slice(0, slash);
|
|
9
|
+
let result = slash < 0 ? `docker.io/library/${reference}`
|
|
10
|
+
: first.includes(".") || first.includes(":") || first === "localhost" ? reference : `docker.io/${reference}`;
|
|
11
|
+
if (result.startsWith("index.docker.io/"))
|
|
12
|
+
result = result.replace("index.docker.io/", "docker.io/");
|
|
13
|
+
if (result.startsWith("docker.io/") && !result.slice(10).includes("/"))
|
|
14
|
+
result = `docker.io/library/${result.slice(10)}`;
|
|
15
|
+
const [name, digest] = result.split("@");
|
|
16
|
+
const tagAt = name.lastIndexOf(":") > name.lastIndexOf("/") ? name.lastIndexOf(":") : -1;
|
|
17
|
+
const repository = tagAt < 0 ? name : name.slice(0, tagAt);
|
|
18
|
+
if (!/^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*)*$/.test(repository)
|
|
19
|
+
|| (tagAt >= 0 && !/^[\w][\w.-]{0,127}$/.test(name.slice(tagAt + 1)))
|
|
20
|
+
|| (result.includes("@") && (!digest || !/^sha256:[a-f0-9]{64}$/.test(digest) || result.split("@").length !== 2))) {
|
|
21
|
+
throw new Error(`invalid image reference: ${reference}`);
|
|
22
|
+
}
|
|
23
|
+
if (tagAt < 0 && !digest)
|
|
24
|
+
result += ":latest";
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
export function kubernetesImages(images) {
|
|
28
|
+
if (!Array.isArray(images))
|
|
29
|
+
throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
|
|
30
|
+
const pulls = [];
|
|
31
|
+
const builds = [];
|
|
32
|
+
const seen = new Map();
|
|
33
|
+
for (const image of images) {
|
|
34
|
+
if (typeof image !== "string" && (!image || typeof image !== "object" || "type" in image || !image.reference)) {
|
|
35
|
+
throw new Error("k3s(): images expects registry strings or Dockerfile builds with a reference");
|
|
36
|
+
}
|
|
37
|
+
const reference = canonicalImageReference(typeof image === "string" ? image : image.reference);
|
|
38
|
+
if (typeof image !== "string" && reference.includes("@"))
|
|
39
|
+
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)
|
|
44
|
+
throw new Error(`k3s(): conflicting images for ${reference}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
seen.set(reference, identity);
|
|
48
|
+
if (typeof image === "string")
|
|
49
|
+
pulls.push(image);
|
|
50
|
+
else
|
|
51
|
+
builds.push({ reference, image });
|
|
52
|
+
}
|
|
53
|
+
return { pulls, builds };
|
|
54
|
+
}
|
|
55
|
+
/** Stable internal task names, separate from the service graph. */
|
|
56
|
+
export function componentBuildTasks(services) {
|
|
57
|
+
return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
|
|
58
|
+
name: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
|
|
59
|
+
owner: svc.name,
|
|
60
|
+
reference: build.reference,
|
|
61
|
+
image: build.image,
|
|
62
|
+
})));
|
|
63
|
+
}
|
|
64
|
+
/** Private handoff to a component's setup hook. Builds stay generic; the
|
|
65
|
+
* component decides how its runtime consumes them and establishes trust. */
|
|
66
|
+
export const COMPONENT_RUNTIME = Symbol.for("spectest.component-runtime");
|
|
67
|
+
export function componentRuntime(context) {
|
|
68
|
+
return context[COMPONENT_RUNTIME];
|
|
69
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Wait for every writer to stop before reporting a failure to setup/cleanup. */
|
|
2
|
+
export async function waitForAll(work) {
|
|
3
|
+
const results = await Promise.allSettled(work);
|
|
4
|
+
const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
|
|
5
|
+
if (failures.length === 1)
|
|
6
|
+
throw failures[0];
|
|
7
|
+
if (failures.length > 1)
|
|
8
|
+
throw new AggregateError(failures, `Concurrent environment preparation failed: ${failures.map(error => error instanceof Error ? error.message : String(error)).join("; ")}`);
|
|
9
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SpectestContext } from "../index.js";
|
|
2
|
+
import { type PreparedComponentImage } from "./component-images.js";
|
|
3
|
+
export declare function loadK3sBuildImages(service: string, images: readonly PreparedComponentImage[], gateway: string, exec: SpectestContext["exec"]): Promise<void>;
|
|
@@ -0,0 +1,152 @@
|
|
|
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 { canonicalImageReference } from "./component-images.js";
|
|
9
|
+
import { waitForK3sRuntime } from "./k3s-prepull.js";
|
|
10
|
+
import { waitForAll } from "./concurrency.js";
|
|
11
|
+
const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
12
|
+
const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
|
|
15
|
+
async function hostContent(id) {
|
|
16
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id))
|
|
17
|
+
throw new Error(`invalid content digest ${id}`);
|
|
18
|
+
const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
|
|
19
|
+
if (digest(result.stdout) !== id)
|
|
20
|
+
throw new Error(`corrupt image metadata ${id}`);
|
|
21
|
+
return result.stdout;
|
|
22
|
+
}
|
|
23
|
+
/** Preserve the original index/manifest bytes and all application descriptors. */
|
|
24
|
+
async function hostImage(source) {
|
|
25
|
+
const name = canonicalImageReference(source);
|
|
26
|
+
const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
|
|
27
|
+
const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
|
|
28
|
+
if (!found)
|
|
29
|
+
throw new Error(`built image ${name} is missing from the VM container store`);
|
|
30
|
+
const metadata = new Map();
|
|
31
|
+
const allowed = new Map();
|
|
32
|
+
let configDigest;
|
|
33
|
+
async function visit(id, selected, depth = 0) {
|
|
34
|
+
if (depth > 4)
|
|
35
|
+
throw new Error(`built image ${name} has a recursive manifest index`);
|
|
36
|
+
const bytes = await hostContent(id);
|
|
37
|
+
const obj = JSON.parse(bytes.toString());
|
|
38
|
+
const desc = descriptor(bytes, obj.mediaType);
|
|
39
|
+
metadata.set(id, bytes);
|
|
40
|
+
allowed.set(id, desc);
|
|
41
|
+
if (obj.config && Array.isArray(obj.layers)) {
|
|
42
|
+
const manifest = obj;
|
|
43
|
+
for (const blob of [manifest.config, ...manifest.layers])
|
|
44
|
+
allowed.set(blob.digest, blob);
|
|
45
|
+
if (selected)
|
|
46
|
+
configDigest = manifest.config.digest;
|
|
47
|
+
}
|
|
48
|
+
else if (Array.isArray(obj.manifests)) {
|
|
49
|
+
for (const entry of obj.manifests) {
|
|
50
|
+
const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
|
|
51
|
+
await visit(entry.digest, selected && matches, depth + 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
throw new Error(`built image ${name} has invalid image metadata`);
|
|
56
|
+
return desc;
|
|
57
|
+
}
|
|
58
|
+
const target = await visit(found, true);
|
|
59
|
+
if (!configDigest)
|
|
60
|
+
throw new Error(`built image ${name} has no image for this VM's platform`);
|
|
61
|
+
return { target, metadata, allowed, configDigest };
|
|
62
|
+
}
|
|
63
|
+
export async function loadK3sBuildImages(service, images, gateway, exec) {
|
|
64
|
+
if (!images.length)
|
|
65
|
+
return;
|
|
66
|
+
await waitForK3sRuntime(service, exec);
|
|
67
|
+
const checked = async (args, timeoutMs = 30_000) => {
|
|
68
|
+
const result = await exec(service, args, { timeoutMs });
|
|
69
|
+
if (result.exitCode !== 0)
|
|
70
|
+
throw new Error(`k3s(${service}): image load failed: ${result.stderr || result.stdout}`);
|
|
71
|
+
return result.stdout;
|
|
72
|
+
};
|
|
73
|
+
const config = await checked(["cat", "/var/lib/rancher/k3s/agent/etc/containerd/config.toml"]);
|
|
74
|
+
const snapshotter = config.match(/^\s*snapshotter\s*=\s*['"]([^'"]+)['"]/m)?.[1];
|
|
75
|
+
if (!snapshotter)
|
|
76
|
+
throw new Error(`k3s(${service}): cannot determine kubelet's snapshotter`);
|
|
77
|
+
const ctr = ["ctr", "--address", "/run/k3s/containerd/containerd.sock", "--namespace", "k8s.io"];
|
|
78
|
+
const help = await checked([...ctr, "images", "pull", "--help"]);
|
|
79
|
+
const local = help.includes("--local") ? ["--local"] : [];
|
|
80
|
+
await waitForAll(images.map(async (image) => {
|
|
81
|
+
await image.ready;
|
|
82
|
+
const reference = canonicalImageReference(image.reference);
|
|
83
|
+
const { target, metadata: blobs, allowed, configDigest } = await hostImage(image.source);
|
|
84
|
+
const streams = new Set();
|
|
85
|
+
let transferred = 0;
|
|
86
|
+
const server = createServer((req, res) => {
|
|
87
|
+
const pathname = new URL(req.url ?? "/", "http://local").pathname;
|
|
88
|
+
const match = pathname.match(/^\/v2\/.+\/(manifests|blobs)\/([^/]+)$/);
|
|
89
|
+
if (!["GET", "HEAD"].includes(req.method ?? "") || !match) {
|
|
90
|
+
res.writeHead(404).end();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const id = match[1] === "manifests" && !match[2].startsWith("sha256:") ? target.digest : match[2];
|
|
94
|
+
const desc = allowed.get(id);
|
|
95
|
+
if (!desc) {
|
|
96
|
+
res.writeHead(404).end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
res.writeHead(200, { "Content-Type": desc.mediaType, "Content-Length": desc.size, "Docker-Content-Digest": id });
|
|
100
|
+
if (req.method === "HEAD") {
|
|
101
|
+
res.end();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
transferred += desc.size;
|
|
105
|
+
const bytes = blobs.get(id);
|
|
106
|
+
if (bytes) {
|
|
107
|
+
res.end(bytes);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const proc = spawn("ctr", [...HOST_CTR, "content", "get", id], { stdio: ["ignore", "pipe", "ignore"] });
|
|
111
|
+
streams.add(proc);
|
|
112
|
+
proc.stdout.pipe(res);
|
|
113
|
+
proc.on("error", err => res.destroy(err));
|
|
114
|
+
proc.on("exit", code => { streams.delete(proc); if (code !== 0)
|
|
115
|
+
res.destroy(new Error(`content read failed: ${id}`)); });
|
|
116
|
+
res.on("close", () => { if (proc.exitCode === null)
|
|
117
|
+
proc.kill(); });
|
|
118
|
+
});
|
|
119
|
+
const directory = `/run/spectest-image-load-${createHash("sha256").update(reference).digest("hex").slice(0, 16)}`;
|
|
120
|
+
try {
|
|
121
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, gateway, resolve); });
|
|
122
|
+
const port = server.address().port;
|
|
123
|
+
// A private hosts directory scopes this override to this single ctr call;
|
|
124
|
+
// it cannot change the cluster's configured registries or pull policy.
|
|
125
|
+
const hosts = `server = "http://${gateway}:${port}"\n`;
|
|
126
|
+
await checked(["sh", "-c", 'mkdir -p "$1/_default"; printf "%s" "$2" > "$1/_default/hosts.toml"', "sh", directory, hosts]);
|
|
127
|
+
await checked([...ctr, "images", "pull", ...local, "--hosts-dir", directory,
|
|
128
|
+
"--snapshotter", snapshotter, "--label", "io.cri-containerd.image=managed", reference], 600_000);
|
|
129
|
+
const repo = reference.slice(0, reference.lastIndexOf(":"));
|
|
130
|
+
await checked([...ctr, "images", "tag", "--force", reference, `${repo}@${target.digest}`]);
|
|
131
|
+
// CRI learns about ctr image updates asynchronously. Confirm the current
|
|
132
|
+
// build's config, not merely that an older tag already exists.
|
|
133
|
+
const deadline = Date.now() + 30_000;
|
|
134
|
+
for (;;) {
|
|
135
|
+
const result = await exec(service, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference], { timeoutMs: 5_000 });
|
|
136
|
+
if (result.exitCode === 0 && JSON.parse(result.stdout).status?.id === configDigest)
|
|
137
|
+
break;
|
|
138
|
+
if (Date.now() >= deadline)
|
|
139
|
+
throw new Error(`k3s(${service}): current build is not visible to kubelet: ${reference}`);
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
141
|
+
}
|
|
142
|
+
console.log(`[k3s-images] ${service}: ${reference} @ ${target.digest}, transferred ${transferred} bytes`);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
for (const proc of streams)
|
|
146
|
+
proc.kill();
|
|
147
|
+
server.closeAllConnections();
|
|
148
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
149
|
+
await exec(service, ["rm", "-rf", directory], { timeoutMs: 5_000 }).catch(() => undefined);
|
|
150
|
+
}
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SpectestContext } from "../index.js";
|
|
2
|
+
/** CRI PullImage waits for unpacking and uses k3s's registry configuration.
|
|
3
|
+
* Always resolve tags on a fresh setup. Immutable digests can be used offline.
|
|
4
|
+
* Submit every image concurrently; containerd manages downloads and unpacking.
|
|
5
|
+
* Drain all pulls on failure so none outlive the setup hook.
|
|
6
|
+
*/
|
|
7
|
+
export declare function prePullK3sImages(name: string, images: readonly string[], exec: SpectestContext["exec"]): Promise<void>;
|
|
8
|
+
export declare function waitForK3sRuntime(name: string, exec: SpectestContext["exec"]): Promise<void>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { waitForAll } from "./concurrency.js";
|
|
2
|
+
/** CRI PullImage waits for unpacking and uses k3s's registry configuration.
|
|
3
|
+
* Always resolve tags on a fresh setup. Immutable digests can be used offline.
|
|
4
|
+
* Submit every image concurrently; containerd manages downloads and unpacking.
|
|
5
|
+
* Drain all pulls on failure so none outlive the setup hook.
|
|
6
|
+
*/
|
|
7
|
+
export async function prePullK3sImages(name, images, exec) {
|
|
8
|
+
if (!images.length)
|
|
9
|
+
return;
|
|
10
|
+
await waitForK3sRuntime(name, exec);
|
|
11
|
+
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
12
|
+
await waitForAll(images.map(async (image) => {
|
|
13
|
+
if (/@sha256:[a-f0-9]{64}$/.test(image)) {
|
|
14
|
+
const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
|
|
15
|
+
if (existing.exitCode === 0)
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
|
|
19
|
+
if (result.exitCode !== 0)
|
|
20
|
+
throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
export async function waitForK3sRuntime(name, exec) {
|
|
24
|
+
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
25
|
+
// /readyz belongs to the API server and can precede the node runtime.
|
|
26
|
+
const deadline = Date.now() + 60_000;
|
|
27
|
+
for (;;) {
|
|
28
|
+
const result = await exec(name, [...cri, "info"], { timeoutMs: 5_000 });
|
|
29
|
+
if (result.exitCode === 0)
|
|
30
|
+
break;
|
|
31
|
+
if (Date.now() >= deadline)
|
|
32
|
+
throw new Error(`k3s(${name}): containerd did not become ready for pre-pulls: ${result.stderr || result.stdout}`);
|
|
33
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { type KubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
1
2
|
export declare const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
2
3
|
export declare const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
3
|
-
export
|
|
4
|
+
export { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
4
5
|
export interface StickyConsumerConfig {
|
|
5
6
|
path: string;
|
|
6
7
|
capacityBytes: number;
|
|
@@ -19,10 +20,9 @@ export declare function initializeStickyConsumer(config: StickyConsumerConfig):
|
|
|
19
20
|
export declare function collectStickyCache(config: StickyConsumerConfig, run?: typeof exec, usage?: () => Promise<{
|
|
20
21
|
freeBytes: number;
|
|
21
22
|
totalBytes: number;
|
|
22
|
-
}
|
|
23
|
+
}>, kubernetes?: KubernetesCacheMaintenance): Promise<void>;
|
|
23
24
|
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
24
25
|
export declare function prepareStickyConsumer(artifact?: boolean): Promise<{
|
|
25
26
|
prepared: true;
|
|
26
27
|
freeBytes: number;
|
|
27
28
|
}>;
|
|
28
|
-
export {};
|
|
@@ -1,9 +1,11 @@
|
|
|
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
|
+
import { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
5
|
+
import { openKubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
4
6
|
export const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
5
7
|
export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
6
|
-
export
|
|
8
|
+
export { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
7
9
|
function exec(program, args, timeout = 60_000) {
|
|
8
10
|
return new Promise((resolve, reject) => {
|
|
9
11
|
execFile(program, args, { timeout, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
@@ -110,7 +112,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
110
112
|
export async function collectStickyCache(config, run = exec, usage = async () => {
|
|
111
113
|
const fs = await statfs(config.path);
|
|
112
114
|
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
|
|
113
|
-
}) {
|
|
115
|
+
}, kubernetes) {
|
|
114
116
|
validateStickyConfig(config);
|
|
115
117
|
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
116
118
|
await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
|
|
@@ -128,6 +130,11 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
|
|
|
128
130
|
await pruneBuildkit();
|
|
129
131
|
if (withinTarget(fs))
|
|
130
132
|
return;
|
|
133
|
+
while (await kubernetes?.pruneOldest()) {
|
|
134
|
+
fs = await usage();
|
|
135
|
+
if (withinTarget(fs))
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
131
138
|
// Pressure can be entirely in recent Docker images. Drop oldest images
|
|
132
139
|
// first, even within the age window, until the whole filesystem fits.
|
|
133
140
|
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
|
|
@@ -135,6 +142,8 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
|
|
|
135
142
|
? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
|
|
136
143
|
images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
|
|
137
144
|
for (const image of images) {
|
|
145
|
+
if (kubernetes?.protectedImages.has(image.Id))
|
|
146
|
+
continue;
|
|
138
147
|
await run("docker", ["image", "rm", "-f", image.Id], 120_000);
|
|
139
148
|
await pruneBuildkit();
|
|
140
149
|
if (withinTarget(fs))
|
|
@@ -157,7 +166,13 @@ export async function prepareStickyConsumer(artifact = false) {
|
|
|
157
166
|
const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
|
|
158
167
|
if (volumes.length)
|
|
159
168
|
await exec("docker", ["volume", "rm", ...volumes]);
|
|
160
|
-
await
|
|
169
|
+
const kubernetes = await openKubernetesCacheMaintenance(exec);
|
|
170
|
+
try {
|
|
171
|
+
await collectStickyCache(config, exec, undefined, kubernetes);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await kubernetes.close();
|
|
175
|
+
}
|
|
161
176
|
}
|
|
162
177
|
const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
|
|
163
178
|
for (const unit of units) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type CacheCommand = (program: string, args: string[], timeout?: number) => Promise<string>;
|
|
2
|
+
export declare function stickyKubernetesDirectory(service: string, version: string): string;
|
|
3
|
+
export declare function registerStickyKubernetesStore(service: string, version: string, run?: CacheCommand): Promise<void>;
|
|
4
|
+
/** Standalone containerd never starts CRI/kubelet or any inherited workload.
|
|
5
|
+
* Native API deletion preserves content and committed image snapshots while
|
|
6
|
+
* releasing sandbox, container, lease and writable snapshot references. */
|
|
7
|
+
export declare const KUBERNETES_CACHE_MAINTENANCE_SH = "set -eu\nexec 2>&1\ncat > /run/cache.toml <<'CONFIG'\nversion = 2\ndisabled_plugins = [\"io.containerd.grpc.v1.cri\", \"io.containerd.cri.v1.runtime\", \"io.containerd.cri.v1.images\"]\nCONFIG\ncontainerd --config /run/cache.toml --root /var/lib/rancher/k3s/agent/containerd --state /run/cache-state --address /run/spectest-cache.sock > /run/cache.log 2>&1 &\npid=$!\nstop() { kill \"$pid\"; wait \"$pid\"; }\ntrap 'stop; exit 0' TERM INT\ntrap 'kill \"$pid\" 2>/dev/null || true' EXIT\nc() { ctr --address /run/spectest-cache.sock --namespace k8s.io \"$@\"; }\ni=0\nuntil c version >/dev/null 2>&1; do\n kill -0 \"$pid\" || { cat /run/cache.log; exit 1; }\n i=$((i+1)); [ \"$i\" -lt 300 ] || { cat /run/cache.log; exit 1; }\n sleep 0.1\ndone\nids=$(c containers ls -q)\nif [ -n \"$ids\" ]; then c containers rm $ids; fi\n# 1.7 has no sandbox store; 2.x does.\ncase \"$(containerd --version)\" in\n *v1.*) ;;\n *) sandboxes=$(c sandboxes ls)\n ids=$(printf '%s\\n' \"$sandboxes\" | awk 'NR > 1 {print $1}')\n if [ -n \"$ids\" ]; then c sandboxes rm --force $ids; fi ;;\nesac\nids=$(c leases ls -q)\nif [ -n \"$ids\" ]; then c leases rm $ids; fi\n# Remove children before parents, and tolerate asynchronous GC winning a race.\nprevious=''\nwhile :; do\n snapshots=$(c snapshots --snapshotter overlayfs ls)\n ids=$(printf '%s\\n' \"$snapshots\" | awk 'NR > 1 && $NF != \"Committed\" {print $1}')\n [ -n \"$ids\" ] || break\n [ \"$ids\" != \"$previous\" ] || { echo 'cannot clean Kubernetes writable snapshots' >&2; exit 1; }\n previous=\"$ids\"\n for id in $ids; do c snapshots --snapshotter overlayfs rm \"$id\" >/dev/null 2>&1 || true; done\ndone\ntouch /run/cache-ready\nwhile :; do sleep 1 & wait $!; done\n";
|
|
8
|
+
export interface KubernetesCacheMaintenance {
|
|
9
|
+
/** Outer images needed by live maintenance containers must not be evicted. */
|
|
10
|
+
protectedImages: Set<string>;
|
|
11
|
+
/** Evicts one complete image store, oldest service/version first. */
|
|
12
|
+
pruneOldest(): Promise<boolean>;
|
|
13
|
+
close(): Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export declare function openKubernetesCacheMaintenance(run: CacheCommand, root?: string): Promise<KubernetesCacheMaintenance>;
|