Skip to content

Commit f05974e

Browse files
endersonmaiaclaude
andcommitted
feat(cli): add --cache-from and --cache-to options to cartesi build
Expose Docker Buildx cache backend specs via two new repeatable CLI flags: cartesi build --cache-from type=local,src=/tmp/cache \ --cache-to type=local,dest=/tmp/cache The same options are also available per-drive in cartesi.toml: [drives.root] cache_from = ["type=gha"] cache_to = ["type=gha,mode=max"] CLI flags override the TOML values when non-empty. Changes: - config.ts: add cacheFrom/cacheTo fields to DockerDriveConfig and defaultRootDriveConfig; parse cache_from/cache_to from TOML - builder/docker.ts: thread cacheFrom/cacheTo through ImageBuildOptions and append --cache-from / --cache-to args to the buildx invocation - commands/build.ts: add --cache-from and --cache-to options (accumulator style); merge into BuildContext and apply over per-drive config - tests/unit/config.test.ts: add assertion for cache_from/cache_to parsing - tests/integration/builder/docker.test.ts: add two integration tests — one verifying the local cache directory is written, one verifying that layers are recovered from a local cache after the daemon build cache is pruned (attested by comparing root.ext2 SHA-256 between the two builds) - tests/integration/builder/fixtures/Dockerfile.cache: new fixture using a non-deterministic RUN date layer to make cache-hit vs miss detectable Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0546bf8 commit f05974e

9 files changed

Lines changed: 217 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@cartesi/cli": minor
3+
---
4+
5+
add --cache-from and --cache-to options to cartesi build
6+
7+
Expose Docker Buildx cache backend options via `--cache-from <spec>` and
8+
`--cache-to <spec>` CLI flags (repeatable). The same options can also be
9+
set per-drive in `cartesi.toml` via `cache_from` and `cache_to` arrays.
10+
CLI flags override the TOML values when provided.

.github/workflows/cli.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ jobs:
3535
- name: Build
3636
run: bun run build --filter @cartesi/cli
3737

38+
- name: Set up QEMU
39+
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
40+
41+
- name: Set up Docker Buildx
42+
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
43+
3844
- name: Test
3945
run: bun test apps/cli/
4046

apps/cli/src/builder/docker.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import { genext2fs, mksquashfs } from "../exec/index.js";
66

77
type ImageBuildOptions = Pick<
88
DockerDriveConfig,
9-
"buildArgs" | "context" | "dockerfile" | "tags" | "target"
9+
| "buildArgs"
10+
| "cacheFrom"
11+
| "cacheTo"
12+
| "context"
13+
| "dockerfile"
14+
| "tags"
15+
| "target"
1016
> & { destination: string; dockerfileContent?: string };
1117

1218
type ImageInfo = {
@@ -22,6 +28,8 @@ type ImageInfo = {
2228
const buildImage = async (options: ImageBuildOptions): Promise<string> => {
2329
const {
2430
buildArgs,
31+
cacheFrom,
32+
cacheTo,
2533
context,
2634
destination,
2735
dockerfile,
@@ -52,6 +60,10 @@ const buildImage = async (options: ImageBuildOptions): Promise<string> => {
5260
// set build args
5361
args.push(...buildArgs.flatMap((arg) => ["--build-arg", arg]));
5462

63+
// set cache options
64+
args.push(...cacheFrom.flatMap((spec) => ["--cache-from", spec]));
65+
args.push(...cacheTo.flatMap((spec) => ["--cache-to", spec]));
66+
5567
if (target) {
5668
args.push("--target", target);
5769
}

apps/cli/src/commands/build.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { bootMachine } from "../machine.js";
1717

1818
// context for Listr build tasks
1919
interface BuildContext {
20+
cacheFrom: string[];
21+
cacheTo: string[];
2022
config: Config;
2123
debug: boolean;
2224
destination: string;
@@ -37,6 +39,8 @@ const buildDriveTask = (
3739
break;
3840
}
3941
case "docker": {
42+
if (ctx.cacheFrom.length > 0) drive.cacheFrom = ctx.cacheFrom;
43+
if (ctx.cacheTo.length > 0) drive.cacheTo = ctx.cacheTo;
4044
const imageInfo = await buildDocker(
4145
name,
4246
drive,
@@ -86,10 +90,22 @@ export const createBuildCommand = () => {
8690
.default(false)
8791
.hideHelp(),
8892
)
93+
.option(
94+
"--cache-from <spec>",
95+
"cache source for docker buildx build (can be repeated)",
96+
(value, prev) => prev.concat([value]),
97+
[],
98+
)
99+
.option(
100+
"--cache-to <spec>",
101+
"cache destination for docker buildx build (can be repeated)",
102+
(value, prev) => prev.concat([value]),
103+
[],
104+
)
89105
.option("-d, --drives-only", "only build drives, do not boot machine")
90106
.option("-v, --verbose", "verbose output", false)
91107
.action(async (options) => {
92-
const { debug, drivesOnly, verbose } = options;
108+
const { cacheFrom, cacheTo, debug, drivesOnly, verbose } = options;
93109

94110
// clean up temp files we create along the process
95111
tmp.setGracefulCleanup();
@@ -104,7 +120,14 @@ export const createBuildCommand = () => {
104120
await fs.emptyDir(destination); // XXX: make it less error prone
105121

106122
// build context
107-
const ctx = { config, debug, destination, imageInfo: undefined };
123+
const ctx = {
124+
cacheFrom,
125+
cacheTo,
126+
config,
127+
debug,
128+
destination,
129+
imageInfo: undefined,
130+
};
108131

109132
// tasks to build drives
110133
const driveTasks = Object.entries(config.drives).map(

apps/cli/src/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export type DirectoryDriveConfig = {
9999
export type DockerDriveConfig = {
100100
builder: "docker";
101101
buildArgs: string[]; // default is empty array
102+
cacheFrom: string[]; // default is empty array
103+
cacheTo: string[]; // default is empty array
102104
context: string;
103105
dockerfile: string;
104106
extraSize: number; // default is 0 (no extra size)
@@ -163,6 +165,8 @@ type TomlTable = { [key: string]: TomlPrimitive };
163165
export const defaultRootDriveConfig = (): DriveConfig => ({
164166
builder: "docker",
165167
buildArgs: [],
168+
cacheFrom: [],
169+
cacheTo: [],
166170
context: ".",
167171
dockerfile: "Dockerfile", // file on current working directory
168172
extraSize: 0,
@@ -408,6 +412,8 @@ const parseDrive = (drive: TomlPrimitive): DriveConfig => {
408412
case "docker": {
409413
const {
410414
build_args,
415+
cache_from,
416+
cache_to,
411417
context,
412418
dockerfile,
413419
extra_size,
@@ -422,6 +428,8 @@ const parseDrive = (drive: TomlPrimitive): DriveConfig => {
422428
return {
423429
builder: "docker",
424430
buildArgs: parseStringArray(build_args),
431+
cacheFrom: parseStringArray(cache_from),
432+
cacheTo: parseStringArray(cache_to),
425433
image: parseOptionalString(image),
426434
context: parseString(context, "."),
427435
dockerfile: parseString(dockerfile, "Dockerfile"),

apps/cli/tests/integration/builder/docker.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
expect,
77
it,
88
} from "bun:test";
9+
import crypto from "node:crypto";
910
import fs from "fs-extra";
1011
import path from "node:path";
12+
import { execa } from "execa";
1113
import { build } from "../../../src/builder/docker.js";
1214
import type { DockerDriveConfig } from "../../../src/config.js";
1315
import { setupIntegrationTests, TEST_SDK } from "../config.js";
@@ -36,6 +38,8 @@ describe("when building with the docker builder", () => {
3638
const drive: DockerDriveConfig = {
3739
buildArgs: [],
3840
builder: "docker",
41+
cacheFrom: [],
42+
cacheTo: [],
3943
context: ".",
4044
dockerfile: "Dockerfile",
4145
extraSize: 0,
@@ -53,6 +57,8 @@ describe("when building with the docker builder", () => {
5357
const drive: DockerDriveConfig = {
5458
buildArgs: [],
5559
builder: "docker",
60+
cacheFrom: [],
61+
cacheTo: [],
5662
context: path.join(__dirname, "data"),
5763
dockerfile: "Dockerfile",
5864
extraSize: 0,
@@ -70,6 +76,8 @@ describe("when building with the docker builder", () => {
7076
const drive: DockerDriveConfig = {
7177
buildArgs: [],
7278
builder: "docker",
79+
cacheFrom: [],
80+
cacheTo: [],
7381
context: path.join(__dirname, "fixtures"),
7482
dockerfile: path.join(__dirname, "fixtures", "Dockerfile"),
7583
extraSize: 0,
@@ -88,6 +96,8 @@ describe("when building with the docker builder", () => {
8896
const drive: DockerDriveConfig = {
8997
buildArgs: [],
9098
builder: "docker",
99+
cacheFrom: [],
100+
cacheTo: [],
91101
context: path.join(__dirname, "fixtures"),
92102
dockerfile: path.join(__dirname, "fixtures", "Dockerfile"),
93103
extraSize: 0,
@@ -106,6 +116,8 @@ describe("when building with the docker builder", () => {
106116
const drive: DockerDriveConfig = {
107117
buildArgs: [],
108118
builder: "docker",
119+
cacheFrom: [],
120+
cacheTo: [],
109121
context: path.join(__dirname, "fixtures"),
110122
dockerfile: path.join(__dirname, "fixtures", "Dockerfile"),
111123
extraSize: 0,
@@ -120,3 +132,126 @@ describe("when building with the docker builder", () => {
120132
expect(stat.size).toEqual(29327360);
121133
});
122134
});
135+
136+
describe("when using cache options", () => {
137+
const image = TEST_SDK;
138+
const CACHE_TEST_TAG = "cartesi-cli-integration-test-cache:latest";
139+
let destination: string;
140+
141+
beforeEach(async () => {
142+
destination = await createTempDir();
143+
});
144+
145+
afterEach(async () => {
146+
await cleanupTempDir(destination);
147+
});
148+
149+
it(
150+
"should populate local cache when --cache-to is specified",
151+
async () => {
152+
const cacheDir = path.join(destination, "cache");
153+
const drive: DockerDriveConfig = {
154+
buildArgs: [],
155+
builder: "docker",
156+
cacheFrom: [],
157+
cacheTo: [`type=local,dest=${cacheDir}`],
158+
context: path.join(__dirname, "fixtures"),
159+
dockerfile: path.join(
160+
__dirname,
161+
"fixtures",
162+
"Dockerfile.cache",
163+
),
164+
extraSize: 0,
165+
format: "ext2",
166+
image: undefined,
167+
tags: [CACHE_TEST_TAG],
168+
target: undefined,
169+
};
170+
171+
await build("root", drive, image, destination, false);
172+
173+
// Clean up the tagged image
174+
await execa("docker", ["image", "rm", CACHE_TEST_TAG], {
175+
reject: false,
176+
});
177+
178+
// Cache directory must have been written
179+
expect(fs.existsSync(path.join(cacheDir, "index.json"))).toBe(true);
180+
},
181+
{ timeout: 120000 },
182+
);
183+
184+
it(
185+
"should recover layers from local cache when --cache-from is specified",
186+
async () => {
187+
const cacheDir = path.join(destination, "cache");
188+
189+
// ── Build 1: populate local cache ────────────────────────────────
190+
const driveWithCacheTo: DockerDriveConfig = {
191+
buildArgs: [],
192+
builder: "docker",
193+
cacheFrom: [],
194+
cacheTo: [`type=local,dest=${cacheDir}`],
195+
context: path.join(__dirname, "fixtures"),
196+
dockerfile: path.join(
197+
__dirname,
198+
"fixtures",
199+
"Dockerfile.cache",
200+
),
201+
extraSize: 0,
202+
format: "ext2",
203+
image: undefined,
204+
tags: [CACHE_TEST_TAG],
205+
target: undefined,
206+
};
207+
await build("root", driveWithCacheTo, image, destination, false);
208+
209+
const hash1 = crypto
210+
.createHash("sha256")
211+
.update(fs.readFileSync(path.join(destination, "root.ext2")))
212+
.digest("hex");
213+
214+
// ── Teardown: remove image from daemon and build cache ────────────
215+
// Remove tagged image so BuildKit cannot reuse its layers implicitly
216+
await execa("docker", ["image", "rm", CACHE_TEST_TAG], {
217+
reject: false,
218+
});
219+
// Remove all build-cache records from the daemon
220+
await execa("docker", ["builder", "prune", "--force"]);
221+
222+
// Remove build artefacts so the second build starts clean
223+
await fs.remove(path.join(destination, "root.ext2"));
224+
225+
// ── Build 2: rebuild using only the local cache ──────────────────
226+
const driveWithCacheFrom: DockerDriveConfig = {
227+
buildArgs: [],
228+
builder: "docker",
229+
cacheFrom: [`type=local,src=${cacheDir}`],
230+
cacheTo: [],
231+
context: path.join(__dirname, "fixtures"),
232+
dockerfile: path.join(
233+
__dirname,
234+
"fixtures",
235+
"Dockerfile.cache",
236+
),
237+
extraSize: 0,
238+
format: "ext2",
239+
image: undefined,
240+
tags: [],
241+
target: undefined,
242+
};
243+
await build("root", driveWithCacheFrom, image, destination, false);
244+
245+
const hash2 = crypto
246+
.createHash("sha256")
247+
.update(fs.readFileSync(path.join(destination, "root.ext2")))
248+
.digest("hex");
249+
250+
// ── Attest: cache was recovered ──────────────────────────────────
251+
// Identical hashes mean the RUN date layer was reused (not re-executed).
252+
// A fresh build would produce a different timestamp → different content → different hash.
253+
expect(hash2).toEqual(hash1);
254+
},
255+
{ timeout: 180000 },
256+
);
257+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
FROM --platform=linux/riscv64 ubuntu:24.04@sha256:3f83fb03282ef4e453bdf0060e0d83833bb3cf6e6f36f54d9b8517d311d78e03
2+
# Non-deterministic RUN: output changes each build unless the layer is reused from cache
3+
RUN date +%s%N > /build-id.txt

apps/cli/tests/unit/config.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ shared = true`,
7878
root: {
7979
buildArgs: [],
8080
builder: "docker",
81+
cacheFrom: [],
82+
cacheTo: [],
8183
dockerfile: "backend/Dockerfile",
8284
context: ".",
8385
extraSize: 0,
@@ -93,6 +95,19 @@ shared = true`,
9395
});
9496
});
9597

98+
it("should parse cache_from and cache_to for docker drive", () => {
99+
const config = parse([
100+
`[drives.root]
101+
builder = "docker"
102+
cache_from = ["type=gha"]
103+
cache_to = ["type=gha,mode=max"]`,
104+
]);
105+
expect(config.drives.root).toMatchObject({
106+
cacheFrom: ["type=gha"],
107+
cacheTo: ["type=gha,mode=max"],
108+
});
109+
});
110+
96111
/**
97112
* [machine]
98113
*/

packages/devnet/build.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const dependencies: ListrTask[] = [
5656
task: async () => await downloadAndExtract(file),
5757
}));
5858

59-
type ContractDeployments = Record<string, { address: string; abi: any }>;
59+
type ContractDeployments = Record<string, { address: string; abi: unknown[] }>;
6060

6161
/**
6262
* Collect contracts from deployments, objects keyed by contractName, with abi and address
@@ -100,7 +100,7 @@ const collectContracts = async (dir: string): Promise<ContractDeployments> => {
100100
contracts[contractName] = { abi, address };
101101
return contracts;
102102
},
103-
{} as Record<string, { abi: any; address: string }>,
103+
{} as Record<string, { abi: unknown[]; address: string }>,
104104
);
105105
};
106106

0 commit comments

Comments
 (0)