Skip to content
Open
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
33 changes: 11 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,16 @@ This action follows semantic versioning and supports multiple referencing patter

## Inputs

| Name | Description | Required | Default |
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | -------------- |
| `buildx-version` | Buildx version (e.g., v0.23.0, latest) | No | `v0.23.0` |
| `buildkit-version` | BuildKit version to install (e.g., v0.16.0, v0.18.0) | No | System default |
| `platforms` | List of target platforms for build (e.g., linux/amd64,linux/arm64) | No | |
| `nofallback` | If true, fail the action if Blacksmith builder setup fails | No | `false` |
| `github-token` | GitHub token for GitHub API access | No | |
| `skip-integrity-check` | Deprecated: the bbolt database integrity check has been removed; this input has no effect | No | `false` |
| `driver-opts` | List of additional driver-specific options (e.g., env.VARIABLE=value) | No | |
| `max-parallelism` | Maximum number of concurrent BuildKit RUN steps. Defaults to the number of vCPUs on the runner | No | |
| `max-cache-size-mb` | Amount of build cache to retain after pruning, in MB (e.g., 409600 for 400GB). If not set, pruning is skipped | No | |
| Name | Description | Required | Default |
| ---------------------- | ---------------------------------------------------------------------------------------------- | -------- | -------------- |
| `buildx-version` | Buildx version (e.g., v0.23.0, latest) | No | `v0.23.0` |
| `buildkit-version` | BuildKit version to install (e.g., v0.16.0, v0.18.0) | No | System default |
| `platforms` | List of target platforms for build (e.g., linux/amd64,linux/arm64) | No | |
| `nofallback` | If true, fail the action if Blacksmith builder setup fails | No | `false` |
| `github-token` | GitHub token for GitHub API access | No | |
| `skip-integrity-check` | Deprecated: the bbolt database integrity check has been removed; this input has no effect | No | `false` |
| `driver-opts` | List of additional driver-specific options (e.g., env.VARIABLE=value) | No | |
| `max-parallelism` | Maximum number of concurrent BuildKit RUN steps. Defaults to the number of vCPUs on the runner | No | |

## Example Workflows

Expand Down Expand Up @@ -90,17 +89,7 @@ This action follows semantic versioning and supports multiple referencing patter

### Cache management

Use `max-cache-size-mb` to automatically prune the BuildKit cache after each build, retaining the specified amount in MB. This prevents the cache from growing unbounded while keeping the most recent layers available. The layers will be trimmed based off of the last accessed timestamp stored in the cache manager.

```yaml
- uses: useblacksmith/setup-docker-builder@v1
with:
max-cache-size-mb: "409600" # retain up to 400 GB of build cache
- uses: useblacksmith/build-push-action@v2
with:
push: true
tags: user/app:latest
```
buildkitd runs with garbage collection enabled and reclaims cache entries that have not been used for 8 days. There is no need to run `docker buildx prune` or `buildctl prune` inside the job; if you do, `prune --all --keep-storage` only removes layers that are no longer referenced, in least-recently-used order.

### Custom Docker commands

Expand Down
2 changes: 1 addition & 1 deletion 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.

15 changes: 14 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,16 +392,29 @@ async function maybeShutdownBuildkitd(): Promise<void> {

core.info(`buildkitd process: ${pid}`);

// Diagnostic only: a failing `buildctl du` must not affect the commit.
await logBuildCacheContents();

const buildkitdShutdownStartTime = Date.now();
await shutdownBuildkitd();
const wasRunning = await shutdownBuildkitd();
const buildkitdShutdownDurationMs = Date.now() - buildkitdShutdownStartTime;
await reporter.reportMetric(
Metric_MetricType.BPA_BUILDKITD_SHUTDOWN_DURATION_MS,
buildkitdShutdownDurationMs,
);

if (!wasRunning) {
// buildkitd exited between the pgrep above and SIGTERM (e.g. it crashed
// while serving `buildctl du`). The build already finished and the disk
// is still mounted and consistent, so cleanup continues and the sticky
// disk is committed as usual.
core.warning(
"buildkitd exited unexpectedly before shutdown; continuing with cleanup",
);
await logBuildkitdLogTail();
return;
}

if (stateHelper.getSigkillUsed()) {
core.warning(
"buildkitd was terminated with SIGKILL after graceful shutdown failed",
Expand Down
116 changes: 18 additions & 98 deletions src/setup-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,24 @@ describe("setup_builder", () => {
});
});

describe("writeBuildkitdTomlFile", () => {
it("disables in-use pruning and enables GC in the oci worker config", async () => {
const writeFile = vi.mocked(fs.promises.writeFile);

await setupBuilder.writeBuildkitdTomlFile(4, "tcp://127.0.0.1:1234", [
"10.0.0.1",
]);

expect(writeFile.mock.calls[0][0]).toBe("buildkitd.toml");
const config = writeFile.mock.calls[0][1] as string;
expect(config).toContain("[worker.oci]");
expect(config).toContain("pruneInUse = false");
expect(config).toContain("gc = true");
expect(config).toContain('keepDuration = "192h"');
expect(config).toContain("max-parallelism = 4");
});
});

describe("logBuildCacheContents", () => {
it("should log build cache contents from buildctl du", async () => {
const exec = (await import("child_process")).exec as unknown as {
Expand Down Expand Up @@ -195,102 +213,4 @@ describe("setup_builder", () => {
);
});
});

describe("pruneBuildkitCache", () => {
it("should prune buildkit cache successfully and log reclaimed entries", async () => {
const exec = (await import("child_process")).exec as unknown as {
mockImplementation: (
fn: (cmd: string, cb: (...args: unknown[]) => void) => void,
) => void;
};
exec.mockImplementation(
(cmd: string, cb: (...args: unknown[]) => void) => {
if (cmd.includes("buildctl") && cmd.includes("prune")) {
cb(null, {
stdout:
"ID\tRECLAIMABLE\tSIZE\nabc123\ttrue\t50MB\nTotal:\t\t50MB\n",
stderr: "",
});
}
},
);

await setupBuilder.pruneBuildkitCache();
expect(core.info).toHaveBeenCalledWith(
"Build cache pruned: Total:\t\t50MB",
);
});

it("should include --keep-storage when provided", async () => {
const exec = (await import("child_process")).exec as unknown as {
mockImplementation: (
fn: (cmd: string, cb: (...args: unknown[]) => void) => void,
) => void;
};
let capturedCmd = "";
exec.mockImplementation(
(cmd: string, cb: (...args: unknown[]) => void) => {
capturedCmd = cmd;
cb(null, { stdout: "", stderr: "" });
},
);

await setupBuilder.pruneBuildkitCache(1000);
expect(capturedCmd).toContain("--keep-storage 1000");
});

it("should default to 20480 MB when no value provided", async () => {
const exec = (await import("child_process")).exec as unknown as {
mockImplementation: (
fn: (cmd: string, cb: (...args: unknown[]) => void) => void,
) => void;
};
let capturedCmd = "";
exec.mockImplementation(
(cmd: string, cb: (...args: unknown[]) => void) => {
capturedCmd = cmd;
cb(null, { stdout: "", stderr: "" });
},
);

await setupBuilder.pruneBuildkitCache();
expect(capturedCmd).toContain("--keep-storage 20480");
});

it("should log no data reclaimed when prune output is empty", async () => {
const exec = (await import("child_process")).exec as unknown as {
mockImplementation: (
fn: (cmd: string, cb: (...args: unknown[]) => void) => void,
) => void;
};
exec.mockImplementation(
(cmd: string, cb: (...args: unknown[]) => void) => {
if (cmd.includes("buildctl") && cmd.includes("prune")) {
cb(null, { stdout: "", stderr: "" });
}
},
);

await setupBuilder.pruneBuildkitCache();
expect(core.info).toHaveBeenCalledWith(
"Build cache pruned: no data reclaimed",
);
});

it("should handle prune errors", async () => {
const exec = (await import("child_process")).exec as unknown as {
mockImplementation: (
fn: (cmd: string, cb: (...args: unknown[]) => void) => void,
) => void;
};
exec.mockImplementation(
(cmd: string, cb: (...args: unknown[]) => void) => {
cb(new Error("Prune failed"), null);
},
);

await expect(setupBuilder.pruneBuildkitCache()).rejects.toThrow();
expect(core.warning).toHaveBeenCalled();
});
});
});
37 changes: 6 additions & 31 deletions src/setup_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ async function writeTomlConfig(
}
}

async function writeBuildkitdTomlFile(
export async function writeBuildkitdTomlFile(
parallelism: number,
addr: string,
dnsNameservers: string[],
Expand Down Expand Up @@ -237,6 +237,11 @@ async function writeBuildkitdTomlFile(
...(gcPolicy && gcPolicy.length > 0 ? { gcpolicy: gcPolicy } : {}),
"max-parallelism": effectiveParallelism,
snapshotter: "overlayfs",
// Keep upstream prune semantics: `buildctl prune --all --keep-storage`
// must never remove layers that are still referenced (e.g. parents of
// surviving layers), otherwise buildkitd's cache graph is left with
// dangling parents. GC handles reclamation via gcpolicy instead.
pruneInUse: false,
},
containerd: {
enabled: false,
Expand Down Expand Up @@ -575,36 +580,6 @@ export async function logBuildCacheContents(): Promise<void> {
}
}

/**
* Prunes buildkit cache data.
* @param keepStorageMB Storage to retain in cache, in MB. Defaults to 20480 (20GB).
* @throws Error if buildctl prune command fails
*/
export async function pruneBuildkitCache(
keepStorageMB: number = 20480,
): Promise<void> {
try {
const cmd = `sudo buildctl --addr ${BUILDKIT_DAEMON_ADDR} prune --all --keep-storage ${keepStorageMB}`;
const { stdout } = await execAsync(cmd);
const output = stdout.trim();
if (output) {
const lines = output.split("\n").filter((l) => l.trim());
const totalLine = lines.find((l) => l.toLowerCase().startsWith("total:"));
if (totalLine) {
core.info(`Build cache pruned: ${totalLine.trim()}`);
} else {
core.info(`Build cache pruned (${lines.length} entries reclaimed)`);
}
core.debug(`Prune output:\n${output}`);
} else {
core.info("Build cache pruned: no data reclaimed");
}
} catch (error) {
core.warning(`Error pruning buildkit cache: ${(error as Error).message}`);
throw error;
}
}

/**
* Logs MD5 hashes of specific buildkit database files
* Uses md5sum with a 5-second timeout to avoid blocking on large files
Expand Down
88 changes: 88 additions & 0 deletions src/shutdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as core from "@actions/core";
import * as stateHelper from "./state-helper";
import { shutdownBuildkitd } from "./shutdown";

vi.mock("@actions/core", () => ({
debug: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
error: vi.fn(),
}));

vi.mock("./state-helper", () => ({
setSigkillUsed: vi.fn(),
}));

vi.mock("child_process", () => ({
exec: vi.fn(),
}));

type ExecCb = (
err: Error | null,
result: { stdout: string; stderr: string },
) => void;

function execError(code: number): Error & { code: number } {
return Object.assign(new Error(`Command failed with exit code ${code}`), {
code,
});
}

async function mockExec(handler: (cmd: string) => Error | string) {
const exec = (await import("child_process")).exec as unknown as {
mockImplementation: (fn: (cmd: string, cb: ExecCb) => void) => void;
};
exec.mockImplementation((cmd: string, cb: ExecCb) => {
const result = handler(cmd);
if (result instanceof Error) {
cb(result, { stdout: "", stderr: "" });
} else {
cb(null, { stdout: result, stderr: "" });
}
});
}

describe("shutdownBuildkitd", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns true after a graceful shutdown", async () => {
await mockExec((cmd) => {
if (cmd.includes("pkill -TERM")) return "";
if (cmd.includes("pgrep")) return execError(1);
throw new Error(`unexpected command: ${cmd}`);
});

await expect(shutdownBuildkitd()).resolves.toBe(true);
expect(stateHelper.setSigkillUsed).not.toHaveBeenCalled();
expect(core.info).toHaveBeenCalledWith(
"buildkitd successfully shutdown gracefully",
);
});

it("returns false without throwing when buildkitd is already gone", async () => {
await mockExec((cmd) => {
if (cmd.includes("pkill -TERM")) return execError(1);
throw new Error(`unexpected command: ${cmd}`);
});

await expect(shutdownBuildkitd()).resolves.toBe(false);
expect(stateHelper.setSigkillUsed).not.toHaveBeenCalled();
expect(core.error).not.toHaveBeenCalled();
expect(core.warning).toHaveBeenCalledWith(
"buildkitd is not running; nothing to shut down",
);
});

it("rethrows pkill failures other than no-match", async () => {
await mockExec((cmd) => {
if (cmd.includes("pkill -TERM")) return execError(2);
throw new Error(`unexpected command: ${cmd}`);
});

await expect(shutdownBuildkitd()).rejects.toThrow();
expect(core.error).toHaveBeenCalled();
});
});
Loading
Loading