From a6f9068f0bcd82267e78506540f64531db7ab25d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 17:44:59 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016vnD51QAGwAvqaZuEZJypr --- .changeset/cartesi-toml-env-config.md | 11 +++ apps/cli/package.json | 1 + apps/cli/src/config.ts | 44 ++++++++++ apps/cli/src/machine.ts | 33 ++++++-- apps/cli/tests/unit/config.test.ts | 81 +++++++++++++++++++ apps/cli/tests/unit/config/fixtures/full.toml | 5 ++ bun.lock | 5 +- 7 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 .changeset/cartesi-toml-env-config.md diff --git a/.changeset/cartesi-toml-env-config.md b/.changeset/cartesi-toml-env-config.md new file mode 100644 index 00000000..e749b3f8 --- /dev/null +++ b/.changeset/cartesi-toml-env-config.md @@ -0,0 +1,11 @@ +--- +"@cartesi/cli": minor +--- + +feat: support `env` and `env_file` in the `[machine]` section of `cartesi.toml` + +Environment variables can now be injected into the Cartesi Machine build +without relying solely on the Dockerfile `ENV`. Define an `env` table with +inline key/value pairs and/or point `env_file` at a `.env` file. Precedence, +from lowest to highest, is: Docker image `ENV` (when `use_docker_env` is +enabled), `env_file`, then the `env` table. diff --git a/apps/cli/package.json b/apps/cli/package.json index ae40682c..95ad6649 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -25,6 +25,7 @@ "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^14.0.3", + "dotenv": "^16.6.1", "execa": "^9.6.0", "fs-extra": "^11.3.2", "get-port": "^7.1.0", diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts index 28d88b7d..7096f8ff 100644 --- a/apps/cli/src/config.ts +++ b/apps/cli/src/config.ts @@ -78,6 +78,13 @@ export class InvalidStringArrayError extends Error { } } +export class InvalidEnvError extends Error { + constructor(value: TomlPrimitive) { + super(`Invalid env configuration: ${value}`); + this.name = "InvalidEnvError"; + } +} + /** * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder */ @@ -153,6 +160,8 @@ export type MachineConfig = { assertRollingTemplate?: boolean; // default given by cartesi-machine bootargs: string[]; entrypoint?: string; + env: Record; // explicit environment variables injected into cartesi-machine ENV + envFile?: string; // path to a .env file with environment variables injected into cartesi-machine ENV maxMCycle?: bigint; // default given by cartesi-machine ramLength: string; ramImage?: string; // default given by cartesi-machine @@ -197,6 +206,8 @@ export const defaultMachineConfig = (): MachineConfig => ({ assertRollingTemplate: undefined, bootargs: [], entrypoint: undefined, + env: {}, + envFile: undefined, maxMCycle: undefined, ramLength: DEFAULT_RAM, useDockerEnv: true, @@ -259,6 +270,37 @@ const parseStringArray = (value: TomlPrimitive): string[] => { throw new InvalidStringArrayError(); }; +/** + * Parses a TOML table into a record of string environment variables. + * Non-string scalar values (number, bigint, boolean) are coerced to string, + * since environment variable values are always strings. + */ +const parseStringRecord = (value: TomlPrimitive): Record => { + if (value === undefined) { + return {}; + } + if (!isTomlTable(value)) { + throw new InvalidEnvError(value); + } + return Object.entries(value).reduce>( + (acc, [key, val]) => { + if (typeof val === "string") { + acc[key] = val; + } else if ( + typeof val === "number" || + typeof val === "bigint" || + typeof val === "boolean" + ) { + acc[key] = String(val); + } else { + throw new InvalidStringValueError(val); + } + return acc; + }, + {}, + ); +}; + const parseRequiredString = (value: TomlPrimitive, key: string): string => { if (value === undefined) { throw new RequiredFieldError(key); @@ -419,6 +461,8 @@ const parseMachine = (value: TomlPrimitive): MachineConfig => { ), bootargs: parseStringArray(toml.boot_args), entrypoint: parseOptionalString(toml.entrypoint), + env: parseStringRecord(toml.env), + envFile: parseOptionalString(toml.env_file), maxMCycle: parseOptionalNumber(toml.max_mcycle), ramLength: parseString(toml.ram_length, DEFAULT_RAM), ramImage: parseOptionalString(toml.ram_image), diff --git a/apps/cli/src/machine.ts b/apps/cli/src/machine.ts index 77d739df..b7f347ee 100644 --- a/apps/cli/src/machine.ts +++ b/apps/cli/src/machine.ts @@ -1,3 +1,5 @@ +import dotenv from "dotenv"; +import fs from "node:fs"; import type { Config, DriveConfig, ImageInfo } from "./config.js"; import { cartesiMachine } from "./exec/index.js"; import type { ExecaOptionsDockerFallback } from "./exec/util.js"; @@ -34,6 +36,8 @@ export const bootMachine = ( const { machine } = config; const { assertRollingTemplate, + env: envConfig, + envFile, maxMCycle, ramLength, ramImage, @@ -42,12 +46,29 @@ export const bootMachine = ( user, } = machine; - // list of environment variables of docker image - const env = useDockerEnv ? (info?.env ?? []) : []; - const envs = env.map((variable) => { - const [key, value] = variable.split("="); - return `--env=${key}="${value}"`; - }); + // environment variables injected into the cartesi-machine ENV, by + // increasing precedence: docker image ENV, env_file, then the env object + const envMap: Record = {}; + + // environment variables of docker image + if (useDockerEnv) { + for (const variable of info?.env ?? []) { + const [key, value = ""] = variable.split("="); + envMap[key] = value; + } + } + + // environment variables from an .env file + if (envFile) { + Object.assign(envMap, dotenv.parse(fs.readFileSync(envFile))); + } + + // environment variables explicitly defined in the config + Object.assign(envMap, envConfig); + + const envs = Object.entries(envMap).map( + ([key, value]) => `--env=${key}="${value}"`, + ); // check if we need a rootfstype boot arg const root = config.drives.root; diff --git a/apps/cli/tests/unit/config.test.ts b/apps/cli/tests/unit/config.test.ts index 2d32e92b..9da8006f 100644 --- a/apps/cli/tests/unit/config.test.ts +++ b/apps/cli/tests/unit/config.test.ts @@ -10,6 +10,7 @@ import { InvalidBytesValueError, InvalidDriveFormatError, InvalidEmptyDriveFormatError, + InvalidEnvError, InvalidNumberValueError, InvalidStringValueError, parse, @@ -134,6 +135,86 @@ shared = true`, }, }); }); + + it("should parse env_file", () => { + const envFileConfig = ` + [machine] + env_file = ".env" + `; + expect(parse([envFileConfig])).toEqual({ + ...defaultConfig(), + machine: { + ...defaultMachineConfig(), + envFile: ".env", + }, + }); + }); + + it("should fail for invalid env_file", () => { + expect(() => parse(["[machine]\nenv_file = 42"])).toThrowError( + new InvalidStringValueError(42), + ); + }); + + it("should parse an env table", () => { + const envConfig = ` + [machine.env] + FOO = "bar" + LOG_LEVEL = "debug" + `; + expect(parse([envConfig])).toEqual({ + ...defaultConfig(), + machine: { + ...defaultMachineConfig(), + env: { FOO: "bar", LOG_LEVEL: "debug" }, + }, + }); + }); + + it("should parse an inline env table", () => { + const envConfig = ` + [machine] + env = { FOO = "bar" } + `; + expect(parse([envConfig])).toEqual({ + ...defaultConfig(), + machine: { + ...defaultMachineConfig(), + env: { FOO: "bar" }, + }, + }); + }); + + it("should coerce non-string scalar env values to string", () => { + const envConfig = ` + [machine.env] + PORT = 8080 + ENABLED = true + `; + expect(parse([envConfig])).toEqual({ + ...defaultConfig(), + machine: { + ...defaultMachineConfig(), + env: { PORT: "8080", ENABLED: "true" }, + }, + }); + }); + + it("should fail for an env that is not a table", () => { + expect(() => parse(["[machine]\nenv = 42"])).toThrowError( + new InvalidEnvError(42), + ); + }); + + it("should fail for an env value that is an array", () => { + const envConfig = ` + [machine.env] + FOO = ["bar"] + `; + expect(() => parse([envConfig])).toThrowError( + new InvalidStringValueError(["bar"]), + ); + }); }); /** diff --git a/apps/cli/tests/unit/config/fixtures/full.toml b/apps/cli/tests/unit/config/fixtures/full.toml index fa40fabb..0f00964f 100644 --- a/apps/cli/tests/unit/config/fixtures/full.toml +++ b/apps/cli/tests/unit/config/fixtures/full.toml @@ -11,6 +11,11 @@ # use_docker_env = true # use_docker_workdir = true # user = "dapp" +# env_file = ".env" # optional. path to a .env file with environment variables + +# [machine.env] # optional. environment variables injected into the machine +# FOO = "bar" +# LOG_LEVEL = "debug" # [drives.root] # builder = "docker" diff --git a/bun.lock b/bun.lock index f52fbd25..c72b9b26 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "apps/cli": { "name": "@cartesi/cli", - "version": "2.0.0-alpha.34", + "version": "2.0.0-alpha.35", "bin": { "cartesi": "./dist/index.js", }, @@ -28,6 +28,7 @@ "chalk": "^5.6.2", "cli-table3": "^0.6.5", "commander": "^14.0.3", + "dotenv": "^16.6.1", "execa": "^9.6.0", "fs-extra": "^11.3.2", "get-port": "^7.1.0", @@ -105,7 +106,7 @@ }, "packages/sdk": { "name": "@cartesi/sdk", - "version": "0.12.0-alpha.39", + "version": "0.12.0-alpha.41", }, "packages/tsconfig": { "name": "tsconfig",