@specific.dev/spectest 0.116.0 → 0.116.2
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/harness/sticky-consumer.d.ts +11 -3
- package/dist/harness/sticky-consumer.js +67 -39
- package/dist/harness/sticky-image-retention.d.ts +15 -0
- package/dist/harness/sticky-image-retention.js +32 -0
- package/package.json +1 -1
- package/src/harness/sticky-consumer.test.ts +107 -23
- package/src/harness/sticky-consumer.ts +68 -41
- package/src/harness/sticky-image-retention.test.ts +59 -0
- package/src/harness/sticky-image-retention.ts +43 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type KubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
2
|
+
import { type StickyImageRetention } from "./sticky-image-retention.js";
|
|
2
3
|
export declare const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
3
4
|
export declare const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
4
5
|
export { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
@@ -6,18 +7,25 @@ export interface StickyConsumerConfig {
|
|
|
6
7
|
path: string;
|
|
7
8
|
capacityBytes: number;
|
|
8
9
|
headroomBytes: number;
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
/** Legacy controller fields, accepted for compatibility but not enforced. */
|
|
11
|
+
cacheTargetBytes?: number;
|
|
12
|
+
buildkitTargetBytes?: number;
|
|
11
13
|
compatibility: string;
|
|
12
14
|
}
|
|
13
15
|
declare function exec(program: string, args: string[], timeout?: number): Promise<string>;
|
|
14
16
|
export declare function validateStickyConfig(config: StickyConsumerConfig): void;
|
|
17
|
+
/** Explicit policy avoids BuildKit's default size caps (including its separate
|
|
18
|
+
* cache-mount/source cap). Use physical capacity as the nonbinding max: with
|
|
19
|
+
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
20
|
+
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
21
|
+
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
22
|
+
export declare function stickyBuildkitConfig(config: StickyConsumerConfig): string;
|
|
15
23
|
export declare function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void>;
|
|
16
24
|
/** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
|
|
17
25
|
* Only called after runtime containers/volumes have been removed on the
|
|
18
26
|
* isolated publication clone. Never removes files behind the daemons' backs.
|
|
19
27
|
*/
|
|
20
|
-
export declare function collectStickyCache(config: StickyConsumerConfig, run?: typeof exec, usage?: () => Promise<{
|
|
28
|
+
export declare function collectStickyCache(config: StickyConsumerConfig, retention: StickyImageRetention, run?: typeof exec, usage?: () => Promise<{
|
|
21
29
|
freeBytes: number;
|
|
22
30
|
totalBytes: number;
|
|
23
31
|
}>, kubernetes?: KubernetesCacheMaintenance): Promise<void>;
|
|
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
|
|
|
3
3
|
import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
|
|
4
4
|
import { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
5
5
|
import { openKubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
6
|
+
import { recordStickyImageUse } from "./sticky-image-retention.js";
|
|
6
7
|
export const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
7
8
|
export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
8
9
|
export { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
@@ -35,15 +36,40 @@ export function validateStickyConfig(config) {
|
|
|
35
36
|
if (config.path !== "/var/lib/spectest-sticky" || config.compatibility !== STICKY_COMPATIBILITY ||
|
|
36
37
|
!Number.isSafeInteger(config.capacityBytes) || config.capacityBytes <= 0 ||
|
|
37
38
|
!Number.isSafeInteger(config.headroomBytes) || config.headroomBytes < 16 * 1024 * 1024 ||
|
|
38
|
-
|
|
39
|
-
!Number.isSafeInteger(config.buildkitTargetBytes) || config.buildkitTargetBytes <= 0 ||
|
|
40
|
-
config.buildkitTargetBytes > config.cacheTargetBytes ||
|
|
41
|
-
config.cacheTargetBytes + config.headroomBytes >= config.capacityBytes) {
|
|
39
|
+
config.headroomBytes >= config.capacityBytes) {
|
|
42
40
|
throw new Error("unsupported sticky consumer profile or budget");
|
|
43
41
|
}
|
|
44
42
|
}
|
|
43
|
+
/** Explicit policy avoids BuildKit's default size caps (including its separate
|
|
44
|
+
* cache-mount/source cap). Use physical capacity as the nonbinding max: with
|
|
45
|
+
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
46
|
+
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
47
|
+
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
48
|
+
export function stickyBuildkitConfig(config) {
|
|
49
|
+
validateStickyConfig(config);
|
|
50
|
+
return `root = "${config.path}/buildkit"
|
|
51
|
+
[grpc]
|
|
52
|
+
address = ["tcp://127.0.0.1:1234"]
|
|
53
|
+
[worker.oci]
|
|
54
|
+
enabled = false
|
|
55
|
+
[worker.containerd]
|
|
56
|
+
enabled = true
|
|
57
|
+
address = "/run/containerd/containerd.sock"
|
|
58
|
+
namespace = "moby"
|
|
59
|
+
snapshotter = "overlayfs"
|
|
60
|
+
gc = true
|
|
61
|
+
[[worker.containerd.gcpolicy]]
|
|
62
|
+
all = true
|
|
63
|
+
maxUsedSpace = ${config.capacityBytes}
|
|
64
|
+
minFreeSpace = ${config.headroomBytes}
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
45
67
|
export async function initializeStickyConsumer(config) {
|
|
46
68
|
validateStickyConfig(config);
|
|
69
|
+
// Do not persist obsolete caps supplied by an older controller. Maintenance
|
|
70
|
+
// also ignores them when restoring a snapshot written by an older SDK.
|
|
71
|
+
config = { path: config.path, capacityBytes: config.capacityBytes,
|
|
72
|
+
headroomBytes: config.headroomBytes, compatibility: config.compatibility };
|
|
47
73
|
await exec("mountpoint", ["-q", config.path]);
|
|
48
74
|
// The base must have stopped these before its RAM was captured. Refuse an
|
|
49
75
|
// unexpected writer rather than racing it for the standard daemon sockets.
|
|
@@ -72,21 +98,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
72
98
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
73
99
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
74
100
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
75
|
-
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`,
|
|
76
|
-
[grpc]
|
|
77
|
-
address = ["tcp://127.0.0.1:1234"]
|
|
78
|
-
[worker.oci]
|
|
79
|
-
enabled = false
|
|
80
|
-
[worker.containerd]
|
|
81
|
-
enabled = true
|
|
82
|
-
address = "/run/containerd/containerd.sock"
|
|
83
|
-
namespace = "moby"
|
|
84
|
-
snapshotter = "overlayfs"
|
|
85
|
-
gc = true
|
|
86
|
-
reservedSpace = "1GB"
|
|
87
|
-
maxUsedSpace = ${config.buildkitTargetBytes}
|
|
88
|
-
minFreeSpace = ${config.headroomBytes}
|
|
89
|
-
`);
|
|
101
|
+
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
|
|
90
102
|
const start = async (unit, program, args) => {
|
|
91
103
|
await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
|
|
92
104
|
// Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
|
|
@@ -109,22 +121,38 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
109
121
|
* Only called after runtime containers/volumes have been removed on the
|
|
110
122
|
* isolated publication clone. Never removes files behind the daemons' backs.
|
|
111
123
|
*/
|
|
112
|
-
export async function collectStickyCache(config, run = exec, usage = async () => {
|
|
124
|
+
export async function collectStickyCache(config, retention, run = exec, usage = async () => {
|
|
113
125
|
const fs = await statfs(config.path);
|
|
114
126
|
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
|
|
115
127
|
}, kubernetes) {
|
|
116
128
|
validateStickyConfig(config);
|
|
117
129
|
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
// Docker 29's containerd until filter uses local image-record creation;
|
|
131
|
+
// inspect's Created uses the publisher's build date. Neither tracks use.
|
|
132
|
+
// Never prune a just-used image merely because either timestamp is old.
|
|
133
|
+
const protectedImage = (id) => retention.protectedImages.has(id) || kubernetes?.protectedImages.has(id);
|
|
134
|
+
const images = [...retention.images].sort((a, b) => a.lastUsed - b.lastUsed || a.id.localeCompare(b.id));
|
|
135
|
+
const expired = new Set();
|
|
136
|
+
for (const image of images) {
|
|
137
|
+
if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 6 * 60 * 60 * 1000)
|
|
138
|
+
continue;
|
|
139
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
140
|
+
expired.add(image.id);
|
|
141
|
+
}
|
|
142
|
+
// Drop expired image references first so they no longer pin obsolete build
|
|
143
|
+
// layers. Cache mounts need a longer window: a cached RUN skips mounting
|
|
144
|
+
// its package cache, even though a later dependency change will need it.
|
|
145
|
+
await run("buildctl", [...buildctl, "--keep-duration", "6h", "--filter", "type!=exec.cachemount"], 120_000);
|
|
146
|
+
await run("buildctl", [...buildctl, "--keep-duration", "168h", "--filter", "type==exec.cachemount"], 120_000);
|
|
147
|
+
const withinTarget = (fs) => fs.freeBytes >= config.headroomBytes;
|
|
121
148
|
let fs = await usage();
|
|
122
|
-
|
|
123
|
-
|
|
149
|
+
if (withinTarget(fs))
|
|
150
|
+
return;
|
|
151
|
+
// All consumers share this filesystem. Reclaim only the missing headroom;
|
|
152
|
+
// a per-consumer cap evicts useful cache even when the disk is mostly empty.
|
|
124
153
|
const pruneBuildkit = async () => {
|
|
125
|
-
await run("buildctl", [...buildctl, "--all", "--
|
|
126
|
-
String(Math.
|
|
127
|
-
String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
|
|
154
|
+
await run("buildctl", [...buildctl, "--all", "--free-storage",
|
|
155
|
+
String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
|
|
128
156
|
fs = await usage();
|
|
129
157
|
};
|
|
130
158
|
await pruneBuildkit();
|
|
@@ -135,16 +163,15 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
|
|
|
135
163
|
if (withinTarget(fs))
|
|
136
164
|
return;
|
|
137
165
|
}
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
|
|
141
|
-
const images = ids.length
|
|
142
|
-
? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
|
|
143
|
-
images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
|
|
166
|
+
// Under pressure, evict least recently used images, including within the
|
|
167
|
+
// age window, but preserve this environment's images for the next build.
|
|
144
168
|
for (const image of images) {
|
|
145
|
-
if (
|
|
169
|
+
if (expired.has(image.id) || protectedImage(image.id))
|
|
146
170
|
continue;
|
|
147
|
-
await run("docker", ["image", "rm", "-f", image.
|
|
171
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
172
|
+
fs = await usage();
|
|
173
|
+
if (withinTarget(fs))
|
|
174
|
+
return;
|
|
148
175
|
await pruneBuildkit();
|
|
149
176
|
if (withinTarget(fs))
|
|
150
177
|
return;
|
|
@@ -153,14 +180,15 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
|
|
|
153
180
|
for (let pass = 0; pass < 3 && !withinTarget(fs); pass++)
|
|
154
181
|
await pruneBuildkit();
|
|
155
182
|
if (!withinTarget(fs))
|
|
156
|
-
throw new Error("sticky cache cannot
|
|
183
|
+
throw new Error("sticky cache cannot reclaim required write headroom");
|
|
157
184
|
}
|
|
158
185
|
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
159
186
|
export async function prepareStickyConsumer(artifact = false) {
|
|
160
187
|
const config = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
|
|
161
188
|
validateStickyConfig(config);
|
|
162
189
|
if (!artifact) {
|
|
163
|
-
const
|
|
190
|
+
const retention = await recordStickyImageUse(config.path, exec);
|
|
191
|
+
const { containers } = retention;
|
|
164
192
|
if (containers.length)
|
|
165
193
|
await exec("docker", ["rm", "-f", ...containers]);
|
|
166
194
|
const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
|
|
@@ -168,7 +196,7 @@ export async function prepareStickyConsumer(artifact = false) {
|
|
|
168
196
|
await exec("docker", ["volume", "rm", ...volumes]);
|
|
169
197
|
const kubernetes = await openKubernetesCacheMaintenance(exec);
|
|
170
198
|
try {
|
|
171
|
-
await collectStickyCache(config, exec, undefined, kubernetes);
|
|
199
|
+
await collectStickyCache(config, retention, exec, undefined, kubernetes);
|
|
172
200
|
}
|
|
173
201
|
finally {
|
|
174
202
|
await kubernetes.close();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CacheCommand } from "./sticky-kubernetes.js";
|
|
2
|
+
export interface StickyImageRetention {
|
|
3
|
+
images: {
|
|
4
|
+
id: string;
|
|
5
|
+
lastUsed: number;
|
|
6
|
+
}[];
|
|
7
|
+
protectedImages: Set<string>;
|
|
8
|
+
containers: string[];
|
|
9
|
+
observedAt: number;
|
|
10
|
+
}
|
|
11
|
+
/** Call before removing containers from the isolated publication clone.
|
|
12
|
+
* New/legacy images get a first observation; inherited unused images retain
|
|
13
|
+
* their previous timestamp. Rewriting from the inventory drops stale IDs.
|
|
14
|
+
*/
|
|
15
|
+
export declare function recordStickyImageUse(root: string, run: CacheCommand, now?: number): Promise<StickyImageRetention>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Image use belongs to the cache lineage, not the publisher's image clock. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
/** Call before removing containers from the isolated publication clone.
|
|
5
|
+
* New/legacy images get a first observation; inherited unused images retain
|
|
6
|
+
* their previous timestamp. Rewriting from the inventory drops stale IDs.
|
|
7
|
+
*/
|
|
8
|
+
export async function recordStickyImageUse(root, run, now = Date.now()) {
|
|
9
|
+
const path = `${root}/.spectest-image-use.json`;
|
|
10
|
+
const saved = await readFile(path, "utf8").catch((error) => {
|
|
11
|
+
if (error.code === "ENOENT")
|
|
12
|
+
return undefined;
|
|
13
|
+
throw error;
|
|
14
|
+
});
|
|
15
|
+
const previous = saved === undefined ? { schema: 1, images: {} } : JSON.parse(saved);
|
|
16
|
+
if (previous.schema !== 1 || !previous.images || typeof previous.images !== "object" ||
|
|
17
|
+
Array.isArray(previous.images) || Object.values(previous.images).some(value => typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) {
|
|
18
|
+
throw new Error("invalid sticky image-use ledger");
|
|
19
|
+
}
|
|
20
|
+
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
|
|
21
|
+
.split(/\s+/).filter(Boolean))];
|
|
22
|
+
const containers = (await run("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
|
|
23
|
+
const protectedImages = new Set(containers.length
|
|
24
|
+
? (await run("docker", ["container", "inspect", "--format", "{{.Image}}", ...containers]))
|
|
25
|
+
.split(/\s+/).filter(Boolean) : []);
|
|
26
|
+
const images = ids.map(id => ({ id, lastUsed: protectedImages.has(id) ? now : previous.images[id] ?? now }));
|
|
27
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
28
|
+
await writeFile(temporary, JSON.stringify({ schema: 1,
|
|
29
|
+
images: Object.fromEntries(images.map(({ id, lastUsed }) => [id, lastUsed])) }));
|
|
30
|
+
await rename(temporary, path);
|
|
31
|
+
return { images, protectedImages, containers, observedAt: now };
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,55 +1,141 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache } from "./sticky-consumer";
|
|
2
|
+
import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache, stickyBuildkitConfig } from "./sticky-consumer";
|
|
3
|
+
|
|
4
|
+
import type { StickyImageRetention } from "./sticky-image-retention";
|
|
5
|
+
|
|
6
|
+
const now = Date.UTC(2026, 9, 13);
|
|
7
|
+
const retention = (images: StickyImageRetention["images"] = [], protectedImages: string[] = []): StickyImageRetention =>
|
|
8
|
+
({ images, protectedImages: new Set(protectedImages), containers: [], observedAt: now });
|
|
3
9
|
|
|
4
10
|
const GiB = 1024 ** 3;
|
|
5
11
|
const config = { path: "/var/lib/spectest-sticky", capacityBytes: 256 * GiB,
|
|
6
|
-
headroomBytes: 32 * GiB,
|
|
12
|
+
headroomBytes: 32 * GiB,
|
|
7
13
|
compatibility: STICKY_COMPATIBILITY };
|
|
8
14
|
|
|
9
|
-
test("sticky consumer validates
|
|
15
|
+
test("sticky consumer validates disk capacity and required free space", () => {
|
|
10
16
|
expect(() => validateStickyConfig(config)).not.toThrow();
|
|
11
|
-
//
|
|
17
|
+
// Retention has no per-consumer size caps.
|
|
12
18
|
expect(() => validateStickyConfig({ ...config, capacityBytes: 512 * GiB })).not.toThrow();
|
|
13
19
|
for (const override of [{ path: "/" }, { compatibility: "legacy" }, { headroomBytes: 0 },
|
|
14
20
|
{ headroomBytes: config.capacityBytes }, { capacityBytes: Number.NaN },
|
|
15
|
-
{
|
|
21
|
+
{ headroomBytes: Number.NaN }, { headroomBytes: -1 }, { capacityBytes: 0 }]) {
|
|
16
22
|
expect(() => validateStickyConfig({ ...config, ...override })).toThrow();
|
|
17
23
|
}
|
|
18
24
|
});
|
|
19
25
|
|
|
20
|
-
test("
|
|
21
|
-
|
|
26
|
+
test("automatic BuildKit GC has one free-space policy without default cache-size caps", () => {
|
|
27
|
+
const worker = (Bun.TOML.parse(stickyBuildkitConfig(config)) as any).worker.containerd;
|
|
28
|
+
expect(worker.gc).toBe(true);
|
|
29
|
+
expect(worker.gcpolicy).toEqual([{ all: true, maxUsedSpace: config.capacityBytes, minFreeSpace: 32 * GiB }]);
|
|
30
|
+
expect(worker.maxUsedSpace).toBeUndefined();
|
|
31
|
+
// Older controllers still send targets; they must not reinstate size GC.
|
|
32
|
+
expect(stickyBuildkitConfig({ ...config, cacheTargetBytes: 64 * GiB, buildkitTargetBytes: 32 * GiB }))
|
|
33
|
+
.toBe(stickyBuildkitConfig(config));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Listen-sized and larger caches retain recent build state while there is headroom", async () => {
|
|
37
|
+
for (const used of [77, 160, 219]) {
|
|
38
|
+
const calls: string[][] = [];
|
|
39
|
+
const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
|
|
40
|
+
// Real filesystem capacity excludes ext4 overhead. Legacy wire values
|
|
41
|
+
// must not turn 77 GiB of healthy cache into a 64 GiB pressure event.
|
|
42
|
+
await collectStickyCache({ ...config, cacheTargetBytes: 64 * GiB, buildkitTargetBytes: 32 * GiB },
|
|
43
|
+
retention([{ id: "postgres", lastUsed: now }]), run,
|
|
44
|
+
async () => ({ totalBytes: 251 * GiB, freeBytes: (251 - used) * GiB }));
|
|
45
|
+
expect(calls.some(c => c.includes("--free-storage") || c.includes("--keep-storage"))).toBe(false);
|
|
46
|
+
expect(calls.some(c => c[0] === "docker")).toBe(false);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("pressure reclamation stops when the free-space reserve is restored", async () => {
|
|
51
|
+
let free = 20 * GiB;
|
|
52
|
+
const calls: string[][] = [];
|
|
53
|
+
const run = async (command: string, args: string[]) => {
|
|
54
|
+
calls.push([command, ...args]);
|
|
55
|
+
if (args.includes("--free-storage")) free = 33 * GiB;
|
|
56
|
+
return "";
|
|
57
|
+
};
|
|
58
|
+
await collectStickyCache(config, retention([{ id: "unused", lastUsed: now }]), run,
|
|
59
|
+
async () => ({ totalBytes: 251 * GiB, freeBytes: free }));
|
|
60
|
+
expect(calls.filter(c => c.includes("--free-storage"))).toHaveLength(1);
|
|
61
|
+
expect(calls.some(c => c[0] === "docker")).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("filesystem pressure removes least recently used Docker images when BuildKit pruning frees nothing", async () => {
|
|
65
|
+
let used = 236 * GiB;
|
|
22
66
|
const calls: string[][] = [];
|
|
23
67
|
const run = async (command: string, args: string[]) => {
|
|
24
68
|
calls.push([command, ...args]);
|
|
25
|
-
if (command === "docker" && args[1] === "ls") return "new\nold\n";
|
|
26
|
-
if (command === "docker" && args[1] === "inspect") return JSON.stringify([
|
|
27
|
-
{ Id: "new", Created: "2026-09-29T10:00:00Z" }, { Id: "old", Created: "2026-09-28T10:00:00Z" }]);
|
|
28
69
|
if (command === "docker" && args[1] === "rm") used -= 40 * GiB;
|
|
29
70
|
return ""; // Model Harmony: BuildKit's own GC reports no reclaimable data.
|
|
30
71
|
};
|
|
31
|
-
await collectStickyCache(config,
|
|
72
|
+
await collectStickyCache(config, retention([{ id: "new", lastUsed: now }, { id: "old", lastUsed: now - 1000 }]),
|
|
73
|
+
run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }));
|
|
32
74
|
expect(calls.filter(c => c[2] === "rm")).toEqual([["docker", "image", "rm", "-f", "old"]]);
|
|
33
75
|
const prune = calls.find(c => c.includes("--free-storage"))!;
|
|
34
|
-
expect(prune
|
|
35
|
-
expect(prune[prune.indexOf("--free-storage") + 1]).toBe(String(Math.ceil(
|
|
76
|
+
expect(prune.includes("--keep-storage")).toBe(false);
|
|
77
|
+
expect(prune[prune.indexOf("--free-storage") + 1]).toBe(String(Math.ceil(32 * GiB / 1e6)));
|
|
36
78
|
});
|
|
37
79
|
|
|
38
|
-
test("healthy cache keeps
|
|
80
|
+
test("healthy cache keeps recently used images and a cache that cannot shrink is not published", async () => {
|
|
39
81
|
const calls: string[][] = [];
|
|
40
82
|
const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
|
|
41
|
-
await collectStickyCache(config,
|
|
83
|
+
await collectStickyCache(config, retention([{ id: "postgres-built-in-june", lastUsed: now }]),
|
|
84
|
+
run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
|
|
42
85
|
expect(calls.some(c => c[2] === "rm" || c[2] === "inspect")).toBe(false);
|
|
43
|
-
|
|
44
|
-
|
|
86
|
+
expect(calls.some(c => c[0] === "docker" && c.includes("prune"))).toBe(false);
|
|
87
|
+
await expect(collectStickyCache(config, retention(), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 5 * GiB })))
|
|
88
|
+
.rejects.toThrow("cannot reclaim required write headroom");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("six-hour image expiry uses last observation and respects the boundary and image protection", async () => {
|
|
92
|
+
const removed: string[] = [];
|
|
93
|
+
const run = async (command: string, args: string[]) => {
|
|
94
|
+
if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
|
|
95
|
+
return "";
|
|
96
|
+
};
|
|
97
|
+
await collectStickyCache(config, retention([
|
|
98
|
+
{ id: "unused-for-seven-hours", lastUsed: now - 7 * 3600_000 },
|
|
99
|
+
{ id: "exactly-six-hours", lastUsed: now - 6 * 3600_000 },
|
|
100
|
+
{ id: "used-five-hours-ago", lastUsed: now - 5 * 3600_000 },
|
|
101
|
+
{ id: "used-today", lastUsed: now },
|
|
102
|
+
{ id: "protected", lastUsed: 0 },
|
|
103
|
+
], ["protected"]), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
|
|
104
|
+
expect(removed).toEqual(["unused-for-seven-hours"]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("publication removes expired images before pruning build records and gives cache mounts seven days", async () => {
|
|
108
|
+
const calls: string[][] = [];
|
|
109
|
+
const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
|
|
110
|
+
await collectStickyCache(config, retention([{ id: "superseded", lastUsed: now - 7 * 3600_000 }]),
|
|
111
|
+
run, async () => ({ totalBytes: 251 * GiB, freeBytes: 170 * GiB }));
|
|
112
|
+
expect(calls).toEqual([
|
|
113
|
+
["docker", "image", "rm", "-f", "superseded"],
|
|
114
|
+
["buildctl", "--addr", "tcp://127.0.0.1:1234", "prune", "--keep-duration", "6h", "--filter", "type!=exec.cachemount"],
|
|
115
|
+
["buildctl", "--addr", "tcp://127.0.0.1:1234", "prune", "--keep-duration", "168h", "--filter", "type==exec.cachemount"],
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("pressure cannot evict the images used by the environment being published", async () => {
|
|
120
|
+
const removed: string[] = [];
|
|
121
|
+
const run = async (command: string, args: string[]) => {
|
|
122
|
+
if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
|
|
123
|
+
return "";
|
|
124
|
+
};
|
|
125
|
+
await expect(collectStickyCache(config, retention([
|
|
126
|
+
{ id: "postgres", lastUsed: now },
|
|
127
|
+
{ id: "unused", lastUsed: now - 1000 },
|
|
128
|
+
], ["postgres"]), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 20 * GiB })))
|
|
129
|
+
.rejects.toThrow("cannot reclaim required write headroom");
|
|
130
|
+
expect(removed).toEqual(["unused"]);
|
|
45
131
|
});
|
|
46
132
|
|
|
47
133
|
test("nested Kubernetes stores share the filesystem budget and are pruned before recent Docker images", async () => {
|
|
48
|
-
let used =
|
|
134
|
+
let used = 236 * GiB;
|
|
49
135
|
const calls: string[][] = [];
|
|
50
136
|
let evictions = 0;
|
|
51
137
|
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 }), {
|
|
138
|
+
await collectStickyCache(config, retention(), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }), {
|
|
53
139
|
protectedImages: new Set(), close: async () => {},
|
|
54
140
|
pruneOldest: async () => { used -= 40 * GiB; evictions++; return true; },
|
|
55
141
|
});
|
|
@@ -60,13 +146,11 @@ test("nested Kubernetes stores share the filesystem budget and are pruned before
|
|
|
60
146
|
test("GC does not remove an image while it supplies the Kubernetes maintenance daemon", async () => {
|
|
61
147
|
const removed: string[] = [];
|
|
62
148
|
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
149
|
if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
|
|
67
150
|
return "";
|
|
68
151
|
};
|
|
69
|
-
await collectStickyCache(config,
|
|
152
|
+
await collectStickyCache(config, retention([{ id: "runtime", lastUsed: 0 }, { id: "old", lastUsed: now }]),
|
|
153
|
+
run, async () => ({ totalBytes: 256 * GiB, freeBytes: (removed.length ? 220 : 20) * GiB }), {
|
|
70
154
|
protectedImages: new Set(["runtime"]), close: async () => {}, pruneOldest: async () => false,
|
|
71
155
|
});
|
|
72
156
|
expect(removed).toEqual(["old"]);
|
|
@@ -4,6 +4,7 @@ import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
import { STICKY_RUNTIME } from "./sticky-paths.js";
|
|
6
6
|
import { openKubernetesCacheMaintenance, type KubernetesCacheMaintenance } from "./sticky-kubernetes.js";
|
|
7
|
+
import { recordStickyImageUse, type StickyImageRetention } from "./sticky-image-retention.js";
|
|
7
8
|
|
|
8
9
|
export const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
9
10
|
export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
@@ -12,8 +13,9 @@ export interface StickyConsumerConfig {
|
|
|
12
13
|
path: string;
|
|
13
14
|
capacityBytes: number;
|
|
14
15
|
headroomBytes: number;
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
/** Legacy controller fields, accepted for compatibility but not enforced. */
|
|
17
|
+
cacheTargetBytes?: number;
|
|
18
|
+
buildkitTargetBytes?: number;
|
|
17
19
|
compatibility: string;
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -38,16 +40,42 @@ export function validateStickyConfig(config: StickyConsumerConfig): void {
|
|
|
38
40
|
if (config.path !== "/var/lib/spectest-sticky" || config.compatibility !== STICKY_COMPATIBILITY ||
|
|
39
41
|
!Number.isSafeInteger(config.capacityBytes) || config.capacityBytes <= 0 ||
|
|
40
42
|
!Number.isSafeInteger(config.headroomBytes) || config.headroomBytes < 16 * 1024 * 1024 ||
|
|
41
|
-
|
|
42
|
-
!Number.isSafeInteger(config.buildkitTargetBytes) || config.buildkitTargetBytes <= 0 ||
|
|
43
|
-
config.buildkitTargetBytes > config.cacheTargetBytes ||
|
|
44
|
-
config.cacheTargetBytes + config.headroomBytes >= config.capacityBytes) {
|
|
43
|
+
config.headroomBytes >= config.capacityBytes) {
|
|
45
44
|
throw new Error("unsupported sticky consumer profile or budget");
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
|
|
48
|
+
/** Explicit policy avoids BuildKit's default size caps (including its separate
|
|
49
|
+
* cache-mount/source cap). Use physical capacity as the nonbinding max: with
|
|
50
|
+
* only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
|
|
51
|
+
* and pruneOnce's strict '<' check can still evict a record on every GC pass.
|
|
52
|
+
* Actual filesystem capacity is smaller than the device due to metadata. */
|
|
53
|
+
export function stickyBuildkitConfig(config: StickyConsumerConfig): string {
|
|
54
|
+
validateStickyConfig(config);
|
|
55
|
+
return `root = "${config.path}/buildkit"
|
|
56
|
+
[grpc]
|
|
57
|
+
address = ["tcp://127.0.0.1:1234"]
|
|
58
|
+
[worker.oci]
|
|
59
|
+
enabled = false
|
|
60
|
+
[worker.containerd]
|
|
61
|
+
enabled = true
|
|
62
|
+
address = "/run/containerd/containerd.sock"
|
|
63
|
+
namespace = "moby"
|
|
64
|
+
snapshotter = "overlayfs"
|
|
65
|
+
gc = true
|
|
66
|
+
[[worker.containerd.gcpolicy]]
|
|
67
|
+
all = true
|
|
68
|
+
maxUsedSpace = ${config.capacityBytes}
|
|
69
|
+
minFreeSpace = ${config.headroomBytes}
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
49
73
|
export async function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void> {
|
|
50
74
|
validateStickyConfig(config);
|
|
75
|
+
// Do not persist obsolete caps supplied by an older controller. Maintenance
|
|
76
|
+
// also ignores them when restoring a snapshot written by an older SDK.
|
|
77
|
+
config = { path: config.path, capacityBytes: config.capacityBytes,
|
|
78
|
+
headroomBytes: config.headroomBytes, compatibility: config.compatibility };
|
|
51
79
|
await exec("mountpoint", ["-q", config.path]);
|
|
52
80
|
// The base must have stopped these before its RAM was captured. Refuse an
|
|
53
81
|
// unexpected writer rather than racing it for the standard daemon sockets.
|
|
@@ -73,21 +101,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
73
101
|
dockerConfig.features = { "containerd-snapshotter": true };
|
|
74
102
|
dockerConfig["storage-driver"] = "overlayfs";
|
|
75
103
|
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
76
|
-
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`,
|
|
77
|
-
[grpc]
|
|
78
|
-
address = ["tcp://127.0.0.1:1234"]
|
|
79
|
-
[worker.oci]
|
|
80
|
-
enabled = false
|
|
81
|
-
[worker.containerd]
|
|
82
|
-
enabled = true
|
|
83
|
-
address = "/run/containerd/containerd.sock"
|
|
84
|
-
namespace = "moby"
|
|
85
|
-
snapshotter = "overlayfs"
|
|
86
|
-
gc = true
|
|
87
|
-
reservedSpace = "1GB"
|
|
88
|
-
maxUsedSpace = ${config.buildkitTargetBytes}
|
|
89
|
-
minFreeSpace = ${config.headroomBytes}
|
|
90
|
-
`);
|
|
104
|
+
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
|
|
91
105
|
const start = async (unit: string, program: string, args: string[]) => {
|
|
92
106
|
await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
|
|
93
107
|
// Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
|
|
@@ -113,6 +127,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
|
113
127
|
*/
|
|
114
128
|
export async function collectStickyCache(
|
|
115
129
|
config: StickyConsumerConfig,
|
|
130
|
+
retention: StickyImageRetention,
|
|
116
131
|
run: typeof exec = exec,
|
|
117
132
|
usage = async (): Promise<{ freeBytes: number; totalBytes: number }> => {
|
|
118
133
|
const fs = await statfs(config.path);
|
|
@@ -122,17 +137,30 @@ export async function collectStickyCache(
|
|
|
122
137
|
): Promise<void> {
|
|
123
138
|
validateStickyConfig(config);
|
|
124
139
|
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
140
|
+
// Docker 29's containerd until filter uses local image-record creation;
|
|
141
|
+
// inspect's Created uses the publisher's build date. Neither tracks use.
|
|
142
|
+
// Never prune a just-used image merely because either timestamp is old.
|
|
143
|
+
const protectedImage = (id: string) => retention.protectedImages.has(id) || kubernetes?.protectedImages.has(id);
|
|
144
|
+
const images = [...retention.images].sort((a, b) => a.lastUsed - b.lastUsed || a.id.localeCompare(b.id));
|
|
145
|
+
const expired = new Set<string>();
|
|
146
|
+
for (const image of images) {
|
|
147
|
+
if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 6 * 60 * 60 * 1000) continue;
|
|
148
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
149
|
+
expired.add(image.id);
|
|
150
|
+
}
|
|
151
|
+
// Drop expired image references first so they no longer pin obsolete build
|
|
152
|
+
// layers. Cache mounts need a longer window: a cached RUN skips mounting
|
|
153
|
+
// its package cache, even though a later dependency change will need it.
|
|
154
|
+
await run("buildctl", [...buildctl, "--keep-duration", "6h", "--filter", "type!=exec.cachemount"], 120_000);
|
|
155
|
+
await run("buildctl", [...buildctl, "--keep-duration", "168h", "--filter", "type==exec.cachemount"], 120_000);
|
|
156
|
+
const withinTarget = (fs: { freeBytes: number }) => fs.freeBytes >= config.headroomBytes;
|
|
129
157
|
let fs = await usage();
|
|
130
|
-
|
|
131
|
-
//
|
|
158
|
+
if (withinTarget(fs)) return;
|
|
159
|
+
// All consumers share this filesystem. Reclaim only the missing headroom;
|
|
160
|
+
// a per-consumer cap evicts useful cache even when the disk is mostly empty.
|
|
132
161
|
const pruneBuildkit = async () => {
|
|
133
|
-
await run("buildctl", [...buildctl, "--all", "--
|
|
134
|
-
String(Math.
|
|
135
|
-
String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
|
|
162
|
+
await run("buildctl", [...buildctl, "--all", "--free-storage",
|
|
163
|
+
String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
|
|
136
164
|
fs = await usage();
|
|
137
165
|
};
|
|
138
166
|
await pruneBuildkit();
|
|
@@ -141,21 +169,19 @@ export async function collectStickyCache(
|
|
|
141
169
|
fs = await usage();
|
|
142
170
|
if (withinTarget(fs)) return;
|
|
143
171
|
}
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
|
|
147
|
-
const images: { Id: string; Created: string }[] = ids.length
|
|
148
|
-
? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
|
|
149
|
-
images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
|
|
172
|
+
// Under pressure, evict least recently used images, including within the
|
|
173
|
+
// age window, but preserve this environment's images for the next build.
|
|
150
174
|
for (const image of images) {
|
|
151
|
-
if (
|
|
152
|
-
await run("docker", ["image", "rm", "-f", image.
|
|
175
|
+
if (expired.has(image.id) || protectedImage(image.id)) continue;
|
|
176
|
+
await run("docker", ["image", "rm", "-f", image.id], 120_000);
|
|
177
|
+
fs = await usage();
|
|
178
|
+
if (withinTarget(fs)) return;
|
|
153
179
|
await pruneBuildkit();
|
|
154
180
|
if (withinTarget(fs)) return;
|
|
155
181
|
}
|
|
156
182
|
// Containerd GC may lag reference removal; allow bounded additional passes.
|
|
157
183
|
for (let pass = 0; pass < 3 && !withinTarget(fs); pass++) await pruneBuildkit();
|
|
158
|
-
if (!withinTarget(fs)) throw new Error("sticky cache cannot
|
|
184
|
+
if (!withinTarget(fs)) throw new Error("sticky cache cannot reclaim required write headroom");
|
|
159
185
|
}
|
|
160
186
|
|
|
161
187
|
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
@@ -163,12 +189,13 @@ export async function prepareStickyConsumer(artifact = false): Promise<{ prepare
|
|
|
163
189
|
const config: StickyConsumerConfig = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
|
|
164
190
|
validateStickyConfig(config);
|
|
165
191
|
if (!artifact) {
|
|
166
|
-
const
|
|
192
|
+
const retention = await recordStickyImageUse(config.path, exec);
|
|
193
|
+
const { containers } = retention;
|
|
167
194
|
if (containers.length) await exec("docker", ["rm", "-f", ...containers]);
|
|
168
195
|
const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
|
|
169
196
|
if (volumes.length) await exec("docker", ["volume", "rm", ...volumes]);
|
|
170
197
|
const kubernetes = await openKubernetesCacheMaintenance(exec);
|
|
171
|
-
try { await collectStickyCache(config, exec, undefined, kubernetes); }
|
|
198
|
+
try { await collectStickyCache(config, retention, exec, undefined, kubernetes); }
|
|
172
199
|
finally { await kubernetes.close(); }
|
|
173
200
|
}
|
|
174
201
|
const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { recordStickyImageUse } from "./sticky-image-retention";
|
|
6
|
+
import { collectStickyCache, STICKY_COMPATIBILITY } from "./sticky-consumer";
|
|
7
|
+
|
|
8
|
+
test("publication records container use before removal and carries it into the next generation", async () => {
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "sticky-image-use-"));
|
|
10
|
+
try {
|
|
11
|
+
const now = Date.UTC(2026, 9, 13), GiB = 1024 ** 3;
|
|
12
|
+
const path = `${root}/.spectest-image-use.json`;
|
|
13
|
+
await writeFile(path, JSON.stringify({ schema: 1, images: {
|
|
14
|
+
postgres: now - 10 * 86400_000, unused: now - 9 * 86400_000, gone: now,
|
|
15
|
+
} }));
|
|
16
|
+
let containers = true;
|
|
17
|
+
const calls: string[][] = [];
|
|
18
|
+
const run = async (command: string, args: string[]) => {
|
|
19
|
+
calls.push([command, ...args]);
|
|
20
|
+
if (command === "docker" && args[0] === "image" && args[1] === "ls") return "postgres\nnew\nunused\npostgres";
|
|
21
|
+
if (command === "docker" && args[0] === "ps") return containers ? "db-container" : "";
|
|
22
|
+
if (command === "docker" && args[0] === "container" && args[1] === "inspect") return "postgres";
|
|
23
|
+
return "";
|
|
24
|
+
};
|
|
25
|
+
const first = await recordStickyImageUse(root, run, now);
|
|
26
|
+
expect(first.containers).toEqual(["db-container"]);
|
|
27
|
+
expect(first.protectedImages).toEqual(new Set(["postgres"]));
|
|
28
|
+
expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ schema: 1,
|
|
29
|
+
images: { postgres: now, new: now, unused: now - 9 * 86400_000 } });
|
|
30
|
+
containers = false; // Publication removes the containers after recording use.
|
|
31
|
+
await collectStickyCache({ path: "/var/lib/spectest-sticky", capacityBytes: 256 * GiB,
|
|
32
|
+
headroomBytes: 32 * GiB,
|
|
33
|
+
compatibility: STICKY_COMPATIBILITY }, first, run,
|
|
34
|
+
async () => ({ totalBytes: 256 * GiB, freeBytes: 200 * GiB }));
|
|
35
|
+
expect(calls.filter(c => c[0] === "docker" && c[2] === "rm"))
|
|
36
|
+
.toEqual([["docker", "image", "rm", "-f", "unused"]]);
|
|
37
|
+
expect(calls.some(c => c.includes("until=168h"))).toBe(false);
|
|
38
|
+
const next = await recordStickyImageUse(root, run, now + 86400_000);
|
|
39
|
+
expect(next.images.find(image => image.id === "postgres")?.lastUsed).toBe(now);
|
|
40
|
+
expect(next.protectedImages.size).toBe(0);
|
|
41
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("legacy generations give unknown images a first observation instead of inferring publisher age", async () => {
|
|
45
|
+
const root = await mkdtemp(join(tmpdir(), "sticky-image-use-"));
|
|
46
|
+
try {
|
|
47
|
+
const state = await recordStickyImageUse(root, async (_, args) => args[1] === "ls" ? "old-published-image" : "", 1234);
|
|
48
|
+
expect(state.images).toEqual([{ id: "old-published-image", lastUsed: 1234 }]);
|
|
49
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("invalid image-use history fails publication rather than treating every image as expired", async () => {
|
|
53
|
+
const root = await mkdtemp(join(tmpdir(), "sticky-image-use-"));
|
|
54
|
+
try {
|
|
55
|
+
await writeFile(`${root}/.spectest-image-use.json`, JSON.stringify({ schema: 1, images: { postgres: "yesterday" } }));
|
|
56
|
+
await expect(recordStickyImageUse(root, async () => { throw new Error("must not reach Docker"); }))
|
|
57
|
+
.rejects.toThrow("invalid sticky image-use ledger");
|
|
58
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
59
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Image use belongs to the cache lineage, not the publisher's image clock. */
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import type { CacheCommand } from "./sticky-kubernetes.js";
|
|
5
|
+
|
|
6
|
+
export interface StickyImageRetention {
|
|
7
|
+
images: { id: string; lastUsed: number }[];
|
|
8
|
+
protectedImages: Set<string>;
|
|
9
|
+
containers: string[];
|
|
10
|
+
observedAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Call before removing containers from the isolated publication clone.
|
|
14
|
+
* New/legacy images get a first observation; inherited unused images retain
|
|
15
|
+
* their previous timestamp. Rewriting from the inventory drops stale IDs.
|
|
16
|
+
*/
|
|
17
|
+
export async function recordStickyImageUse(
|
|
18
|
+
root: string, run: CacheCommand, now = Date.now(),
|
|
19
|
+
): Promise<StickyImageRetention> {
|
|
20
|
+
const path = `${root}/.spectest-image-use.json`;
|
|
21
|
+
const saved = await readFile(path, "utf8").catch((error: NodeJS.ErrnoException) => {
|
|
22
|
+
if (error.code === "ENOENT") return undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
});
|
|
25
|
+
const previous = saved === undefined ? { schema: 1, images: {} } : JSON.parse(saved);
|
|
26
|
+
if (previous.schema !== 1 || !previous.images || typeof previous.images !== "object" ||
|
|
27
|
+
Array.isArray(previous.images) || Object.values(previous.images).some(value =>
|
|
28
|
+
typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) {
|
|
29
|
+
throw new Error("invalid sticky image-use ledger");
|
|
30
|
+
}
|
|
31
|
+
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
|
|
32
|
+
.split(/\s+/).filter(Boolean))];
|
|
33
|
+
const containers = (await run("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
|
|
34
|
+
const protectedImages = new Set(containers.length
|
|
35
|
+
? (await run("docker", ["container", "inspect", "--format", "{{.Image}}", ...containers]))
|
|
36
|
+
.split(/\s+/).filter(Boolean) : []);
|
|
37
|
+
const images = ids.map(id => ({ id, lastUsed: protectedImages.has(id) ? now : previous.images[id] ?? now }));
|
|
38
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
39
|
+
await writeFile(temporary, JSON.stringify({ schema: 1,
|
|
40
|
+
images: Object.fromEntries(images.map(({ id, lastUsed }) => [id, lastUsed])) }));
|
|
41
|
+
await rename(temporary, path);
|
|
42
|
+
return { images, protectedImages, containers, observedAt: now };
|
|
43
|
+
}
|