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
10 changes: 10 additions & 0 deletions .changeset/cache-options-for-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@cartesi/cli": minor
---

add --cache-from and --cache-to options to cartesi build

Expose Docker Buildx cache backend options via `--cache-from <spec>` and
`--cache-to <spec>` CLI flags (repeatable). The same options can also be
set per-drive in `cartesi.toml` via `cache_from` and `cache_to` arrays.
CLI flags override the TOML values when provided.
14 changes: 13 additions & 1 deletion apps/cli/src/builder/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import type { BuildxMetadata } from "../types/docker.js";

type ImageBuildOptions = Pick<
DockerDriveConfig,
"buildArgs" | "context" | "dockerfile" | "tags" | "target"
| "buildArgs"
| "cacheFrom"
| "cacheTo"
| "context"
| "dockerfile"
| "tags"
| "target"
> & { destination: string; dockerfileContent?: string; reporter?: Reporter };

type ImageInfo = {
Expand All @@ -25,6 +31,8 @@ type ImageInfo = {
const buildImage = async (options: ImageBuildOptions): Promise<string> => {
const {
buildArgs,
cacheFrom,
cacheTo,
context,
destination,
dockerfile,
Expand Down Expand Up @@ -62,6 +70,10 @@ const buildImage = async (options: ImageBuildOptions): Promise<string> => {
const metadataFile = path.join(tmpDir, "metadata.json");
args.push("--metadata-file", metadataFile);

// set cache options
args.push(...cacheFrom.flatMap((spec) => ["--cache-from", spec]));
args.push(...cacheTo.flatMap((spec) => ["--cache-to", spec]));

if (target) {
args.push("--target", target);
}
Expand Down
20 changes: 19 additions & 1 deletion apps/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { describeWithdrawalConfig, resolveWithdrawal } from "../withdrawal.js";

// context for Listr build tasks
interface BuildContext {
cacheFrom: string[];
cacheTo: string[];
config: Config;
debug: boolean;
destination: string;
Expand Down Expand Up @@ -49,6 +51,8 @@ const buildDriveTask = (
break;
}
case "docker": {
if (ctx.cacheFrom.length > 0) drive.cacheFrom = ctx.cacheFrom;
if (ctx.cacheTo.length > 0) drive.cacheTo = ctx.cacheTo;
const imageInfo = await buildDocker(
name,
drive,
Expand Down Expand Up @@ -110,10 +114,22 @@ export const createBuildCommand = () => {
.default(false)
.hideHelp(),
)
.option(
"--cache-from <spec>",
"cache source for docker buildx build (can be repeated)",
(value: string, prev: string[]) => prev.concat([value]),
[] as string[],
)
.option(
"--cache-to <spec>",
"cache destination for docker buildx build (can be repeated)",
(value: string, prev: string[]) => prev.concat([value]),
[] as string[],
)
.option("-d, --drives-only", "only build drives, do not boot machine")
.option("-v, --verbose", "verbose output", false)
.action(async (options) => {
const { debug, drivesOnly, verbose } = options;
const { cacheFrom, cacheTo, debug, drivesOnly, verbose } = options;

// get application configuration from 'cartesi.toml'
const config = getApplicationConfig(options.config);
Expand All @@ -126,6 +142,8 @@ export const createBuildCommand = () => {

// build context
const ctx = {
cacheFrom,
cacheTo,
config,
debug,
destination,
Expand Down
8 changes: 8 additions & 0 deletions apps/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ export type DirectoryDriveConfig = {
export type DockerDriveConfig = {
builder: "docker";
buildArgs: string[]; // default is empty array
cacheFrom: string[]; // default is empty array
cacheTo: string[]; // default is empty array
context: string;
dockerfile: string;
extraSize: number; // default is 0 (no extra size)
Expand Down Expand Up @@ -424,6 +426,8 @@ type TomlTable = { [key: string]: TomlPrimitive };
export const defaultRootDriveConfig = (): DriveConfig => ({
builder: "docker",
buildArgs: [],
cacheFrom: [],
cacheTo: [],
context: ".",
dockerfile: "Dockerfile", // file on current working directory
extraSize: 0,
Expand Down Expand Up @@ -824,6 +828,8 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
case "docker": {
const {
build_args,
cache_from,
cache_to,
context,
dockerfile,
extra_size,
Expand All @@ -838,6 +844,8 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
return {
builder: "docker",
buildArgs: parseStringArray(build_args),
cacheFrom: parseStringArray(cache_from),
cacheTo: parseStringArray(cache_to),
image: parseOptionalString(image),
context: parseString(context, "."),
dockerfile: parseString(dockerfile, "Dockerfile"),
Expand Down
169 changes: 169 additions & 0 deletions apps/cli/tests/integration/builder/docker.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
} from "bun:test";
import { execa } from "execa";
import fs from "fs-extra";
import crypto from "node:crypto";
import path from "node:path";
import { build } from "../../../src/builder/docker.js";
import type { DockerDriveConfig } from "../../../src/config.js";
Expand Down Expand Up @@ -36,6 +39,8 @@ describe("when building with the docker builder", () => {
const drive: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [],
context: ".",
dockerfile: "Dockerfile",
extraSize: 0,
Expand All @@ -53,6 +58,8 @@ describe("when building with the docker builder", () => {
const drive: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [],
context: path.join(__dirname, "data"),
dockerfile: "Dockerfile",
extraSize: 0,
Expand All @@ -70,6 +77,8 @@ describe("when building with the docker builder", () => {
const drive: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [],
context: path.join(__dirname, "fixtures"),
dockerfile: path.join(__dirname, "fixtures", "Dockerfile"),
extraSize: 0,
Expand All @@ -88,6 +97,8 @@ describe("when building with the docker builder", () => {
const drive: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [],
context: path.join(__dirname, "fixtures"),
dockerfile: path.join(__dirname, "fixtures", "Dockerfile"),
extraSize: 0,
Expand All @@ -106,6 +117,8 @@ describe("when building with the docker builder", () => {
const drive: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [],
context: path.join(__dirname, "fixtures"),
dockerfile: path.join(__dirname, "fixtures", "Dockerfile"),
extraSize: 0,
Expand All @@ -120,3 +133,159 @@ describe("when building with the docker builder", () => {
expect(stat.size).toEqual(29327360);
});
});

describe("when using cache options", () => {
const image = TEST_SDK;
const CACHE_TEST_TAG = "cartesi-cli-integration-test-cache:latest";
const CACHE_TEST_BUILDER = `cartesi-cli-test-${process.pid}`;
let previousBuilder: string | undefined;
let destination: string;

// Use a disposable, test-owned builder so pruning its cache never touches
// the developer's own builders. BUILDX_BUILDER is process-scoped, so the
// builder selected by `docker buildx use` is left unchanged.
beforeAll(
async () => {
await execa("docker", [
"buildx",
"create",
"--name",
CACHE_TEST_BUILDER,
"--driver",
"docker-container",
]);
previousBuilder = process.env.BUILDX_BUILDER;
process.env.BUILDX_BUILDER = CACHE_TEST_BUILDER;
},
{ timeout: 60000 },
);

afterAll(async () => {
if (previousBuilder === undefined) {
delete process.env.BUILDX_BUILDER;
} else {
process.env.BUILDX_BUILDER = previousBuilder;
}
await execa("docker", ["buildx", "rm", "--force", CACHE_TEST_BUILDER], {
reject: false,
});
});

beforeEach(async () => {
destination = await createTempDir();
});

afterEach(async () => {
await cleanupTempDir(destination);
});

it(
"should populate local cache when --cache-to is specified",
async () => {
const cacheDir = path.join(destination, "cache");
const drive: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [`type=local,dest=${cacheDir}`],
context: path.join(__dirname, "fixtures"),
dockerfile: path.join(
__dirname,
"fixtures",
"Dockerfile.cache",
),
extraSize: 0,
format: "ext2",
image: undefined,
tags: [CACHE_TEST_TAG],
target: undefined,
};

await build("root", drive, image, destination, false);

// Clean up the tagged image
await execa("docker", ["image", "rm", CACHE_TEST_TAG], {
reject: false,
});

// Cache directory must have been written
expect(fs.existsSync(path.join(cacheDir, "index.json"))).toBe(true);
},
{ timeout: 120000 },
);

it(
"should recover layers from local cache when --cache-from is specified",
async () => {
const cacheDir = path.join(destination, "cache");

// ── Build 1: populate local cache ────────────────────────────────
const driveWithCacheTo: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [`type=local,dest=${cacheDir}`],
context: path.join(__dirname, "fixtures"),
dockerfile: path.join(
__dirname,
"fixtures",
"Dockerfile.cache",
),
extraSize: 0,
format: "ext2",
image: undefined,
tags: [CACHE_TEST_TAG],
target: undefined,
};
await build("root", driveWithCacheTo, image, destination, false);

const hash1 = crypto
.createHash("sha256")
.update(fs.readFileSync(path.join(destination, "root.ext2")))
.digest("hex");

// ── Teardown: remove image from daemon and build cache ────────────
// Remove tagged image so BuildKit cannot reuse its layers implicitly
await execa("docker", ["image", "rm", CACHE_TEST_TAG], {
reject: false,
});
// Remove all build-cache records (including non-dangling ones) from the
// test-owned builder selected through BUILDX_BUILDER
await execa("docker", ["buildx", "prune", "--all", "--force"]);
Comment thread
endersonmaia marked this conversation as resolved.

// Remove build artefacts so the second build starts clean
await fs.remove(path.join(destination, "root.ext2"));

// ── Build 2: rebuild using only the local cache ──────────────────
const driveWithCacheFrom: DockerDriveConfig = {
buildArgs: [],
builder: "docker",
cacheFrom: [`type=local,src=${cacheDir}`],
cacheTo: [],
context: path.join(__dirname, "fixtures"),
dockerfile: path.join(
__dirname,
"fixtures",
"Dockerfile.cache",
),
extraSize: 0,
format: "ext2",
image: undefined,
tags: [],
target: undefined,
};
await build("root", driveWithCacheFrom, image, destination, false);

const hash2 = crypto
.createHash("sha256")
.update(fs.readFileSync(path.join(destination, "root.ext2")))
.digest("hex");

// ── Attest: cache was recovered ──────────────────────────────────
// Identical hashes mean the RUN date layer was reused (not re-executed).
// A fresh build would produce a different timestamp → different content → different hash.
expect(hash2).toEqual(hash1);
},
{ timeout: 180000 },
);
});
3 changes: 3 additions & 0 deletions apps/cli/tests/integration/builder/fixtures/Dockerfile.cache
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM --platform=linux/riscv64 ubuntu:24.04@sha256:3f83fb03282ef4e453bdf0060e0d83833bb3cf6e6f36f54d9b8517d311d78e03
# Non-deterministic RUN: output changes each build unless the layer is reused from cache
RUN date +%s%N > /build-id.txt
15 changes: 15 additions & 0 deletions apps/cli/tests/unit/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ shared = true`,
root: {
buildArgs: [],
builder: "docker",
cacheFrom: [],
cacheTo: [],
dockerfile: "backend/Dockerfile",
context: ".",
extraSize: 0,
Expand All @@ -112,6 +114,19 @@ shared = true`,
});
});

it("should parse cache_from and cache_to for docker drive", () => {
const config = parse([
`[drives.root]
builder = "docker"
cache_from = ["type=gha"]
cache_to = ["type=gha,mode=max"]`,
]);
expect(config.drives.root).toMatchObject({
cacheFrom: ["type=gha"],
cacheTo: ["type=gha,mode=max"],
});
});

/**
* [machine]
*/
Expand Down
Loading