@specific.dev/spectest 0.116.0 → 0.116.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- cacheTargetBytes: number;
10
- buildkitTargetBytes: number;
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
- !Number.isSafeInteger(config.cacheTargetBytes) || config.cacheTargetBytes <= 0 ||
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`, `root = "${config.path}/buildkit"
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,33 @@ 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
130
  await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
119
- await run("docker", ["image", "prune", "-af", "--filter", "until=168h"], 120_000);
120
- const withinTarget = (fs) => fs.freeBytes >= config.headroomBytes && fs.totalBytes - fs.freeBytes <= config.cacheTargetBytes;
131
+ // Docker's until filter and Created field refer to the publisher's build
132
+ // date, not our use. Never prune a just-used image merely because it 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 - 7 * 24 * 60 * 60 * 1000)
138
+ continue;
139
+ await run("docker", ["image", "rm", "-f", image.id], 120_000);
140
+ expired.add(image.id);
141
+ }
142
+ const withinTarget = (fs) => fs.freeBytes >= config.headroomBytes;
121
143
  let fs = await usage();
122
- // Always enforce the independent BuildKit target. Its accounting alone
123
- // cannot bound shared images/content, so also request real filesystem space.
144
+ if (withinTarget(fs))
145
+ return;
146
+ // All consumers share this filesystem. Reclaim only the missing headroom;
147
+ // a per-consumer cap evicts useful cache even when the disk is mostly empty.
124
148
  const pruneBuildkit = async () => {
125
- await run("buildctl", [...buildctl, "--all", "--keep-storage",
126
- String(Math.floor(config.buildkitTargetBytes / 1e6)), "--free-storage",
127
- String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
149
+ await run("buildctl", [...buildctl, "--all", "--free-storage",
150
+ String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
128
151
  fs = await usage();
129
152
  };
130
153
  await pruneBuildkit();
@@ -135,16 +158,15 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
135
158
  if (withinTarget(fs))
136
159
  return;
137
160
  }
138
- // Pressure can be entirely in recent Docker images. Drop oldest images
139
- // first, even within the age window, until the whole filesystem fits.
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));
161
+ // Under pressure, evict least recently used images, including within the
162
+ // age window, but preserve this environment's images for the next build.
144
163
  for (const image of images) {
145
- if (kubernetes?.protectedImages.has(image.Id))
164
+ if (expired.has(image.id) || protectedImage(image.id))
146
165
  continue;
147
- await run("docker", ["image", "rm", "-f", image.Id], 120_000);
166
+ await run("docker", ["image", "rm", "-f", image.id], 120_000);
167
+ fs = await usage();
168
+ if (withinTarget(fs))
169
+ return;
148
170
  await pruneBuildkit();
149
171
  if (withinTarget(fs))
150
172
  return;
@@ -153,14 +175,15 @@ export async function collectStickyCache(config, run = exec, usage = async () =>
153
175
  for (let pass = 0; pass < 3 && !withinTarget(fs); pass++)
154
176
  await pruneBuildkit();
155
177
  if (!withinTarget(fs))
156
- throw new Error("sticky cache cannot meet filesystem retention target and write headroom");
178
+ throw new Error("sticky cache cannot reclaim required write headroom");
157
179
  }
158
180
  /** Only on an isolated maintenance clone; never on the live ready environment. */
159
181
  export async function prepareStickyConsumer(artifact = false) {
160
182
  const config = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
161
183
  validateStickyConfig(config);
162
184
  if (!artifact) {
163
- const containers = (await exec("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
185
+ const retention = await recordStickyImageUse(config.path, exec);
186
+ const { containers } = retention;
164
187
  if (containers.length)
165
188
  await exec("docker", ["rm", "-f", ...containers]);
166
189
  const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
@@ -168,7 +191,7 @@ export async function prepareStickyConsumer(artifact = false) {
168
191
  await exec("docker", ["volume", "rm", ...volumes]);
169
192
  const kubernetes = await openKubernetesCacheMaintenance(exec);
170
193
  try {
171
- await collectStickyCache(config, exec, undefined, kubernetes);
194
+ await collectStickyCache(config, retention, exec, undefined, kubernetes);
172
195
  }
173
196
  finally {
174
197
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.116.0",
3
+ "version": "0.116.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,55 +1,127 @@
1
1
  import { expect, test } from "bun:test";
2
- import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache } from "./sticky-consumer";
2
+ import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache, stickyBuildkitConfig } from "./sticky-consumer";
3
+
4
+ import type { StickyImageRetention } from "./sticky-image-retention";
5
+
6
+ const now = Date.UTC(2026, 9, 13);
7
+ const retention = (images: StickyImageRetention["images"] = [], protectedImages: string[] = []): StickyImageRetention =>
8
+ ({ images, protectedImages: new Set(protectedImages), containers: [], observedAt: now });
3
9
 
4
10
  const GiB = 1024 ** 3;
5
11
  const config = { path: "/var/lib/spectest-sticky", capacityBytes: 256 * GiB,
6
- headroomBytes: 32 * GiB, cacheTargetBytes: 64 * GiB, buildkitTargetBytes: 32 * GiB,
12
+ headroomBytes: 32 * GiB,
7
13
  compatibility: STICKY_COMPATIBILITY };
8
14
 
9
- test("sticky consumer validates independent retention and working budgets", () => {
15
+ test("sticky consumer validates disk capacity and required free space", () => {
10
16
  expect(() => validateStickyConfig(config)).not.toThrow();
11
- // Changing capacity does not require increasing either retention target.
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
- { cacheTargetBytes: 240 * GiB }, { buildkitTargetBytes: 65 * GiB }, { cacheTargetBytes: Number.NaN }]) {
21
+ { headroomBytes: Number.NaN }, { headroomBytes: -1 }, { capacityBytes: 0 }]) {
16
22
  expect(() => validateStickyConfig({ ...config, ...override })).toThrow();
17
23
  }
18
24
  });
19
25
 
20
- test("filesystem pressure removes oldest Docker images when BuildKit pruning frees nothing", async () => {
21
- let used = 100 * GiB;
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, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }));
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[prune.indexOf("--keep-storage") + 1]).toBe(String(Math.floor(32 * GiB / 1e6)));
35
- expect(prune[prune.indexOf("--free-storage") + 1]).toBe(String(Math.ceil(192 * GiB / 1e6)));
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 recent images and a cache that cannot shrink is not published", async () => {
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, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
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
- await expect(collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 5 * GiB })))
44
- .rejects.toThrow("cannot meet filesystem retention target");
86
+ expect(calls.some(c => c[0] === "docker" && c.includes("prune"))).toBe(false);
87
+ await expect(collectStickyCache(config, retention(), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 5 * GiB })))
88
+ .rejects.toThrow("cannot reclaim required write headroom");
89
+ });
90
+
91
+ test("seven-day expiry uses our last observation and respects image protection", async () => {
92
+ const removed: string[] = [];
93
+ const run = async (command: string, args: string[]) => {
94
+ if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
95
+ return "";
96
+ };
97
+ await collectStickyCache(config, retention([
98
+ { id: "unused-for-eight-days", lastUsed: now - 8 * 86400_000 },
99
+ { id: "used-today", lastUsed: now },
100
+ { id: "protected", lastUsed: 0 },
101
+ ], ["protected"]), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
102
+ expect(removed).toEqual(["unused-for-eight-days"]);
103
+ });
104
+
105
+ test("pressure cannot evict the images used by the environment being published", async () => {
106
+ const removed: string[] = [];
107
+ const run = async (command: string, args: string[]) => {
108
+ if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
109
+ return "";
110
+ };
111
+ await expect(collectStickyCache(config, retention([
112
+ { id: "postgres", lastUsed: now },
113
+ { id: "unused", lastUsed: now - 1000 },
114
+ ], ["postgres"]), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 20 * GiB })))
115
+ .rejects.toThrow("cannot reclaim required write headroom");
116
+ expect(removed).toEqual(["unused"]);
45
117
  });
46
118
 
47
119
  test("nested Kubernetes stores share the filesystem budget and are pruned before recent Docker images", async () => {
48
- let used = 100 * GiB;
120
+ let used = 236 * GiB;
49
121
  const calls: string[][] = [];
50
122
  let evictions = 0;
51
123
  const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
52
- await collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }), {
124
+ await collectStickyCache(config, retention(), run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }), {
53
125
  protectedImages: new Set(), close: async () => {},
54
126
  pruneOldest: async () => { used -= 40 * GiB; evictions++; return true; },
55
127
  });
@@ -60,13 +132,11 @@ test("nested Kubernetes stores share the filesystem budget and are pruned before
60
132
  test("GC does not remove an image while it supplies the Kubernetes maintenance daemon", async () => {
61
133
  const removed: string[] = [];
62
134
  const run = async (command: string, args: string[]) => {
63
- if (command === "docker" && args[1] === "ls") return "runtime\nold";
64
- if (command === "docker" && args[1] === "inspect") return JSON.stringify([
65
- { Id: "runtime", Created: "2020" }, { Id: "old", Created: "2021" }]);
66
135
  if (command === "docker" && args[1] === "rm") removed.push(args.at(-1)!);
67
136
  return "";
68
137
  };
69
- await collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: (removed.length ? 220 : 150) * GiB }), {
138
+ await collectStickyCache(config, retention([{ id: "runtime", lastUsed: 0 }, { id: "old", lastUsed: now }]),
139
+ run, async () => ({ totalBytes: 256 * GiB, freeBytes: (removed.length ? 220 : 20) * GiB }), {
70
140
  protectedImages: new Set(["runtime"]), close: async () => {}, pruneOldest: async () => false,
71
141
  });
72
142
  expect(removed).toEqual(["old"]);
@@ -4,6 +4,7 @@ import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
4
4
 
5
5
  import { STICKY_RUNTIME } from "./sticky-paths.js";
6
6
  import { openKubernetesCacheMaintenance, type KubernetesCacheMaintenance } from "./sticky-kubernetes.js";
7
+ import { recordStickyImageUse, type StickyImageRetention } from "./sticky-image-retention.js";
7
8
 
8
9
  export const STICKY_CAPABILITY = "sticky-disks-v1";
9
10
  export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
@@ -12,8 +13,9 @@ export interface StickyConsumerConfig {
12
13
  path: string;
13
14
  capacityBytes: number;
14
15
  headroomBytes: number;
15
- cacheTargetBytes: number;
16
- buildkitTargetBytes: number;
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
- !Number.isSafeInteger(config.cacheTargetBytes) || config.cacheTargetBytes <= 0 ||
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`, `root = "${config.path}/buildkit"
77
- [grpc]
78
- address = ["tcp://127.0.0.1:1234"]
79
- [worker.oci]
80
- enabled = false
81
- [worker.containerd]
82
- enabled = true
83
- address = "/run/containerd/containerd.sock"
84
- namespace = "moby"
85
- snapshotter = "overlayfs"
86
- gc = true
87
- reservedSpace = "1GB"
88
- maxUsedSpace = ${config.buildkitTargetBytes}
89
- minFreeSpace = ${config.headroomBytes}
90
- `);
104
+ await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
91
105
  const start = async (unit: string, program: string, args: string[]) => {
92
106
  await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
93
107
  // Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
@@ -113,6 +127,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
113
127
  */
114
128
  export async function collectStickyCache(
115
129
  config: StickyConsumerConfig,
130
+ retention: StickyImageRetention,
116
131
  run: typeof exec = exec,
117
132
  usage = async (): Promise<{ freeBytes: number; totalBytes: number }> => {
118
133
  const fs = await statfs(config.path);
@@ -123,16 +138,24 @@ export async function collectStickyCache(
123
138
  validateStickyConfig(config);
124
139
  const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
125
140
  await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
126
- await run("docker", ["image", "prune", "-af", "--filter", "until=168h"], 120_000);
127
- const withinTarget = (fs: { freeBytes: number; totalBytes: number }) =>
128
- fs.freeBytes >= config.headroomBytes && fs.totalBytes - fs.freeBytes <= config.cacheTargetBytes;
141
+ // Docker's until filter and Created field refer to the publisher's build
142
+ // date, not our use. Never prune a just-used image merely because it is old.
143
+ const protectedImage = (id: string) => retention.protectedImages.has(id) || kubernetes?.protectedImages.has(id);
144
+ const images = [...retention.images].sort((a, b) => a.lastUsed - b.lastUsed || a.id.localeCompare(b.id));
145
+ const expired = new Set<string>();
146
+ for (const image of images) {
147
+ if (protectedImage(image.id) || image.lastUsed >= retention.observedAt - 7 * 24 * 60 * 60 * 1000) continue;
148
+ await run("docker", ["image", "rm", "-f", image.id], 120_000);
149
+ expired.add(image.id);
150
+ }
151
+ const withinTarget = (fs: { freeBytes: number }) => fs.freeBytes >= config.headroomBytes;
129
152
  let fs = await usage();
130
- // Always enforce the independent BuildKit target. Its accounting alone
131
- // cannot bound shared images/content, so also request real filesystem space.
153
+ if (withinTarget(fs)) return;
154
+ // All consumers share this filesystem. Reclaim only the missing headroom;
155
+ // a per-consumer cap evicts useful cache even when the disk is mostly empty.
132
156
  const pruneBuildkit = async () => {
133
- await run("buildctl", [...buildctl, "--all", "--keep-storage",
134
- String(Math.floor(config.buildkitTargetBytes / 1e6)), "--free-storage",
135
- String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
157
+ await run("buildctl", [...buildctl, "--all", "--free-storage",
158
+ String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
136
159
  fs = await usage();
137
160
  };
138
161
  await pruneBuildkit();
@@ -141,21 +164,19 @@ export async function collectStickyCache(
141
164
  fs = await usage();
142
165
  if (withinTarget(fs)) return;
143
166
  }
144
- // Pressure can be entirely in recent Docker images. Drop oldest images
145
- // first, even within the age window, until the whole filesystem fits.
146
- const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
147
- const images: { Id: string; Created: string }[] = ids.length
148
- ? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
149
- images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
167
+ // Under pressure, evict least recently used images, including within the
168
+ // age window, but preserve this environment's images for the next build.
150
169
  for (const image of images) {
151
- if (kubernetes?.protectedImages.has(image.Id)) continue;
152
- await run("docker", ["image", "rm", "-f", image.Id], 120_000);
170
+ if (expired.has(image.id) || protectedImage(image.id)) continue;
171
+ await run("docker", ["image", "rm", "-f", image.id], 120_000);
172
+ fs = await usage();
173
+ if (withinTarget(fs)) return;
153
174
  await pruneBuildkit();
154
175
  if (withinTarget(fs)) return;
155
176
  }
156
177
  // Containerd GC may lag reference removal; allow bounded additional passes.
157
178
  for (let pass = 0; pass < 3 && !withinTarget(fs); pass++) await pruneBuildkit();
158
- if (!withinTarget(fs)) throw new Error("sticky cache cannot meet filesystem retention target and write headroom");
179
+ if (!withinTarget(fs)) throw new Error("sticky cache cannot reclaim required write headroom");
159
180
  }
160
181
 
161
182
  /** Only on an isolated maintenance clone; never on the live ready environment. */
@@ -163,12 +184,13 @@ export async function prepareStickyConsumer(artifact = false): Promise<{ prepare
163
184
  const config: StickyConsumerConfig = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
164
185
  validateStickyConfig(config);
165
186
  if (!artifact) {
166
- const containers = (await exec("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
187
+ const retention = await recordStickyImageUse(config.path, exec);
188
+ const { containers } = retention;
167
189
  if (containers.length) await exec("docker", ["rm", "-f", ...containers]);
168
190
  const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
169
191
  if (volumes.length) await exec("docker", ["volume", "rm", ...volumes]);
170
192
  const kubernetes = await openKubernetesCacheMaintenance(exec);
171
- try { await collectStickyCache(config, exec, undefined, kubernetes); }
193
+ try { await collectStickyCache(config, retention, exec, undefined, kubernetes); }
172
194
  finally { await kubernetes.close(); }
173
195
  }
174
196
  const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
@@ -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
+ }