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
11 changes: 11 additions & 0 deletions .changeset/cartesi-toml-env-config.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions apps/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -153,6 +160,8 @@ export type MachineConfig = {
assertRollingTemplate?: boolean; // default given by cartesi-machine
bootargs: string[];
entrypoint?: string;
env: Record<string, string>; // 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
Expand Down Expand Up @@ -197,6 +206,8 @@ export const defaultMachineConfig = (): MachineConfig => ({
assertRollingTemplate: undefined,
bootargs: [],
entrypoint: undefined,
env: {},
envFile: undefined,
maxMCycle: undefined,
ramLength: DEFAULT_RAM,
useDockerEnv: true,
Expand Down Expand Up @@ -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<string, string> => {
if (value === undefined) {
return {};
}
if (!isTomlTable(value)) {
throw new InvalidEnvError(value);
}
return Object.entries(value).reduce<Record<string, string>>(
(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);
Expand Down Expand Up @@ -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),
Expand Down
33 changes: 27 additions & 6 deletions apps/cli/src/machine.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -34,6 +36,8 @@ export const bootMachine = (
const { machine } = config;
const {
assertRollingTemplate,
env: envConfig,
envFile,
maxMCycle,
ramLength,
ramImage,
Expand All @@ -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<string, string> = {};

// 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;
Expand Down
81 changes: 81 additions & 0 deletions apps/cli/tests/unit/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
InvalidBytesValueError,
InvalidDriveFormatError,
InvalidEmptyDriveFormatError,
InvalidEnvError,
InvalidNumberValueError,
InvalidStringValueError,
parse,
Expand Down Expand Up @@ -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"]),
);
});
});

/**
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/tests/unit/config/fixtures/full.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading