@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
|
@@ -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,150 @@
|
|
|
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
|
+
const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
11
|
+
const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
|
|
12
|
+
const run = promisify(execFile);
|
|
13
|
+
const HOST_CTR = ["--address", "/run/containerd/containerd.sock", "--namespace", "moby"];
|
|
14
|
+
async function hostContent(id) {
|
|
15
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(id))
|
|
16
|
+
throw new Error(`invalid content digest ${id}`);
|
|
17
|
+
const result = await run("ctr", [...HOST_CTR, "content", "get", id], { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: 30_000 });
|
|
18
|
+
if (digest(result.stdout) !== id)
|
|
19
|
+
throw new Error(`corrupt image metadata ${id}`);
|
|
20
|
+
return result.stdout;
|
|
21
|
+
}
|
|
22
|
+
/** Preserve the original index/manifest bytes and all application descriptors. */
|
|
23
|
+
async function hostImage(source) {
|
|
24
|
+
const name = canonicalImageReference(source);
|
|
25
|
+
const { stdout } = await run("ctr", [...HOST_CTR, "images", "ls", `name==${name}`], { timeout: 30_000 });
|
|
26
|
+
const found = stdout.split("\n").map(line => line.trim().split(/\s+/)).find(row => row[0] === name)?.[2];
|
|
27
|
+
if (!found)
|
|
28
|
+
throw new Error(`built image ${name} is missing from the VM container store`);
|
|
29
|
+
const metadata = new Map();
|
|
30
|
+
const allowed = new Map();
|
|
31
|
+
let configDigest;
|
|
32
|
+
async function visit(id, selected, depth = 0) {
|
|
33
|
+
if (depth > 4)
|
|
34
|
+
throw new Error(`built image ${name} has a recursive manifest index`);
|
|
35
|
+
const bytes = await hostContent(id);
|
|
36
|
+
const obj = JSON.parse(bytes.toString());
|
|
37
|
+
const desc = descriptor(bytes, obj.mediaType);
|
|
38
|
+
metadata.set(id, bytes);
|
|
39
|
+
allowed.set(id, desc);
|
|
40
|
+
if (obj.config && Array.isArray(obj.layers)) {
|
|
41
|
+
const manifest = obj;
|
|
42
|
+
for (const blob of [manifest.config, ...manifest.layers])
|
|
43
|
+
allowed.set(blob.digest, blob);
|
|
44
|
+
if (selected)
|
|
45
|
+
configDigest = manifest.config.digest;
|
|
46
|
+
}
|
|
47
|
+
else if (Array.isArray(obj.manifests)) {
|
|
48
|
+
for (const entry of obj.manifests) {
|
|
49
|
+
const matches = !entry.platform || (entry.platform.os === "linux" && entry.platform.architecture === (process.arch === "arm64" ? "arm64" : "amd64"));
|
|
50
|
+
await visit(entry.digest, selected && matches, depth + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else
|
|
54
|
+
throw new Error(`built image ${name} has invalid image metadata`);
|
|
55
|
+
return desc;
|
|
56
|
+
}
|
|
57
|
+
const target = await visit(found, true);
|
|
58
|
+
if (!configDigest)
|
|
59
|
+
throw new Error(`built image ${name} has no image for this VM's platform`);
|
|
60
|
+
return { target, metadata, allowed, configDigest };
|
|
61
|
+
}
|
|
62
|
+
export async function loadK3sBuildImages(service, images, gateway, exec) {
|
|
63
|
+
if (!images.length)
|
|
64
|
+
return;
|
|
65
|
+
await waitForK3sRuntime(service, exec);
|
|
66
|
+
const checked = async (args, timeoutMs = 30_000) => {
|
|
67
|
+
const result = await exec(service, args, { timeoutMs });
|
|
68
|
+
if (result.exitCode !== 0)
|
|
69
|
+
throw new Error(`k3s(${service}): image load failed: ${result.stderr || result.stdout}`);
|
|
70
|
+
return result.stdout;
|
|
71
|
+
};
|
|
72
|
+
const config = await checked(["cat", "/var/lib/rancher/k3s/agent/etc/containerd/config.toml"]);
|
|
73
|
+
const snapshotter = config.match(/^\s*snapshotter\s*=\s*['"]([^'"]+)['"]/m)?.[1];
|
|
74
|
+
if (!snapshotter)
|
|
75
|
+
throw new Error(`k3s(${service}): cannot determine kubelet's snapshotter`);
|
|
76
|
+
const ctr = ["ctr", "--address", "/run/k3s/containerd/containerd.sock", "--namespace", "k8s.io"];
|
|
77
|
+
const help = await checked([...ctr, "images", "pull", "--help"]);
|
|
78
|
+
const local = help.includes("--local") ? ["--local"] : [];
|
|
79
|
+
for (const image of images) {
|
|
80
|
+
const reference = canonicalImageReference(image.reference);
|
|
81
|
+
const { target, metadata: blobs, allowed, configDigest } = await hostImage(image.source);
|
|
82
|
+
const streams = new Set();
|
|
83
|
+
let transferred = 0;
|
|
84
|
+
const server = createServer((req, res) => {
|
|
85
|
+
const pathname = new URL(req.url ?? "/", "http://local").pathname;
|
|
86
|
+
const match = pathname.match(/^\/v2\/.+\/(manifests|blobs)\/([^/]+)$/);
|
|
87
|
+
if (!["GET", "HEAD"].includes(req.method ?? "") || !match) {
|
|
88
|
+
res.writeHead(404).end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const id = match[1] === "manifests" && !match[2].startsWith("sha256:") ? target.digest : match[2];
|
|
92
|
+
const desc = allowed.get(id);
|
|
93
|
+
if (!desc) {
|
|
94
|
+
res.writeHead(404).end();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
res.writeHead(200, { "Content-Type": desc.mediaType, "Content-Length": desc.size, "Docker-Content-Digest": id });
|
|
98
|
+
if (req.method === "HEAD") {
|
|
99
|
+
res.end();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
transferred += desc.size;
|
|
103
|
+
const bytes = blobs.get(id);
|
|
104
|
+
if (bytes) {
|
|
105
|
+
res.end(bytes);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const proc = spawn("ctr", [...HOST_CTR, "content", "get", id], { stdio: ["ignore", "pipe", "ignore"] });
|
|
109
|
+
streams.add(proc);
|
|
110
|
+
proc.stdout.pipe(res);
|
|
111
|
+
proc.on("error", err => res.destroy(err));
|
|
112
|
+
proc.on("exit", code => { streams.delete(proc); if (code !== 0)
|
|
113
|
+
res.destroy(new Error(`content read failed: ${id}`)); });
|
|
114
|
+
res.on("close", () => { if (proc.exitCode === null)
|
|
115
|
+
proc.kill(); });
|
|
116
|
+
});
|
|
117
|
+
const directory = `/run/spectest-image-load-${createHash("sha256").update(reference).digest("hex").slice(0, 16)}`;
|
|
118
|
+
try {
|
|
119
|
+
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, gateway, resolve); });
|
|
120
|
+
const port = server.address().port;
|
|
121
|
+
// A private hosts directory scopes this override to this single ctr call;
|
|
122
|
+
// it cannot change the cluster's configured registries or pull policy.
|
|
123
|
+
const hosts = `server = "http://${gateway}:${port}"\n`;
|
|
124
|
+
await checked(["sh", "-c", 'mkdir -p "$1/_default"; printf "%s" "$2" > "$1/_default/hosts.toml"', "sh", directory, hosts]);
|
|
125
|
+
await checked([...ctr, "images", "pull", ...local, "--hosts-dir", directory,
|
|
126
|
+
"--snapshotter", snapshotter, "--label", "io.cri-containerd.image=managed", reference], 600_000);
|
|
127
|
+
const repo = reference.slice(0, reference.lastIndexOf(":"));
|
|
128
|
+
await checked([...ctr, "images", "tag", "--force", reference, `${repo}@${target.digest}`]);
|
|
129
|
+
// CRI learns about ctr image updates asynchronously. Confirm the current
|
|
130
|
+
// build's config, not merely that an older tag already exists.
|
|
131
|
+
const deadline = Date.now() + 30_000;
|
|
132
|
+
for (;;) {
|
|
133
|
+
const result = await exec(service, ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock", "inspecti", reference], { timeoutMs: 5_000 });
|
|
134
|
+
if (result.exitCode === 0 && JSON.parse(result.stdout).status?.id === configDigest)
|
|
135
|
+
break;
|
|
136
|
+
if (Date.now() >= deadline)
|
|
137
|
+
throw new Error(`k3s(${service}): current build is not visible to kubelet: ${reference}`);
|
|
138
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
139
|
+
}
|
|
140
|
+
console.log(`[k3s-images] ${service}: ${reference} @ ${target.digest}, transferred ${transferred} bytes`);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
for (const proc of streams)
|
|
144
|
+
proc.kill();
|
|
145
|
+
server.closeAllConnections();
|
|
146
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
147
|
+
await exec(service, ["rm", "-rf", directory], { timeoutMs: 5_000 }).catch(() => undefined);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
* Drain both workers on failure so no pulls outlive the setup hook.
|
|
5
|
+
*/
|
|
6
|
+
export declare function prePullK3sImages(name: string, images: readonly string[], exec: SpectestContext["exec"]): Promise<void>;
|
|
7
|
+
export declare function waitForK3sRuntime(name: string, exec: SpectestContext["exec"]): Promise<void>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** CRI PullImage waits for unpacking and uses k3s's registry configuration.
|
|
2
|
+
* Always resolve tags on a fresh setup. Immutable digests can be used offline.
|
|
3
|
+
* Drain both workers on failure so no pulls outlive the setup hook.
|
|
4
|
+
*/
|
|
5
|
+
export async function prePullK3sImages(name, images, exec) {
|
|
6
|
+
if (!images.length)
|
|
7
|
+
return;
|
|
8
|
+
await waitForK3sRuntime(name, exec);
|
|
9
|
+
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
10
|
+
const queue = [...images];
|
|
11
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(2, queue.length) }, async () => {
|
|
12
|
+
for (let image = queue.shift(); image !== undefined; image = queue.shift()) {
|
|
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
|
+
continue;
|
|
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
|
+
const failed = results.find(result => result.status === "rejected");
|
|
24
|
+
if (failed?.status === "rejected")
|
|
25
|
+
throw failed.reason;
|
|
26
|
+
}
|
|
27
|
+
export async function waitForK3sRuntime(name, exec) {
|
|
28
|
+
const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
|
|
29
|
+
// /readyz belongs to the API server and can precede the node runtime.
|
|
30
|
+
const deadline = Date.now() + 60_000;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const result = await exec(name, [...cri, "info"], { timeoutMs: 5_000 });
|
|
33
|
+
if (result.exitCode === 0)
|
|
34
|
+
break;
|
|
35
|
+
if (Date.now() >= deadline)
|
|
36
|
+
throw new Error(`k3s(${name}): containerd did not become ready for pre-pulls: ${result.stderr || result.stdout}`);
|
|
37
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Kubernetes owns a separate containerd store; Docker/BuildKit GC cannot
|
|
2
|
+
* collect it. Maintenance runs only on the isolated publication clone. */
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
7
|
+
import { STICKY_ROOT } from "./sticky-paths.js";
|
|
8
|
+
const STORE_TARGET = "/var/lib/rancher/k3s/agent/containerd";
|
|
9
|
+
const SOCKET = "/run/spectest-cache.sock";
|
|
10
|
+
const ROOT = `${STICKY_ROOT}/kubernetes`;
|
|
11
|
+
const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
|
|
12
|
+
const SERVICE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
13
|
+
export function stickyKubernetesDirectory(service, version) {
|
|
14
|
+
// Version changes must not open old metadata with a different containerd.
|
|
15
|
+
const layout = createHash("sha256").update(`overlayfs-v1:${version}`).digest("hex").slice(0, 16);
|
|
16
|
+
return `${ROOT}/${service}/${layout}`;
|
|
17
|
+
}
|
|
18
|
+
const runCommand = async (program, args, timeout = 30_000) => (await promisify(execFile)(program, args, { timeout })).stdout.trim();
|
|
19
|
+
export async function registerStickyKubernetesStore(service, version, run = runCommand) {
|
|
20
|
+
if (!SERVICE_NAME.test(service))
|
|
21
|
+
throw new Error(`invalid Kubernetes cache service name: ${service}`);
|
|
22
|
+
// Respect custom Dockerfile overrides of the component's image.
|
|
23
|
+
const image = (await run("docker", ["inspect", "--format", "{{.Image}}", service])).trim();
|
|
24
|
+
if (!IMAGE_ID.test(image))
|
|
25
|
+
throw new Error(`cannot identify Kubernetes runtime image for ${service}`);
|
|
26
|
+
const directory = stickyKubernetesDirectory(service, version);
|
|
27
|
+
await mkdir(directory, { recursive: true });
|
|
28
|
+
const temporary = `${directory}/store.${randomUUID()}.tmp`;
|
|
29
|
+
await writeFile(temporary, JSON.stringify({ schema: 1, image, updatedAt: Date.now() }));
|
|
30
|
+
await rename(temporary, `${directory}/store.json`);
|
|
31
|
+
}
|
|
32
|
+
/** Standalone containerd never starts CRI/kubelet or any inherited workload.
|
|
33
|
+
* Native API deletion preserves content and committed image snapshots while
|
|
34
|
+
* releasing sandbox, container, lease and writable snapshot references. */
|
|
35
|
+
export const KUBERNETES_CACHE_MAINTENANCE_SH = `set -eu
|
|
36
|
+
exec 2>&1
|
|
37
|
+
cat > /run/cache.toml <<'CONFIG'
|
|
38
|
+
version = 2
|
|
39
|
+
disabled_plugins = ["io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "io.containerd.cri.v1.images"]
|
|
40
|
+
CONFIG
|
|
41
|
+
containerd --config /run/cache.toml --root ${STORE_TARGET} --state /run/cache-state --address ${SOCKET} > /run/cache.log 2>&1 &
|
|
42
|
+
pid=$!
|
|
43
|
+
stop() { kill "$pid"; wait "$pid"; }
|
|
44
|
+
trap 'stop; exit 0' TERM INT
|
|
45
|
+
trap 'kill "$pid" 2>/dev/null || true' EXIT
|
|
46
|
+
c() { ctr --address ${SOCKET} --namespace k8s.io "$@"; }
|
|
47
|
+
i=0
|
|
48
|
+
until c version >/dev/null 2>&1; do
|
|
49
|
+
kill -0 "$pid" || { cat /run/cache.log; exit 1; }
|
|
50
|
+
i=$((i+1)); [ "$i" -lt 300 ] || { cat /run/cache.log; exit 1; }
|
|
51
|
+
sleep 0.1
|
|
52
|
+
done
|
|
53
|
+
ids=$(c containers ls -q)
|
|
54
|
+
if [ -n "$ids" ]; then c containers rm $ids; fi
|
|
55
|
+
# 1.7 has no sandbox store; 2.x does.
|
|
56
|
+
case "$(containerd --version)" in
|
|
57
|
+
*v1.*) ;;
|
|
58
|
+
*) sandboxes=$(c sandboxes ls)
|
|
59
|
+
ids=$(printf '%s\\n' "$sandboxes" | awk 'NR > 1 {print $1}')
|
|
60
|
+
if [ -n "$ids" ]; then c sandboxes rm --force $ids; fi ;;
|
|
61
|
+
esac
|
|
62
|
+
ids=$(c leases ls -q)
|
|
63
|
+
if [ -n "$ids" ]; then c leases rm $ids; fi
|
|
64
|
+
# Remove children before parents, and tolerate asynchronous GC winning a race.
|
|
65
|
+
previous=''
|
|
66
|
+
while :; do
|
|
67
|
+
snapshots=$(c snapshots --snapshotter overlayfs ls)
|
|
68
|
+
ids=$(printf '%s\\n' "$snapshots" | awk 'NR > 1 && $NF != "Committed" {print $1}')
|
|
69
|
+
[ -n "$ids" ] || break
|
|
70
|
+
[ "$ids" != "$previous" ] || { echo 'cannot clean Kubernetes writable snapshots' >&2; exit 1; }
|
|
71
|
+
previous="$ids"
|
|
72
|
+
for id in $ids; do c snapshots --snapshotter overlayfs rm "$id" >/dev/null 2>&1 || true; done
|
|
73
|
+
done
|
|
74
|
+
touch /run/cache-ready
|
|
75
|
+
while :; do sleep 1 & wait $!; done
|
|
76
|
+
`;
|
|
77
|
+
export async function openKubernetesCacheMaintenance(run, root = ROOT) {
|
|
78
|
+
const stores = [];
|
|
79
|
+
const protectedImages = new Set();
|
|
80
|
+
const closeStore = async (store) => {
|
|
81
|
+
if (!store.active)
|
|
82
|
+
return;
|
|
83
|
+
await run("docker", ["stop", "--time", "30", store.container], 45_000);
|
|
84
|
+
const status = JSON.parse(await run("docker", ["inspect", "--format", "{{json .State}}", store.container]));
|
|
85
|
+
if (status.ExitCode !== 0 || status.OOMKilled)
|
|
86
|
+
throw new Error(`unclean Kubernetes cache shutdown: ${store.container}`);
|
|
87
|
+
await run("docker", ["rm", store.container]);
|
|
88
|
+
store.active = false;
|
|
89
|
+
if (!stores.some(other => other.active && other.descriptor.image === store.descriptor.image))
|
|
90
|
+
protectedImages.delete(store.descriptor.image);
|
|
91
|
+
// These contain runtime state, not image content or unpacked layers.
|
|
92
|
+
await run("rm", ["-rf", ...["io.containerd.runtime.v2.task", "io.containerd.grpc.v1.cri", "io.containerd.cri.v1.runtime", "tmpmounts"]
|
|
93
|
+
.map(child => `${store.directory}/containerd/${child}`)]);
|
|
94
|
+
};
|
|
95
|
+
const close = async () => {
|
|
96
|
+
const failures = [];
|
|
97
|
+
for (const store of stores) {
|
|
98
|
+
try {
|
|
99
|
+
await closeStore(store);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
failures.push(error);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (failures.length)
|
|
106
|
+
throw new AggregateError(failures, "Kubernetes cache shutdown failed");
|
|
107
|
+
};
|
|
108
|
+
const entries = await readdir(root, { withFileTypes: true }).catch((error) => {
|
|
109
|
+
if (error.code === "ENOENT")
|
|
110
|
+
return [];
|
|
111
|
+
throw error;
|
|
112
|
+
});
|
|
113
|
+
try {
|
|
114
|
+
for (const service of entries) {
|
|
115
|
+
if (!service.isDirectory() || !SERVICE_NAME.test(service.name))
|
|
116
|
+
continue;
|
|
117
|
+
for (const layout of await readdir(`${root}/${service.name}`, { withFileTypes: true })) {
|
|
118
|
+
if (!layout.isDirectory() || !/^[a-f0-9]{16}$/.test(layout.name))
|
|
119
|
+
continue;
|
|
120
|
+
const directory = `${root}/${service.name}/${layout.name}`;
|
|
121
|
+
const descriptor = JSON.parse(await readFile(`${directory}/store.json`, "utf8"));
|
|
122
|
+
if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
|
|
123
|
+
throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
|
|
124
|
+
}
|
|
125
|
+
const container = `spectest-k8s-cache-${randomUUID()}`;
|
|
126
|
+
await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
|
|
127
|
+
"--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
|
|
128
|
+
"--entrypoint", "/bin/sh", descriptor.image, "-c", KUBERNETES_CACHE_MAINTENANCE_SH]);
|
|
129
|
+
stores.push({ directory, descriptor, container, active: true });
|
|
130
|
+
protectedImages.add(descriptor.image);
|
|
131
|
+
try {
|
|
132
|
+
await run("docker", ["exec", container, "sh", "-c", "i=0; until [ -f /run/cache-ready ]; do i=$((i+1)); [ $i -lt 900 ] || exit 1; sleep 0.1; done"], 100_000);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const logs = await run("docker", ["logs", "--tail", "40", container]).catch(() => "");
|
|
136
|
+
throw new Error(`Kubernetes cache preparation failed for ${directory}: ${logs}`, { cause: error });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
stores.sort((a, b) => a.descriptor.updatedAt - b.descriptor.updatedAt || a.directory.localeCompare(b.directory));
|
|
141
|
+
let next = 0;
|
|
142
|
+
const pruneOldest = async () => {
|
|
143
|
+
const store = stores[next++];
|
|
144
|
+
if (!store)
|
|
145
|
+
return false;
|
|
146
|
+
await run("docker", ["exec", store.container, "sh", "-ec",
|
|
147
|
+
`ids=$(ctr -a ${SOCKET} -n k8s.io images ls -q); if [ -n "$ids" ]; then ctr -a ${SOCKET} -n k8s.io images rm --sync $ids; fi`], 120_000);
|
|
148
|
+
await closeStore(store);
|
|
149
|
+
// No writer remains. Discard the now-empty cache and its descriptor so
|
|
150
|
+
// obsolete k3s binaries can also be collected by the outer Docker GC.
|
|
151
|
+
await run("rm", ["-rf", store.directory]);
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
// Abandoned services/versions should not occupy the disk indefinitely.
|
|
155
|
+
while (stores[next] && stores[next].descriptor.updatedAt < Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
156
|
+
await pruneOldest();
|
|
157
|
+
return { protectedImages, pruneOldest, close };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await close().catch(() => { });
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const STICKY_ROOT = "/var/lib/spectest-sticky";
|
|
2
|
+
export declare const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
3
|
+
/** Written only after the supervisor mounts this project's private disk. */
|
|
4
|
+
export declare function stickyDiskPathSync(manifest?: string): string | null;
|
|
5
|
+
export declare function isOnStickyDisk(path: string, root?: string | null): boolean;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
export const STICKY_ROOT = "/var/lib/spectest-sticky";
|
|
4
|
+
export const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
5
|
+
/** Written only after the supervisor mounts this project's private disk. */
|
|
6
|
+
export function stickyDiskPathSync(manifest = `${STICKY_RUNTIME}/consumer.json`) {
|
|
7
|
+
try {
|
|
8
|
+
const config = JSON.parse(readFileSync(manifest, "utf8"));
|
|
9
|
+
return config.path === STICKY_ROOT ? STICKY_ROOT : null;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function isOnStickyDisk(path, root = stickyDiskPathSync()) {
|
|
16
|
+
const absolute = resolve(path);
|
|
17
|
+
return root !== null && (absolute === root || absolute.startsWith(`${root}/`));
|
|
18
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -74,6 +74,8 @@ export interface EnvironmentConfig<S extends ServicesMap = ServicesMap> {
|
|
|
74
74
|
export interface ServiceConfig {
|
|
75
75
|
/** Image definition: registry pull or inline build. */
|
|
76
76
|
image: ServiceImage;
|
|
77
|
+
/** @internal Images built by the harness and loaded into this component. */
|
|
78
|
+
imageBuilds?: readonly ComponentImageBuild[];
|
|
77
79
|
/**
|
|
78
80
|
* Shell command to run (sh -c). Defaults to the image's CMD. NB: this
|
|
79
81
|
* **replaces the image's entrypoint** with `/bin/sh -c` — an
|
|
@@ -741,11 +743,14 @@ export interface ServiceTls {
|
|
|
741
743
|
* daemon forwards proxied requests here over `spectest-net`. */
|
|
742
744
|
port: number;
|
|
743
745
|
}
|
|
744
|
-
|
|
746
|
+
/** @internal Normalized wire representation; legacy definitions remain accepted. */
|
|
747
|
+
export type NormalizedServiceImage = {
|
|
745
748
|
type: "registry";
|
|
746
749
|
reference: string;
|
|
747
750
|
} | {
|
|
748
751
|
type: "dockerfile";
|
|
752
|
+
/** Optional full local image reference. */
|
|
753
|
+
reference?: string;
|
|
749
754
|
/**
|
|
750
755
|
* Path of a Dockerfile in your repo, relative to the project root
|
|
751
756
|
* (where `spectest/` lives) — `"Dockerfile"`, `"apps/api/Dockerfile"`.
|
|
@@ -787,6 +792,8 @@ export type ServiceImage = {
|
|
|
787
792
|
buildArgs?: Readonly<Record<string, string>>;
|
|
788
793
|
} | {
|
|
789
794
|
type: "dockerfile";
|
|
795
|
+
/** Optional full local image reference. */
|
|
796
|
+
reference?: string;
|
|
790
797
|
/**
|
|
791
798
|
* Dockerfile contents, built as if the file sat outside the build
|
|
792
799
|
* context (it is never written into your repo). The context is the
|
|
@@ -815,6 +822,29 @@ export type ServiceImage = {
|
|
|
815
822
|
*/
|
|
816
823
|
buildArgs?: Readonly<Record<string, string>>;
|
|
817
824
|
};
|
|
825
|
+
/** Dockerfile build options shared by services and Kubernetes images. */
|
|
826
|
+
export type BuildImage = (Omit<Extract<NormalizedServiceImage, {
|
|
827
|
+
type: "dockerfile";
|
|
828
|
+
path: string;
|
|
829
|
+
}>, "type"> | Omit<Extract<NormalizedServiceImage, {
|
|
830
|
+
type: "dockerfile";
|
|
831
|
+
content: string;
|
|
832
|
+
}>, "type">) & {
|
|
833
|
+
/** Full image reference, e.g. "k8s.internal:5000/runtime:spectest".
|
|
834
|
+
* Optional for a service; required for a Kubernetes build. This names a
|
|
835
|
+
* local image and does not publish it to a registry. */
|
|
836
|
+
reference?: string;
|
|
837
|
+
};
|
|
838
|
+
/** A registry reference or Dockerfile build. Tagged objects from older SDKs
|
|
839
|
+
* remain accepted for compatibility; new definitions do not need `type`. */
|
|
840
|
+
export type ServiceImage = string | BuildImage | NormalizedServiceImage;
|
|
841
|
+
/** @internal Keep the wire format compatible with existing control planes. */
|
|
842
|
+
export declare function normalizeServiceImage(image: ServiceImage): NormalizedServiceImage;
|
|
843
|
+
/** @internal Serializes build-only work with the owning component. */
|
|
844
|
+
export interface ComponentImageBuild {
|
|
845
|
+
reference: string;
|
|
846
|
+
image: BuildImage;
|
|
847
|
+
}
|
|
818
848
|
/**
|
|
819
849
|
* Check a service's `image` at config time, so a mistake fails the
|
|
820
850
|
* environment load with a message that names it, instead of a build
|
|
@@ -831,7 +861,7 @@ export type ServiceImage = {
|
|
|
831
861
|
* checks go through the project-file resolver, so the same rules as
|
|
832
862
|
* `ctx.readProjectFile` apply.
|
|
833
863
|
*/
|
|
834
|
-
export declare function validateServiceImage(serviceName: string,
|
|
864
|
+
export declare function validateServiceImage(serviceName: string, input: ServiceImage): NormalizedServiceImage;
|
|
835
865
|
export interface VolumeMount {
|
|
836
866
|
/**
|
|
837
867
|
* Named shared volume. Two services mounting the same `name` share one
|