@specific.dev/spectest 0.131.4 → 0.131.6

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/daemon.js CHANGED
@@ -47,6 +47,7 @@ import { isDesktopApp } from "./desktop.js";
47
47
  // each easy to restate subtly differently.
48
48
  import { buildArgFlags, buildContentKey as computeBuildContentKey, buildTargetFlags, imageTag, isGeneratedDockerignore, pickProjectIgnore, serviceDockerignore as composeServiceDockerignore, unionDockerignore, } from "./harness/build-context.js";
49
49
  import { startServices } from "./harness/service-graph.js";
50
+ import { ensureStickyBuildHeadroom } from "./harness/sticky-consumer.js";
50
51
  import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
51
52
  import { dispatchFake } from "./harness/fake-websocket.js";
52
53
  import { casesMetadata as catalogueCases, groupsMetadata as catalogueGroups, } from "./harness/catalogue.js";
@@ -2667,6 +2668,7 @@ async function bootstrapInner(prepared, identity) {
2667
2668
  if (identity !== undefined) {
2668
2669
  // No application services, setup hooks, ingress, or runtime state in the builder.
2669
2670
  await Promise.all(prep.values());
2671
+ await ensureStickyBuildHeadroom();
2670
2672
  const images = Object.create(null);
2671
2673
  for (const svc of tasks) {
2672
2674
  const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)], 30_000);
@@ -2759,6 +2761,7 @@ async function bootstrapInner(prepared, identity) {
2759
2761
  ti.setupMs = svc.setup ? Date.now() - tSetup : 0;
2760
2762
  }
2761
2763
  });
2764
+ await ensureStickyBuildHeadroom();
2762
2765
  // Containers now have IPs — re-seed so service-targeted wildcards (e.g.
2763
2766
  // k3s ingressDomains → the cluster container) resolve. Captured into the
2764
2767
  // warm template, so warm starts inherit the resolved entries.
@@ -19,8 +19,18 @@ export declare function validateStickyConfig(config: StickyConsumerConfig): void
19
19
  * only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
20
20
  * and pruneOnce's strict '<' check can still evict a record on every GC pass.
21
21
  * Actual filesystem capacity is smaller than the device due to metadata. */
22
- export declare function stickyBuildkitConfig(config: StickyConsumerConfig): string;
22
+ export declare function stickyBuildkitConfig(config: StickyConsumerConfig, reservedBytes?: number): string;
23
23
  export declare function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void>;
24
+ /** Builds can finish before automatic GC restores its free-space reserve.
25
+ * Reclaim only unused BuildKit records before project setup adds runtime data.
26
+ * Docker images, containers, volumes and nested Kubernetes stores stay live.
27
+ */
28
+ export declare function ensureBuildHeadroom(config: StickyConsumerConfig, run?: typeof exec, usage?: () => Promise<{
29
+ freeBytes: number;
30
+ totalBytes: number;
31
+ reservedBytes: number;
32
+ }>): Promise<void>;
33
+ export declare function ensureStickyBuildHeadroom(): Promise<void>;
24
34
  /** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
25
35
  * Only called after runtime containers/volumes have been removed on the
26
36
  * isolated publication clone. Never removes files behind the daemons' backs.
@@ -28,6 +38,7 @@ export declare function initializeStickyConsumer(config: StickyConsumerConfig):
28
38
  export declare function collectStickyCache(config: StickyConsumerConfig, retention: StickyImageRetention, run?: typeof exec, usage?: () => Promise<{
29
39
  freeBytes: number;
30
40
  totalBytes: number;
41
+ reservedBytes?: number;
31
42
  }>, kubernetes?: KubernetesCacheMaintenance): Promise<void>;
32
43
  /** Only on an isolated maintenance clone; never on the live ready environment. */
33
44
  export declare function prepareStickyConsumer(artifact?: boolean): Promise<{
@@ -45,7 +45,7 @@ export function validateStickyConfig(config) {
45
45
  * only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
46
46
  * and pruneOnce's strict '<' check can still evict a record on every GC pass.
47
47
  * Actual filesystem capacity is smaller than the device due to metadata. */
48
- export function stickyBuildkitConfig(config) {
48
+ export function stickyBuildkitConfig(config, reservedBytes = 0) {
49
49
  validateStickyConfig(config);
50
50
  return `root = "${config.path}/buildkit"
51
51
  # This standalone BuildKit does not inherit dockerd's registry mirrors.
@@ -65,7 +65,7 @@ export function stickyBuildkitConfig(config) {
65
65
  [[worker.containerd.gcpolicy]]
66
66
  all = true
67
67
  maxUsedSpace = ${config.capacityBytes}
68
- minFreeSpace = ${config.headroomBytes}
68
+ minFreeSpace = ${config.headroomBytes + reservedBytes}
69
69
  `;
70
70
  }
71
71
  export async function initializeStickyConsumer(config) {
@@ -102,7 +102,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
102
102
  dockerConfig.features = { "containerd-snapshotter": true };
103
103
  dockerConfig["storage-driver"] = "overlayfs";
104
104
  await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
105
- await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
105
+ await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
106
106
  const start = async (unit, program, args) => {
107
107
  await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
108
108
  // Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
@@ -121,14 +121,47 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
121
121
  await exec("docker", ["buildx", "create", "--name", "spectest-local", "--driver", "remote", "tcp://127.0.0.1:1234"]);
122
122
  await exec("docker", ["buildx", "inspect", "--bootstrap", "spectest-local"]);
123
123
  }
124
+ async function diskUsage(path) {
125
+ const fs = await statfs(path);
126
+ return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize,
127
+ reservedBytes: Math.max(0, fs.bfree - fs.bavail) * fs.bsize };
128
+ }
129
+ // BuildKit 0.32.2 compares MinFreeSpace against statfs.bfree, whereas
130
+ // applications and kubelet need bavail. Include ext4's reserved blocks so
131
+ // pruning restores the required usable space instead of stopping early.
132
+ function pruneTarget(config, usage) {
133
+ return String(Math.ceil((config.headroomBytes + (usage.reservedBytes ?? 0)) / 1e6));
134
+ }
135
+ /** Builds can finish before automatic GC restores its free-space reserve.
136
+ * Reclaim only unused BuildKit records before project setup adds runtime data.
137
+ * Docker images, containers, volumes and nested Kubernetes stores stay live.
138
+ */
139
+ export async function ensureBuildHeadroom(config, run = exec, usage = () => diskUsage(config.path)) {
140
+ validateStickyConfig(config);
141
+ let fs = await usage();
142
+ for (let pass = 0; fs.freeBytes < config.headroomBytes && pass < 3; pass++) {
143
+ await run("buildctl", ["--addr", "tcp://127.0.0.1:1234", "prune", "--all", "--free-storage",
144
+ pruneTarget(config, fs)], 120_000);
145
+ fs = await usage();
146
+ }
147
+ if (fs.freeBytes < config.headroomBytes) {
148
+ throw new Error(`environment build left insufficient disk headroom: ${Math.floor(fs.freeBytes / 2 ** 20)} MiB available, ${Math.ceil(config.headroomBytes / 2 ** 20)} MiB required`);
149
+ }
150
+ }
151
+ export async function ensureStickyBuildHeadroom() {
152
+ const saved = await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8").catch((error) => {
153
+ if (error.code === "ENOENT")
154
+ return undefined;
155
+ throw error;
156
+ });
157
+ if (saved !== undefined)
158
+ await ensureBuildHeadroom(JSON.parse(saved));
159
+ }
124
160
  /** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
125
161
  * Only called after runtime containers/volumes have been removed on the
126
162
  * isolated publication clone. Never removes files behind the daemons' backs.
127
163
  */
128
- export async function collectStickyCache(config, retention, run = exec, usage = async () => {
129
- const fs = await statfs(config.path);
130
- return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
131
- }, kubernetes) {
164
+ export async function collectStickyCache(config, retention, run = exec, usage = () => diskUsage(config.path), kubernetes) {
132
165
  validateStickyConfig(config);
133
166
  const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
134
167
  // Docker 29's containerd until filter uses local image-record creation;
@@ -156,7 +189,7 @@ export async function collectStickyCache(config, retention, run = exec, usage =
156
189
  // a per-consumer cap evicts useful cache even when the disk is mostly empty.
157
190
  const pruneBuildkit = async () => {
158
191
  await run("buildctl", [...buildctl, "--all", "--free-storage",
159
- String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
192
+ pruneTarget(config, fs)], 120_000);
160
193
  fs = await usage();
161
194
  };
162
195
  await pruneBuildkit();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.4",
3
+ "version": "0.131.6",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/daemon.ts CHANGED
@@ -99,6 +99,7 @@ import {
99
99
  unionDockerignore,
100
100
  } from "./harness/build-context.js";
101
101
  import { startServices } from "./harness/service-graph.js";
102
+ import { ensureStickyBuildHeadroom } from "./harness/sticky-consumer.js";
102
103
  import { fakeFunctions, fakeFunctionParameters, callFakeFunction } from "./harness/fake-functions.js";
103
104
  import { dispatchFake } from "./harness/fake-websocket.js";
104
105
  import {
@@ -3167,6 +3168,7 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3167
3168
  if (identity !== undefined) {
3168
3169
  // No application services, setup hooks, ingress, or runtime state in the builder.
3169
3170
  await Promise.all(prep.values());
3171
+ await ensureStickyBuildHeadroom();
3170
3172
  const images: PreparedImages["images"] = Object.create(null);
3171
3173
  for (const svc of tasks) {
3172
3174
  const out = await docker(["image", "inspect", "--format", "{{.Id}}", tags.get(svc.name)!], 30_000);
@@ -3258,6 +3260,8 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3258
3260
  }
3259
3261
  });
3260
3262
 
3263
+ await ensureStickyBuildHeadroom();
3264
+
3261
3265
  // Containers now have IPs — re-seed so service-targeted wildcards (e.g.
3262
3266
  // k3s ingressDomains → the cluster container) resolve. Captured into the
3263
3267
  // warm template, so warm starts inherit the resolved entries.
@@ -1,5 +1,5 @@
1
1
  import { expect, test } from "bun:test";
2
- import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache, stickyBuildkitConfig } from "./sticky-consumer";
2
+ import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache, stickyBuildkitConfig, ensureBuildHeadroom } from "./sticky-consumer";
3
3
 
4
4
  import type { StickyImageRetention } from "./sticky-image-retention";
5
5
 
@@ -12,6 +12,41 @@ const config = { path: "/var/lib/spectest-sticky", capacityBytes: 256 * GiB,
12
12
  headroomBytes: 32 * GiB,
13
13
  compatibility: STICKY_COMPATIBILITY };
14
14
 
15
+ test("completed builds preserve healthy caches and restore only missing runtime headroom", async () => {
16
+ let free = 32 * GiB;
17
+ const calls: string[][] = [];
18
+ const run = async (command: string, args: string[]) => {
19
+ calls.push([command, ...args]);
20
+ free += 10 * GiB;
21
+ return "";
22
+ };
23
+ await ensureBuildHeadroom(config, run, async () => ({ freeBytes: free, totalBytes: 251 * GiB, reservedBytes: 0 }));
24
+ expect(calls).toEqual([]);
25
+ // Tangent's build left 13 GiB before native scratch crossed the
26
+ // kubelet eviction threshold. Containerd reclamation can take passes.
27
+ free = 13 * GiB;
28
+ await ensureBuildHeadroom(config, run, async () => ({ freeBytes: free, totalBytes: 251 * GiB, reservedBytes: 0 }));
29
+ expect(free).toBe(33 * GiB);
30
+ expect(calls).toEqual(Array.from({ length: 2 }, () => ["buildctl", "--addr", "tcp://127.0.0.1:1234",
31
+ "prune", "--all", "--free-storage", String(Math.ceil(32 * GiB / 1e6))]));
32
+ });
33
+
34
+ test("unreclaimable build storage fails before project setup without deleting runtime state", async () => {
35
+ const commands: string[] = [];
36
+ await expect(ensureBuildHeadroom(config, async (command) => {
37
+ commands.push(command);
38
+ return "";
39
+ }, async () => ({ freeBytes: 13 * GiB, totalBytes: 251 * GiB, reservedBytes: 0 }))).rejects.toThrow("13312 MiB available, 32768 MiB required");
40
+ expect(commands).toEqual(["buildctl", "buildctl", "buildctl"]);
41
+ });
42
+
43
+ test("build headroom checks propagate failed GC and disk observations", async () => {
44
+ await expect(ensureBuildHeadroom(config, async () => { throw new Error("GC unavailable"); },
45
+ async () => ({ freeBytes: 13 * GiB, totalBytes: 251 * GiB, reservedBytes: 0 }))).rejects.toThrow("GC unavailable");
46
+ await expect(ensureBuildHeadroom(config, async () => { throw new Error("must not run"); },
47
+ async () => { throw new Error("filesystem unavailable"); })).rejects.toThrow("filesystem unavailable");
48
+ });
49
+
15
50
  test("sticky consumer validates disk capacity and required free space", () => {
16
51
  expect(() => validateStickyConfig(config)).not.toThrow();
17
52
  // Retention has no per-consumer size caps.
@@ -160,3 +195,30 @@ test("GC does not remove an image while it supplies the Kubernetes maintenance d
160
195
  });
161
196
  expect(removed).toEqual(["old"]);
162
197
  });
198
+
199
+
200
+ test("BuildKit thresholds include ext4 reserved blocks for build and publication GC", async () => {
201
+ // The failed Tangent build had 23 GiB available but more than 32 GiB
202
+ // physically free, so an unadjusted BuildKit request reclaimed nothing.
203
+ const reservedBytes = 13 * GiB;
204
+ const worker = (Bun.TOML.parse(stickyBuildkitConfig(config, reservedBytes)) as any).worker.containerd;
205
+ expect(worker.gcpolicy[0].minFreeSpace).toBe(45 * GiB);
206
+ for (const build of [true, false]) {
207
+ let freeBytes = 23 * GiB;
208
+ const requests: number[] = [];
209
+ const run = async (_command: string, args: string[]) => {
210
+ const index = args.indexOf("--free-storage");
211
+ if (index >= 0) {
212
+ requests.push(Number(args[index + 1]) * 1e6);
213
+ freeBytes = 33 * GiB;
214
+ }
215
+ return "";
216
+ };
217
+ const usage = async () => ({ freeBytes, totalBytes: 251 * GiB, reservedBytes });
218
+ if (build) await ensureBuildHeadroom(config, run, usage);
219
+ else await collectStickyCache(config, retention(), run, usage);
220
+ expect(requests).toHaveLength(1);
221
+ expect(requests[0]).toBeGreaterThanOrEqual(45 * GiB);
222
+ expect(requests[0]).toBeLessThan(45 * GiB + 1e6);
223
+ }
224
+ });
@@ -50,7 +50,7 @@ export function validateStickyConfig(config: StickyConsumerConfig): void {
50
50
  * only minFreeSpace, BuildKit 0.32.2 sets keepBytes == totalSize when healthy,
51
51
  * and pruneOnce's strict '<' check can still evict a record on every GC pass.
52
52
  * Actual filesystem capacity is smaller than the device due to metadata. */
53
- export function stickyBuildkitConfig(config: StickyConsumerConfig): string {
53
+ export function stickyBuildkitConfig(config: StickyConsumerConfig, reservedBytes = 0): string {
54
54
  validateStickyConfig(config);
55
55
  return `root = "${config.path}/buildkit"
56
56
  # This standalone BuildKit does not inherit dockerd's registry mirrors.
@@ -70,7 +70,7 @@ export function stickyBuildkitConfig(config: StickyConsumerConfig): string {
70
70
  [[worker.containerd.gcpolicy]]
71
71
  all = true
72
72
  maxUsedSpace = ${config.capacityBytes}
73
- minFreeSpace = ${config.headroomBytes}
73
+ minFreeSpace = ${config.headroomBytes + reservedBytes}
74
74
  `;
75
75
  }
76
76
 
@@ -105,7 +105,7 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
105
105
  dockerConfig.features = { "containerd-snapshotter": true };
106
106
  dockerConfig["storage-driver"] = "overlayfs";
107
107
  await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
108
- await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config));
108
+ await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
109
109
  const start = async (unit: string, program: string, args: string[]) => {
110
110
  await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
111
111
  // Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
@@ -125,6 +125,48 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
125
125
  await exec("docker", ["buildx", "inspect", "--bootstrap", "spectest-local"]);
126
126
  }
127
127
 
128
+ async function diskUsage(path: string) {
129
+ const fs = await statfs(path);
130
+ return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize,
131
+ reservedBytes: Math.max(0, fs.bfree - fs.bavail) * fs.bsize };
132
+ }
133
+
134
+ // BuildKit 0.32.2 compares MinFreeSpace against statfs.bfree, whereas
135
+ // applications and kubelet need bavail. Include ext4's reserved blocks so
136
+ // pruning restores the required usable space instead of stopping early.
137
+ function pruneTarget(config: StickyConsumerConfig, usage: { reservedBytes?: number }): string {
138
+ return String(Math.ceil((config.headroomBytes + (usage.reservedBytes ?? 0)) / 1e6));
139
+ }
140
+
141
+ /** Builds can finish before automatic GC restores its free-space reserve.
142
+ * Reclaim only unused BuildKit records before project setup adds runtime data.
143
+ * Docker images, containers, volumes and nested Kubernetes stores stay live.
144
+ */
145
+ export async function ensureBuildHeadroom(
146
+ config: StickyConsumerConfig,
147
+ run: typeof exec = exec,
148
+ usage = () => diskUsage(config.path),
149
+ ): Promise<void> {
150
+ validateStickyConfig(config);
151
+ let fs = await usage();
152
+ for (let pass = 0; fs.freeBytes < config.headroomBytes && pass < 3; pass++) {
153
+ await run("buildctl", ["--addr", "tcp://127.0.0.1:1234", "prune", "--all", "--free-storage",
154
+ pruneTarget(config, fs)], 120_000);
155
+ fs = await usage();
156
+ }
157
+ if (fs.freeBytes < config.headroomBytes) {
158
+ throw new Error(`environment build left insufficient disk headroom: ${Math.floor(fs.freeBytes / 2 ** 20)} MiB available, ${Math.ceil(config.headroomBytes / 2 ** 20)} MiB required`);
159
+ }
160
+ }
161
+
162
+ export async function ensureStickyBuildHeadroom(): Promise<void> {
163
+ const saved = await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8").catch((error: NodeJS.ErrnoException) => {
164
+ if (error.code === "ENOENT") return undefined;
165
+ throw error;
166
+ });
167
+ if (saved !== undefined) await ensureBuildHeadroom(JSON.parse(saved));
168
+ }
169
+
128
170
  /** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
129
171
  * Only called after runtime containers/volumes have been removed on the
130
172
  * isolated publication clone. Never removes files behind the daemons' backs.
@@ -133,10 +175,7 @@ export async function collectStickyCache(
133
175
  config: StickyConsumerConfig,
134
176
  retention: StickyImageRetention,
135
177
  run: typeof exec = exec,
136
- usage = async (): Promise<{ freeBytes: number; totalBytes: number }> => {
137
- const fs = await statfs(config.path);
138
- return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
139
- },
178
+ usage: () => Promise<{ freeBytes: number; totalBytes: number; reservedBytes?: number }> = () => diskUsage(config.path),
140
179
  kubernetes?: KubernetesCacheMaintenance,
141
180
  ): Promise<void> {
142
181
  validateStickyConfig(config);
@@ -164,7 +203,7 @@ export async function collectStickyCache(
164
203
  // a per-consumer cap evicts useful cache even when the disk is mostly empty.
165
204
  const pruneBuildkit = async () => {
166
205
  await run("buildctl", [...buildctl, "--all", "--free-storage",
167
- String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
206
+ pruneTarget(config, fs)], 120_000);
168
207
  fs = await usage();
169
208
  };
170
209
  await pruneBuildkit();