Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
},
"dependencies": {
"@actions/core": "^1.10.1",
"@buf/blacksmith_vm-agent.bufbuild_es": "2.14.1-20260904133914-c6beb298c70c.3",
"@bufbuild/protobuf": "^2.14.1",
"@buf/blacksmith_vm-agent.bufbuild_es": "2.16.0-20261026163753-b7ed8710bf2b.1",
"@bufbuild/protobuf": "^2.16.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-node": "^2.1.1",
"@docker/actions-toolkit": "0.37.1",
Expand Down
40 changes: 20 additions & 20 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 62 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
logBuildkitdLogTail,
logDatabaseHashes,
writeDockerContainerBuildkitdTomlFile,
fstrimFailureOutcome,
} from "./setup_builder";
import {
installBuildKit,
Expand All @@ -41,6 +42,38 @@ const DEFAULT_BUILDX_VERSION = "v0.23.0";
const mountPoint = "/var/lib/buildkit";
const execAsync = promisify(exec);

// Best-effort fstrim of the sticky disk before it is unmounted and committed:
// pruned layers keep their RBD extents allocated on the backing store until
// the guest discards them. Bounded by the host-supplied budget; any failure
// (discard not negotiated, missing binary, timeout) only logs. A positive
// minExtentBytes is passed as `fstrim -m`: free extents smaller than that are
// left alone.
async function trimFilesystem(
mountPath: string,
timeoutSecs: number,
minExtentBytes: number,
): Promise<void> {
const start = Date.now();
const minimum = minExtentBytes > 0 ? ` -m ${minExtentBytes}` : "";
try {
const { stdout } = await execAsync(
`timeout ${timeoutSecs} sudo fstrim -v${minimum} -- "${mountPath}"`,
);
core.info(
`fstrim ${mountPath}: ${stdout.trim() || "done"} in ${Date.now() - start}ms`,
);
} catch (error) {
const durationMs = Date.now() - start;
const { code, stderr } = error as { code?: unknown; stderr?: string };
const outcome = fstrimFailureOutcome(code, stderr);
const detail =
outcome === "timeout"
? `timed out after ${timeoutSecs}s`
: stderr?.trim() || (error as Error).message;
core.info(`fstrim ${mountPath}: ${outcome} in ${durationMs}ms: ${detail}`);
}
}

async function getDeviceFromMount(mountPath: string): Promise<string | null> {
try {
const { stdout } = await execAsync(`findmnt -n -o SOURCE "${mountPath}"`);
Expand Down Expand Up @@ -514,6 +547,8 @@ async function startBlacksmithBuilder(
// Save state for post action
stateHelper.setExposeId(stickyDiskSetup.exposeId);
stateHelper.setCommitEarlyDenyReason(stickyDiskSetup.commitEarlyDenyReason);
stateHelper.setFstrimTimeoutSecs(stickyDiskSetup.fstrimTimeoutSecs);
stateHelper.setFstrimMinExtentBytes(stickyDiskSetup.fstrimMinExtentBytes);

return { addr: buildkitdAddr, exposeId: stickyDiskSetup.exposeId };
} catch (error) {
Expand Down Expand Up @@ -795,6 +830,9 @@ void actionsToolkit.run(
let cleanupError: Error | null = null;
let fsDiskUsageBytes: number | null = null;
let integrityCheckPassed: boolean | null = null;
let earlyFailureCheck: Awaited<
ReturnType<typeof checkPreviousStepFailures>
> | null = null;

try {
// Step 1: Shut down buildkitd if this instance started it.
Expand Down Expand Up @@ -827,6 +865,28 @@ void actionsToolkit.run(
// Log database file hashes after integrity check
await logDatabaseHashes("after integrity check");

// Discard freed blocks before unmount so the committed snapshot
// does not keep them allocated. Only when the host enabled the
// trim and this run is still on track to commit; the previous-step
// check is done once here and reused by the commit decision below.
const fstrimTimeoutSecs = stateHelper.getFstrimTimeoutSecs();
if (
fstrimTimeoutSecs > 0 &&
exposeId &&
integrityCheckPassed &&
!stateHelper.getCommitEarlyDenyReason() &&
!stateHelper.getSigkillUsed()
) {
earlyFailureCheck = await checkPreviousStepFailures();
if (!earlyFailureCheck.error && !earlyFailureCheck.hasFailures) {
await trimFilesystem(
mountPoint,
fstrimTimeoutSecs,
stateHelper.getFstrimMinExtentBytes(),
);
}
}

// Get filesystem usage BEFORE unmounting (critical timing)
try {
const { stdout } = await execAsync(
Expand Down Expand Up @@ -928,7 +988,8 @@ void actionsToolkit.run(
core.info(
"Checking for previous step failures before committing sticky disk",
);
const failureCheck = await checkPreviousStepFailures();
const failureCheck =
earlyFailureCheck ?? (await checkPreviousStepFailures());

if (failureCheck.error) {
core.warning(
Expand Down
62 changes: 62 additions & 0 deletions src/setup-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,49 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import * as core from "@actions/core";
import * as fs from "fs";
import * as setupBuilder from "./setup_builder";
import { fromBinary } from "@bufbuild/protobuf";
import { GetStickyDiskResponseSchema } from "@buf/blacksmith_vm-agent.bufbuild_es/stickydisk/v1/stickydisk_pb.js";

describe("parseFstrimTimeoutSecs", () => {
it("reads the int64 field as whole seconds, 0 when absent or unusable", () => {
// field 8, varint wire type => tag 0x40; value 5
const response = fromBinary(
GetStickyDiskResponseSchema,
new Uint8Array([0x40, 0x05]),
);
expect(
setupBuilder.parseFstrimTimeoutSecs(response.fstrimTimeoutSecs),
).toBe(5);
expect(
setupBuilder.parseFstrimTimeoutSecs(
fromBinary(GetStickyDiskResponseSchema, new Uint8Array([]))
.fstrimTimeoutSecs,
),
).toBe(0);
expect(setupBuilder.parseFstrimTimeoutSecs(BigInt(5))).toBe(5);
expect(setupBuilder.parseFstrimTimeoutSecs("2.9")).toBe(2);
expect(setupBuilder.parseFstrimTimeoutSecs(-1)).toBe(0);
expect(setupBuilder.parseFstrimTimeoutSecs(undefined)).toBe(0);
});

it("reads fstrim_min_extent_bytes (field 9) as whole bytes, 0 when absent", () => {
// field 9, varint wire type => tag 0x48; value 4194304 (4 MiB)
const response = fromBinary(
GetStickyDiskResponseSchema,
new Uint8Array([0x48, 0x80, 0x80, 0x80, 0x02]),
);
expect(
setupBuilder.parseFstrimMinExtentBytes(response.fstrimMinExtentBytes),
).toBe(4194304);
expect(
setupBuilder.parseFstrimMinExtentBytes(
fromBinary(GetStickyDiskResponseSchema, new Uint8Array([]))
.fstrimMinExtentBytes,
),
).toBe(0);
expect(setupBuilder.parseFstrimMinExtentBytes(-1)).toBe(0);
});
});
// import * as reporter from "./reporter";

// Mock the modules
Expand Down Expand Up @@ -308,3 +351,22 @@ describe("setup_builder", () => {
});
});
});

describe("fstrimFailureOutcome", () => {
it("classifies failures", () => {
expect(setupBuilder.fstrimFailureOutcome(124, "")).toBe("timeout");
expect(
setupBuilder.fstrimFailureOutcome(
1,
"fstrim: /var/lib/buildkit: the discard operation is not supported",
),
).toBe("unsupported");
expect(setupBuilder.fstrimFailureOutcome(127, "")).toBe("unsupported");
expect(
setupBuilder.fstrimFailureOutcome(
1,
"fstrim: /x: FITRIM ioctl failed: Input/output error",
),
).toBe("failed");
});
});
66 changes: 65 additions & 1 deletion src/setup_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,51 @@ export async function startBuildkitd(
}
}

/** Why the best-effort pre-unmount fstrim did not complete, as logged by the post step. */
export type FstrimFailureOutcome = "timeout" | "unsupported" | "failed";

/**
* Classifies a failed `timeout <n> sudo fstrim` run: 124 is timeout(1)'s
* exit code; discard not negotiated by the hypervisor (EOPNOTSUPP) or no
* fstrim binary are "unsupported"; anything else is "failed".
*/
export function fstrimFailureOutcome(
code: unknown,
stderr: string | undefined,
): FstrimFailureOutcome {
if (code === 124) return "timeout";
if (code === 127 || code === "ENOENT") return "unsupported";
const msg = (stderr ?? "").toLowerCase();
if (
msg.includes("not supported") ||
msg.includes("command not found") ||
msg.includes("no such file or directory")
) {
return "unsupported";
}
return "failed";
}

/**
* Parse the host's fstrim budget (GetStickyDiskResponse.fstrim_timeout_secs,
* a protobuf int64 that arrives as a bigint) into whole seconds. Anything
* unusable is 0, which disables the trim.
*/
export function parseFstrimTimeoutSecs(value: unknown): number {
const n = typeof value === "bigint" ? Number(value) : Number(value ?? 0);
if (!Number.isFinite(n) || n <= 0) return 0;
return Math.floor(n);
}

/**
* Parse the host's `fstrim --minimum` value
* (GetStickyDiskResponse.fstrim_min_extent_bytes, a bigint or saved-state
* string) into whole bytes. Anything unusable is 0: the fstrim default.
*/
export function parseFstrimMinExtentBytes(value: unknown): number {
return parseFstrimTimeoutSecs(value);
}

export async function getStickyDisk(options?: {
signal?: AbortSignal;
}): Promise<{
Expand All @@ -397,6 +442,11 @@ export async function getStickyDisk(options?: {
// Non-empty when the host already knows this job's commit will be denied
// (e.g. branch protection) and its writes to the sticky disk discarded.
commit_early_deny_reason: string;
// Host budget, in whole seconds, for trimming the filesystem before unmount
// at commit; 0 when the host has not enabled the trim.
fstrim_timeout_secs: number;
// Host `fstrim -m` value in bytes; 0 means the fstrim default.
fstrim_min_extent_bytes: number;
}> {
const client = await reporter.createBlacksmithAgentClient();
core.info(`Created Blacksmith agent client`);
Expand Down Expand Up @@ -438,6 +488,10 @@ export async function getStickyDisk(options?: {
commit_early_deny_reason: response.commitEarlyDeny
? response.commitEarlyDenyReason || "denied by host policy"
: "",
fstrim_timeout_secs: parseFstrimTimeoutSecs(response.fstrimTimeoutSecs),
fstrim_min_extent_bytes: parseFstrimMinExtentBytes(
response.fstrimMinExtentBytes,
),
};
}

Expand Down Expand Up @@ -675,6 +729,8 @@ export async function setupStickyDisk(): Promise<{
device: string;
exposeId: string;
commitEarlyDenyReason: string;
fstrimTimeoutSecs: number;
fstrimMinExtentBytes: number;
}> {
try {
const controller = new AbortController();
Expand All @@ -691,6 +747,8 @@ export async function setupStickyDisk(): Promise<{
const cloneName = stickyDiskResponse.clone_name;

const commitEarlyDenyReason = stickyDiskResponse.commit_early_deny_reason;
const fstrimTimeoutSecs = stickyDiskResponse.fstrim_timeout_secs;
const fstrimMinExtentBytes = stickyDiskResponse.fstrim_min_extent_bytes;

core.info(`Sticky disk parent snapshot: ${parentSnapshotName}`);
core.info(`Sticky disk clone name: ${cloneName}`);
Expand Down Expand Up @@ -761,7 +819,13 @@ export async function setupStickyDisk(): Promise<{
// Log database file hashes after mount
await logDatabaseHashes("after mount");

return { device, exposeId, commitEarlyDenyReason };
return {
device,
exposeId,
commitEarlyDenyReason,
fstrimTimeoutSecs,
fstrimMinExtentBytes,
};
} catch (error) {
core.warning(`Error in setupStickyDisk: ${(error as Error).message}`);
await reporter.reportBuildPushActionFailure(
Expand Down
Loading
Loading