Skip to content

Commit 5625142

Browse files
claudetuler
authored andcommitted
feat(cli): support env and env_file in cartesi.toml [machine] section
Allow injecting environment variables into the Cartesi Machine build beyond the Dockerfile ENV controlled by use_docker_env: - env: an inline table of key/value pairs in [machine.env] - env_file: a path to a .env file Precedence (lowest to highest): docker image ENV (when use_docker_env is enabled), env_file, then the env table. Also fixes a latent bug where env values containing "=" were truncated at the first "=". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vnD51QAGwAvqaZuEZJypr
1 parent 3415ff8 commit 5625142

7 files changed

Lines changed: 172 additions & 8 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@cartesi/cli": minor
3+
---
4+
5+
feat: support `env` and `env_file` in the `[machine]` section of `cartesi.toml`
6+
7+
Environment variables can now be injected into the Cartesi Machine build
8+
without relying solely on the Dockerfile `ENV`. Define an `env` table with
9+
inline key/value pairs and/or point `env_file` at a `.env` file. Precedence,
10+
from lowest to highest, is: Docker image `ENV` (when `use_docker_env` is
11+
enabled), `env_file`, then the `env` table.

apps/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"chalk": "^5.6.2",
2626
"cli-table3": "^0.6.5",
2727
"commander": "^14.0.3",
28+
"dotenv": "^16.6.1",
2829
"execa": "^9.6.0",
2930
"fs-extra": "^11.3.2",
3031
"get-port": "^7.1.0",

apps/cli/src/config.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ export class InvalidStringArrayError extends Error {
7878
}
7979
}
8080

81+
export class InvalidEnvError extends Error {
82+
constructor(value: TomlPrimitive) {
83+
super(`Invalid env configuration: ${value}`);
84+
this.name = "InvalidEnvError";
85+
}
86+
}
87+
8188
/**
8289
* Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder
8390
*/
@@ -153,6 +160,8 @@ export type MachineConfig = {
153160
assertRollingTemplate?: boolean; // default given by cartesi-machine
154161
bootargs: string[];
155162
entrypoint?: string;
163+
env: Record<string, string>; // explicit environment variables injected into cartesi-machine ENV
164+
envFile?: string; // path to a .env file with environment variables injected into cartesi-machine ENV
156165
maxMCycle?: bigint; // default given by cartesi-machine
157166
ramLength: string;
158167
ramImage?: string; // default given by cartesi-machine
@@ -197,6 +206,8 @@ export const defaultMachineConfig = (): MachineConfig => ({
197206
assertRollingTemplate: undefined,
198207
bootargs: [],
199208
entrypoint: undefined,
209+
env: {},
210+
envFile: undefined,
200211
maxMCycle: undefined,
201212
ramLength: DEFAULT_RAM,
202213
useDockerEnv: true,
@@ -259,6 +270,37 @@ const parseStringArray = (value: TomlPrimitive): string[] => {
259270
throw new InvalidStringArrayError();
260271
};
261272

273+
/**
274+
* Parses a TOML table into a record of string environment variables.
275+
* Non-string scalar values (number, bigint, boolean) are coerced to string,
276+
* since environment variable values are always strings.
277+
*/
278+
const parseStringRecord = (value: TomlPrimitive): Record<string, string> => {
279+
if (value === undefined) {
280+
return {};
281+
}
282+
if (!isTomlTable(value)) {
283+
throw new InvalidEnvError(value);
284+
}
285+
return Object.entries(value).reduce<Record<string, string>>(
286+
(acc, [key, val]) => {
287+
if (typeof val === "string") {
288+
acc[key] = val;
289+
} else if (
290+
typeof val === "number" ||
291+
typeof val === "bigint" ||
292+
typeof val === "boolean"
293+
) {
294+
acc[key] = String(val);
295+
} else {
296+
throw new InvalidStringValueError(val);
297+
}
298+
return acc;
299+
},
300+
{},
301+
);
302+
};
303+
262304
const parseRequiredString = (value: TomlPrimitive, key: string): string => {
263305
if (value === undefined) {
264306
throw new RequiredFieldError(key);
@@ -419,6 +461,8 @@ const parseMachine = (value: TomlPrimitive): MachineConfig => {
419461
),
420462
bootargs: parseStringArray(toml.boot_args),
421463
entrypoint: parseOptionalString(toml.entrypoint),
464+
env: parseStringRecord(toml.env),
465+
envFile: parseOptionalString(toml.env_file),
422466
maxMCycle: parseOptionalNumber(toml.max_mcycle),
423467
ramLength: parseString(toml.ram_length, DEFAULT_RAM),
424468
ramImage: parseOptionalString(toml.ram_image),

apps/cli/src/machine.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import dotenv from "dotenv";
2+
import fs from "node:fs";
13
import type { Config, DriveConfig, ImageInfo } from "./config.js";
24
import { cartesiMachine } from "./exec/index.js";
35
import type { ExecaOptionsDockerFallback } from "./exec/util.js";
@@ -34,6 +36,8 @@ export const bootMachine = (
3436
const { machine } = config;
3537
const {
3638
assertRollingTemplate,
39+
env: envConfig,
40+
envFile,
3741
maxMCycle,
3842
ramLength,
3943
ramImage,
@@ -42,12 +46,29 @@ export const bootMachine = (
4246
user,
4347
} = machine;
4448

45-
// list of environment variables of docker image
46-
const env = useDockerEnv ? (info?.env ?? []) : [];
47-
const envs = env.map((variable) => {
48-
const [key, value] = variable.split("=");
49-
return `--env=${key}="${value}"`;
50-
});
49+
// environment variables injected into the cartesi-machine ENV, by
50+
// increasing precedence: docker image ENV, env_file, then the env object
51+
const envMap: Record<string, string> = {};
52+
53+
// environment variables of docker image
54+
if (useDockerEnv) {
55+
for (const variable of info?.env ?? []) {
56+
const [key, value = ""] = variable.split("=");
57+
envMap[key] = value;
58+
}
59+
}
60+
61+
// environment variables from an .env file
62+
if (envFile) {
63+
Object.assign(envMap, dotenv.parse(fs.readFileSync(envFile)));
64+
}
65+
66+
// environment variables explicitly defined in the config
67+
Object.assign(envMap, envConfig);
68+
69+
const envs = Object.entries(envMap).map(
70+
([key, value]) => `--env=${key}="${value}"`,
71+
);
5172

5273
// check if we need a rootfstype boot arg
5374
const root = config.drives.root;

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
InvalidBytesValueError,
1111
InvalidDriveFormatError,
1212
InvalidEmptyDriveFormatError,
13+
InvalidEnvError,
1314
InvalidNumberValueError,
1415
InvalidStringValueError,
1516
parse,
@@ -134,6 +135,86 @@ shared = true`,
134135
},
135136
});
136137
});
138+
139+
it("should parse env_file", () => {
140+
const envFileConfig = `
141+
[machine]
142+
env_file = ".env"
143+
`;
144+
expect(parse([envFileConfig])).toEqual({
145+
...defaultConfig(),
146+
machine: {
147+
...defaultMachineConfig(),
148+
envFile: ".env",
149+
},
150+
});
151+
});
152+
153+
it("should fail for invalid env_file", () => {
154+
expect(() => parse(["[machine]\nenv_file = 42"])).toThrowError(
155+
new InvalidStringValueError(42),
156+
);
157+
});
158+
159+
it("should parse an env table", () => {
160+
const envConfig = `
161+
[machine.env]
162+
FOO = "bar"
163+
LOG_LEVEL = "debug"
164+
`;
165+
expect(parse([envConfig])).toEqual({
166+
...defaultConfig(),
167+
machine: {
168+
...defaultMachineConfig(),
169+
env: { FOO: "bar", LOG_LEVEL: "debug" },
170+
},
171+
});
172+
});
173+
174+
it("should parse an inline env table", () => {
175+
const envConfig = `
176+
[machine]
177+
env = { FOO = "bar" }
178+
`;
179+
expect(parse([envConfig])).toEqual({
180+
...defaultConfig(),
181+
machine: {
182+
...defaultMachineConfig(),
183+
env: { FOO: "bar" },
184+
},
185+
});
186+
});
187+
188+
it("should coerce non-string scalar env values to string", () => {
189+
const envConfig = `
190+
[machine.env]
191+
PORT = 8080
192+
ENABLED = true
193+
`;
194+
expect(parse([envConfig])).toEqual({
195+
...defaultConfig(),
196+
machine: {
197+
...defaultMachineConfig(),
198+
env: { PORT: "8080", ENABLED: "true" },
199+
},
200+
});
201+
});
202+
203+
it("should fail for an env that is not a table", () => {
204+
expect(() => parse(["[machine]\nenv = 42"])).toThrowError(
205+
new InvalidEnvError(42),
206+
);
207+
});
208+
209+
it("should fail for an env value that is an array", () => {
210+
const envConfig = `
211+
[machine.env]
212+
FOO = ["bar"]
213+
`;
214+
expect(() => parse([envConfig])).toThrowError(
215+
new InvalidStringValueError(["bar"]),
216+
);
217+
});
137218
});
138219

139220
/**

apps/cli/tests/unit/config/fixtures/full.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
# use_docker_env = true
1212
# use_docker_workdir = true
1313
# user = "dapp"
14+
# env_file = ".env" # optional. path to a .env file with environment variables
15+
16+
# [machine.env] # optional. environment variables injected into the machine
17+
# FOO = "bar"
18+
# LOG_LEVEL = "debug"
1419

1520
# [drives.root]
1621
# builder = "docker"

bun.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)