@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,
|
|
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,
|
|
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
|
|
134
|
-
for (let pass = 0;
|
|
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
|
-
|
|
137
|
-
|
|
144
|
+
pruneTarget(config, fs)], 120_000);
|
|
145
|
+
fs = await usage();
|
|
138
146
|
}
|
|
139
|
-
if (
|
|
140
|
-
throw new Error(`environment build left insufficient disk headroom: ${Math.floor(
|
|
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 =
|
|
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
|
-
|
|
192
|
+
pruneTarget(config, fs)], 120_000);
|
|
188
193
|
fs = await usage();
|
|
189
194
|
};
|
|
190
195
|
await pruneBuildkit();
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
142
|
-
for (let pass = 0;
|
|
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
|
-
|
|
145
|
-
|
|
154
|
+
pruneTarget(config, fs)], 120_000);
|
|
155
|
+
fs = await usage();
|
|
146
156
|
}
|
|
147
|
-
if (
|
|
148
|
-
throw new Error(`environment build left insufficient disk headroom: ${Math.floor(
|
|
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
|
|
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
|
-
|
|
206
|
+
pruneTarget(config, fs)], 120_000);
|
|
200
207
|
fs = await usage();
|
|
201
208
|
};
|
|
202
209
|
await pruneBuildkit();
|