@specific.dev/spectest 0.131.5 → 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.
@@ -19,13 +19,17 @@ 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
24
  /** Builds can finish before automatic GC restores its free-space reserve.
25
25
  * Reclaim only unused BuildKit records before project setup adds runtime data.
26
26
  * Docker images, containers, volumes and nested Kubernetes stores stay live.
27
27
  */
28
- export declare function ensureBuildHeadroom(config: StickyConsumerConfig, run?: typeof exec, available?: () => Promise<number>): Promise<void>;
28
+ export declare function ensureBuildHeadroom(config: StickyConsumerConfig, run?: typeof exec, usage?: () => Promise<{
29
+ freeBytes: number;
30
+ totalBytes: number;
31
+ reservedBytes: number;
32
+ }>): Promise<void>;
29
33
  export declare function ensureStickyBuildHeadroom(): Promise<void>;
30
34
  /** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
31
35
  * Only called after runtime containers/volumes have been removed on the
@@ -34,6 +38,7 @@ export declare function ensureStickyBuildHeadroom(): Promise<void>;
34
38
  export declare function collectStickyCache(config: StickyConsumerConfig, retention: StickyImageRetention, run?: typeof exec, usage?: () => Promise<{
35
39
  freeBytes: number;
36
40
  totalBytes: number;
41
+ reservedBytes?: number;
37
42
  }>, kubernetes?: KubernetesCacheMaintenance): Promise<void>;
38
43
  /** Only on an isolated maintenance clone; never on the live ready environment. */
39
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,23 +121,31 @@ 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
+ }
124
135
  /** Builds can finish before automatic GC restores its free-space reserve.
125
136
  * Reclaim only unused BuildKit records before project setup adds runtime data.
126
137
  * Docker images, containers, volumes and nested Kubernetes stores stay live.
127
138
  */
128
- export async function ensureBuildHeadroom(config, run = exec, available = async () => {
129
- const fs = await statfs(config.path);
130
- return fs.bavail * fs.bsize;
131
- }) {
139
+ export async function ensureBuildHeadroom(config, run = exec, usage = () => diskUsage(config.path)) {
132
140
  validateStickyConfig(config);
133
- let free = await available();
134
- for (let pass = 0; free < config.headroomBytes && pass < 3; pass++) {
141
+ let fs = await usage();
142
+ for (let pass = 0; fs.freeBytes < config.headroomBytes && pass < 3; pass++) {
135
143
  await run("buildctl", ["--addr", "tcp://127.0.0.1:1234", "prune", "--all", "--free-storage",
136
- String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
137
- free = await available();
144
+ pruneTarget(config, fs)], 120_000);
145
+ fs = await usage();
138
146
  }
139
- if (free < config.headroomBytes) {
140
- throw new Error(`environment build left insufficient disk headroom: ${Math.floor(free / 2 ** 20)} MiB available, ${Math.ceil(config.headroomBytes / 2 ** 20)} MiB required`);
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`);
141
149
  }
142
150
  }
143
151
  export async function ensureStickyBuildHeadroom() {
@@ -153,10 +161,7 @@ export async function ensureStickyBuildHeadroom() {
153
161
  * Only called after runtime containers/volumes have been removed on the
154
162
  * isolated publication clone. Never removes files behind the daemons' backs.
155
163
  */
156
- export async function collectStickyCache(config, retention, run = exec, usage = async () => {
157
- const fs = await statfs(config.path);
158
- return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
159
- }, kubernetes) {
164
+ export async function collectStickyCache(config, retention, run = exec, usage = () => diskUsage(config.path), kubernetes) {
160
165
  validateStickyConfig(config);
161
166
  const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
162
167
  // Docker 29's containerd until filter uses local image-record creation;
@@ -184,7 +189,7 @@ export async function collectStickyCache(config, retention, run = exec, usage =
184
189
  // a per-consumer cap evicts useful cache even when the disk is mostly empty.
185
190
  const pruneBuildkit = async () => {
186
191
  await run("buildctl", [...buildctl, "--all", "--free-storage",
187
- String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
192
+ pruneTarget(config, fs)], 120_000);
188
193
  fs = await usage();
189
194
  };
190
195
  await pruneBuildkit();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.5",
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",
@@ -20,12 +20,12 @@ test("completed builds preserve healthy caches and restore only missing runtime
20
20
  free += 10 * GiB;
21
21
  return "";
22
22
  };
23
- await ensureBuildHeadroom(config, run, async () => free);
23
+ await ensureBuildHeadroom(config, run, async () => ({ freeBytes: free, totalBytes: 251 * GiB, reservedBytes: 0 }));
24
24
  expect(calls).toEqual([]);
25
25
  // Tangent's build left 13 GiB before native scratch crossed the
26
26
  // kubelet eviction threshold. Containerd reclamation can take passes.
27
27
  free = 13 * GiB;
28
- await ensureBuildHeadroom(config, run, async () => free);
28
+ await ensureBuildHeadroom(config, run, async () => ({ freeBytes: free, totalBytes: 251 * GiB, reservedBytes: 0 }));
29
29
  expect(free).toBe(33 * GiB);
30
30
  expect(calls).toEqual(Array.from({ length: 2 }, () => ["buildctl", "--addr", "tcp://127.0.0.1:1234",
31
31
  "prune", "--all", "--free-storage", String(Math.ceil(32 * GiB / 1e6))]));
@@ -36,13 +36,13 @@ test("unreclaimable build storage fails before project setup without deleting ru
36
36
  await expect(ensureBuildHeadroom(config, async (command) => {
37
37
  commands.push(command);
38
38
  return "";
39
- }, async () => 13 * GiB)).rejects.toThrow("13312 MiB available, 32768 MiB required");
39
+ }, async () => ({ freeBytes: 13 * GiB, totalBytes: 251 * GiB, reservedBytes: 0 }))).rejects.toThrow("13312 MiB available, 32768 MiB required");
40
40
  expect(commands).toEqual(["buildctl", "buildctl", "buildctl"]);
41
41
  });
42
42
 
43
43
  test("build headroom checks propagate failed GC and disk observations", async () => {
44
44
  await expect(ensureBuildHeadroom(config, async () => { throw new Error("GC unavailable"); },
45
- async () => 13 * GiB)).rejects.toThrow("GC unavailable");
45
+ async () => ({ freeBytes: 13 * GiB, totalBytes: 251 * GiB, reservedBytes: 0 }))).rejects.toThrow("GC unavailable");
46
46
  await expect(ensureBuildHeadroom(config, async () => { throw new Error("must not run"); },
47
47
  async () => { throw new Error("filesystem unavailable"); })).rejects.toThrow("filesystem unavailable");
48
48
  });
@@ -195,3 +195,30 @@ test("GC does not remove an image while it supplies the Kubernetes maintenance d
195
195
  });
196
196
  expect(removed).toEqual(["old"]);
197
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,19 @@ 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
+
128
141
  /** Builds can finish before automatic GC restores its free-space reserve.
129
142
  * Reclaim only unused BuildKit records before project setup adds runtime data.
130
143
  * Docker images, containers, volumes and nested Kubernetes stores stay live.
@@ -132,20 +145,17 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
132
145
  export async function ensureBuildHeadroom(
133
146
  config: StickyConsumerConfig,
134
147
  run: typeof exec = exec,
135
- available = async () => {
136
- const fs = await statfs(config.path);
137
- return fs.bavail * fs.bsize;
138
- },
148
+ usage = () => diskUsage(config.path),
139
149
  ): Promise<void> {
140
150
  validateStickyConfig(config);
141
- let free = await available();
142
- for (let pass = 0; free < config.headroomBytes && pass < 3; pass++) {
151
+ let fs = await usage();
152
+ for (let pass = 0; fs.freeBytes < config.headroomBytes && pass < 3; pass++) {
143
153
  await run("buildctl", ["--addr", "tcp://127.0.0.1:1234", "prune", "--all", "--free-storage",
144
- String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
145
- free = await available();
154
+ pruneTarget(config, fs)], 120_000);
155
+ fs = await usage();
146
156
  }
147
- if (free < config.headroomBytes) {
148
- throw new Error(`environment build left insufficient disk headroom: ${Math.floor(free / 2 ** 20)} MiB available, ${Math.ceil(config.headroomBytes / 2 ** 20)} MiB required`);
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`);
149
159
  }
150
160
  }
151
161
 
@@ -165,10 +175,7 @@ export async function collectStickyCache(
165
175
  config: StickyConsumerConfig,
166
176
  retention: StickyImageRetention,
167
177
  run: typeof exec = exec,
168
- usage = async (): Promise<{ freeBytes: number; totalBytes: number }> => {
169
- const fs = await statfs(config.path);
170
- return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
171
- },
178
+ usage: () => Promise<{ freeBytes: number; totalBytes: number; reservedBytes?: number }> = () => diskUsage(config.path),
172
179
  kubernetes?: KubernetesCacheMaintenance,
173
180
  ): Promise<void> {
174
181
  validateStickyConfig(config);
@@ -196,7 +203,7 @@ export async function collectStickyCache(
196
203
  // a per-consumer cap evicts useful cache even when the disk is mostly empty.
197
204
  const pruneBuildkit = async () => {
198
205
  await run("buildctl", [...buildctl, "--all", "--free-storage",
199
- String(Math.ceil(config.headroomBytes / 1e6))], 120_000);
206
+ pruneTarget(config, fs)], 120_000);
200
207
  fs = await usage();
201
208
  };
202
209
  await pruneBuildkit();