From ce0c0da417ea8c9dda737a3119fee3e3ae449fe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 02:17:11 +0000 Subject: [PATCH] feat(cli): configure applications with defineConfig Replace `cartesi.toml` with a `cartesi.config.ts` file that exports its configuration through `defineConfig`, imported from `@cartesi/cli/config`. The helper does nothing at runtime, and exists so the configuration is type checked and completed by the editor, without any annotation. A configuration file may also export a function, which receives the command being run and the mode, so a project can configure itself differently for `build` and `run`. TypeScript configuration files are imported by the runtime itself, so there is no build step and no extra dependency: the standalone binaries run on bun, and node strips the types from the version this package requires. Applications not written in TypeScript or JavaScript describe the same configuration as plain data, in a `cartesi.config.json`, `cartesi.config.yaml` or `cartesi.config` file, the last one read as YAML so it accepts JSON too. Because those are not type checked, the whole configuration is validated at load time, whichever format it came from. The configuration gained a `run` section with the project defaults of the local development environment, so they do not have to be repeated on every `cartesi run`. Command line options take precedence over it, which is why the commander defaults were moved into `run` itself. `cartesi.toml` keeps working, and is read when a project has no other configuration file, but is deprecated and prints a warning. Its parser moves to `config/toml.ts`, split from the types, the errors, the size parsing and the cross-section validation it now shares with the other formats, and is otherwise unchanged: it keeps its snake_case keys and has no `[run]` section. Errors of an asynchronous command action are now reported without a stack trace, as was intended. They surfaced as unhandled rejections, which had no handler, and the handler that did exist was dead code because the bundler replaces `process.env.NODE_ENV` with its build time value. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BHFyMTf1k57qtDwcbwVjrs --- .changeset/tidy-eagles-shout.md | 32 + CLAUDE.md | 2 +- apps/cli/README.md | 123 +- apps/cli/build.ts | 2 +- apps/cli/package.json | 4 + apps/cli/src/api/build.ts | 6 +- apps/cli/src/api/doctor.ts | 2 +- apps/cli/src/api/run.ts | 103 +- apps/cli/src/api/shell.ts | 6 +- apps/cli/src/api/types.ts | 46 +- apps/cli/src/base.ts | 21 +- apps/cli/src/builder/directory.ts | 2 +- apps/cli/src/builder/docker.ts | 2 +- apps/cli/src/builder/empty.ts | 2 +- apps/cli/src/builder/none.ts | 2 +- apps/cli/src/builder/nvram.ts | 2 +- apps/cli/src/builder/tar.ts | 2 +- apps/cli/src/commands/build.ts | 15 +- apps/cli/src/commands/run.ts | 64 +- apps/cli/src/commands/shell.ts | 17 +- apps/cli/src/config.ts | 1109 ----------------- apps/cli/src/config/errors.ts | 220 ++++ apps/cli/src/config/index.ts | 29 + apps/cli/src/config/limits.ts | 10 + apps/cli/src/config/load.ts | 258 ++++ apps/cli/src/config/merge.ts | 63 + apps/cli/src/config/normalize.ts | 508 ++++++++ apps/cli/src/config/size.ts | 68 + apps/cli/src/config/toml.ts | 555 +++++++++ apps/cli/src/config/types.ts | 430 +++++++ apps/cli/src/config/user.ts | 344 +++++ apps/cli/src/config/validate.ts | 99 ++ apps/cli/src/defineConfig.ts | 66 + .../src/exec/cartesi-machine-stored-hash.ts | 2 +- apps/cli/src/exec/rollups.ts | 2 +- apps/cli/src/index.ts | 20 +- apps/cli/src/lib.ts | 32 +- apps/cli/src/machine.ts | 2 +- apps/cli/src/wallet.ts | 2 +- apps/cli/src/withdrawal.ts | 2 +- .../integration/builder/directory.test.ts | 2 +- .../tests/integration/builder/docker.test.ts | 2 +- .../tests/integration/builder/empty.test.ts | 2 +- .../tests/integration/builder/none.test.ts | 2 +- .../tests/integration/builder/nvram.test.ts | 2 +- .../cli/tests/integration/builder/tar.test.ts | 2 +- apps/cli/tests/integration/config.ts | 5 +- .../tests/integration/machine/nvram.test.ts | 2 +- apps/cli/tests/unit/api/run.test.ts | 104 ++ apps/cli/tests/unit/api/types.test.ts | 34 +- apps/cli/tests/unit/config.test.ts | 2 +- .../unit/config/fixtures/files/bare.config | 3 + .../config/fixtures/files/cartesi.config.js | 3 + .../config/fixtures/files/cartesi.config.json | 5 + .../config/fixtures/files/cartesi.config.ts | 10 + .../config/fixtures/files/cartesi.config.yaml | 10 + .../unit/config/fixtures/files/empty.config | 0 .../config/fixtures/files/function.config.ts | 6 + .../config/fixtures/files/invalid.config.ts | 1 + .../unit/config/fixtures/files/override.yaml | 4 + .../config/fixtures/files/unsupported.ini | 1 + .../fixtures/project-legacy/cartesi.toml | 2 + .../unit/config/fixtures/project-none/.keep | 0 .../fixtures/project-yaml/cartesi.config.yaml | 4 + apps/cli/tests/unit/config/load.test.ts | 178 +++ apps/cli/tests/unit/config/normalize.test.ts | 501 ++++++++ apps/cli/tests/unit/config/size.test.ts | 76 ++ apps/cli/tests/unit/machine.test.ts | 2 +- apps/cli/tests/unit/withdrawal.test.ts | 2 +- 69 files changed, 3969 insertions(+), 1274 deletions(-) create mode 100644 .changeset/tidy-eagles-shout.md delete mode 100644 apps/cli/src/config.ts create mode 100644 apps/cli/src/config/errors.ts create mode 100644 apps/cli/src/config/index.ts create mode 100644 apps/cli/src/config/limits.ts create mode 100644 apps/cli/src/config/load.ts create mode 100644 apps/cli/src/config/merge.ts create mode 100644 apps/cli/src/config/normalize.ts create mode 100644 apps/cli/src/config/size.ts create mode 100644 apps/cli/src/config/toml.ts create mode 100644 apps/cli/src/config/types.ts create mode 100644 apps/cli/src/config/user.ts create mode 100644 apps/cli/src/config/validate.ts create mode 100644 apps/cli/src/defineConfig.ts create mode 100644 apps/cli/tests/unit/api/run.test.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/bare.config create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.js create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.json create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml create mode 100644 apps/cli/tests/unit/config/fixtures/files/empty.config create mode 100644 apps/cli/tests/unit/config/fixtures/files/function.config.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/invalid.config.ts create mode 100644 apps/cli/tests/unit/config/fixtures/files/override.yaml create mode 100644 apps/cli/tests/unit/config/fixtures/files/unsupported.ini create mode 100644 apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml create mode 100644 apps/cli/tests/unit/config/fixtures/project-none/.keep create mode 100644 apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml create mode 100644 apps/cli/tests/unit/config/load.test.ts create mode 100644 apps/cli/tests/unit/config/normalize.test.ts create mode 100644 apps/cli/tests/unit/config/size.test.ts diff --git a/.changeset/tidy-eagles-shout.md b/.changeset/tidy-eagles-shout.md new file mode 100644 index 000000000..edc0151ab --- /dev/null +++ b/.changeset/tidy-eagles-shout.md @@ -0,0 +1,32 @@ +--- +"@cartesi/cli": minor +--- + +Add a `defineConfig` function, and configure applications from +`cartesi.config.ts` instead of `cartesi.toml`. + +An application is now configured by a `cartesi.config.ts` file that exports its +configuration through `defineConfig`, imported from `@cartesi/cli/config`. The +helper does nothing at runtime, and exists so the configuration is type checked +and completed by the editor. A configuration file may also export a function, +which receives the command being run and the mode, so a project can configure +itself differently for `cartesi build` and `cartesi run`. + +Applications not written in TypeScript or JavaScript describe the same +configuration as plain data, in a `cartesi.config.json`, `cartesi.config.yaml` +or `cartesi.config` file. + +The configuration gained a `run` section with the project defaults of the local +development environment (`epochLength`, `services`, `blockTime`, `forkUrl`, +...), so they do not have to be repeated on every `cartesi run`. Command line +options take precedence over it. + +Nvrams, the accounts drive markers and the `withdrawal` overrides are +configured the same way, with camelCase keys. + +`cartesi.toml` keeps working, and is read when a project has no other +configuration file, but it is deprecated and now prints a warning. + +`resolveConfig` is now asynchronous, since a configuration file has to be +imported, and the `config` option of the API functions accepts a configuration +written inline, in the same shape a `cartesi.config.ts` file exports. diff --git a/CLAUDE.md b/CLAUDE.md index ce5924d2b..0a021f36e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ The CLI build pipeline (`apps/cli`): `clean` → `codegen` (wagmi ABI generation - **`builder/`** — Drive builder implementations (directory, docker, tar, empty, none). Each builder produces ext2 or SquashFS filesystems for Cartesi Machine drives. - **`compose/`** — Docker Compose service definitions generated as TypeScript objects (anvil, node, bundler, database, paymaster, proxy, explorer, etc.). - **`exec/`** — Wrappers around subprocess execution (cartesi-machine, rollups) using `execa`. -- **`config.ts`** — Parses `cartesi.toml` (TOML-based project config) into typed `Config` objects. Defines drive configs, machine configs, and SDK versions. +- **`config/`** — Reads the project configuration into typed `Config` objects, and defines the drive, nvram, machine and run configs. An application is configured by a `cartesi.config.ts` file exporting `defineConfig({ ... })`, or by the same configuration as plain data in `cartesi.config.json`, `cartesi.config.yaml` or `cartesi.config`. The deprecated `cartesi.toml` is parsed by `config/toml.ts`. - **`contracts.ts`** — Generated contract addresses and ABI bindings (via `@wagmi/cli`). - **`wallet.ts`** — Wallet utilities using `viem` for Ethereum interaction. diff --git a/apps/cli/README.md b/apps/cli/README.md index 3290c21e9..43ed3fcb1 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -18,6 +18,111 @@ cartesi --help More documentation at [https://docs.cartesi.io](https://docs.cartesi.io). +## Configuration + +An application is configured by a `cartesi.config.ts` file at its root, which +exports its configuration through `defineConfig`: + +```ts +import { defineConfig } from "@cartesi/cli/config"; + +export default defineConfig({ + drives: { + root: { builder: "docker", dockerfile: "Dockerfile" }, + data: { builder: "empty", size: "64Mi", mount: "/mnt/data" }, + }, + machine: { + entrypoint: "dapp", + ramLength: "256Mi", + }, + run: { + epochLength: 10, + services: ["explorer"], + }, +}); +``` + +`defineConfig` does nothing at runtime: it exists so the configuration is type +checked and completed by the editor, without any annotation. A configuration +file is free to compute its configuration, and to export a function instead of +an object when it depends on what is being run: + +```ts +export default defineConfig(async ({ command, mode }) => ({ + machine: { envFile: `.env.${mode}` }, + run: { epochLength: command === "run" ? 10 : 720 }, +})); +``` + +`mode` comes from `CARTESI_ENV` (or `NODE_ENV`), and defaults to +`development`. + +The `run` section holds the defaults of the local development environment +started by `cartesi run`, so a project does not have to repeat the same +options on every invocation. Command line options always take precedence over +it. + +Nvrams, and the drive or nvram that holds the accounts of emergency +withdrawal, are configured the same way: + +```ts +export default defineConfig({ + nvrams: { + accounts: { accountsDrive: true, size: "4Ki" }, + state: { size: "8Ki", shared: true }, + }, + withdrawal: { account_size: 64 }, +}); +``` + +An application has no obligation to have a configuration file: without one, it +is built from a `Dockerfile` of its directory, which is what most applications +need. + +### Applications not written in TypeScript + +The very same configuration can be written as plain data, for applications not +written in TypeScript or JavaScript. The keys are the ones above, and a +`$schema` key is accepted and ignored: + +```yaml +# cartesi.config.yaml +machine: + entrypoint: dapp + ramLength: 256Mi +drives: + data: + builder: empty + size: 64Mi + mount: /mnt/data +run: + epochLength: 10 +``` + +The configuration file of a project is the first of these that exists: + +| File | Format | +| -------------------------------------------------------------------------- | ----------------------------------------- | +| `cartesi.config.ts`, `.mts`, `.cts`, `.js`, `.mjs`, `.cjs` | module exporting `defineConfig({ ... })` | +| `cartesi.config.json` | JSON | +| `cartesi.config.yaml`, `cartesi.config.yml` | YAML | +| `cartesi.config` | YAML, which accepts JSON as well | +| `cartesi.toml` | deprecated | + +TypeScript configuration files are read by the runtime itself, so there is no +build step and no extra dependency: the standalone binaries run on Bun, and +node strips the types from version 24, which this package requires. + +### Migrating from `cartesi.toml` + +`cartesi.toml` still works, and is read when a project has no other +configuration file, but it is deprecated and prints a warning. The new formats +describe the same configuration, with one difference: keys are camelCase, as +the resolved configuration already is, so `extra_size` becomes `extraSize`, +`boot_args` becomes `bootargs` and `accounts_drive` becomes `accountsDrive`. +The keys of the `withdrawal` section keep the names the `cartesi-rollups-cli` +expects (`account_size`, `withdrawal_output_builder`). + ## Library Every command of the CLI is also available as a function, so applications can be @@ -57,9 +162,15 @@ The following functions are available: `addressBook`, `build`, `clean`, A few things to keep in mind: - functions operate on the current working directory, just like the CLI, and - read `cartesi.toml` from it by default. Functions that take a configuration - accept a path, a list of paths (merged in order), or an already parsed - `Config` object; + look the configuration file of the project up in it by default. Functions + that take a configuration accept a path, a list of paths (merged in order), + or the configuration written inline, in the same shape a `cartesi.config.ts` + file exports: + + ```ts + await build({ config: { machine: { ramLength: "256Mi" } } }); + await build({ config: "cartesi.config.production.ts" }); + ``` - functions are silent, and never write to the terminal. Pass `progress: "default"` (or `"verbose"`) to get the same output as the CLI; - functions throw on error, and never terminate the process; @@ -73,5 +184,7 @@ A few things to keep in mind: `DepositError` (`InsufficientBalanceError`, `InvalidAmountError` or `TokenNotFoundError`). -The package is typed, and the types of the configuration file (`Config`, -`DriveConfig`, `MachineConfig`, ...) are exported as well. +The package is typed, and the types of the configuration file (`UserConfig`, +`Config`, `DriveConfig`, `MachineConfig`, `RunConfig`, ...) are exported as +well, along with `defineConfig`, `loadConfig`, `findConfigFile`, +`normalizeConfig` and `mergeConfig`. diff --git a/apps/cli/build.ts b/apps/cli/build.ts index 133a836ed..e4d2d6140 100644 --- a/apps/cli/build.ts +++ b/apps/cli/build.ts @@ -9,7 +9,7 @@ await Bun.build({ }); await Bun.build({ - entrypoints: ["./src/lib.ts"], + entrypoints: ["./src/lib.ts", "./src/defineConfig.ts"], minify: true, outdir: "dist", sourcemap: true, diff --git a/apps/cli/package.json b/apps/cli/package.json index c04a4984f..20351e5af 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,6 +15,10 @@ "types": "./dist/types/lib.d.ts", "default": "./dist/lib.js" }, + "./config": { + "types": "./dist/types/defineConfig.d.ts", + "default": "./dist/defineConfig.js" + }, "./package.json": "./package.json" }, "repository": "cartesi/cli", diff --git a/apps/cli/src/api/build.ts b/apps/cli/src/api/build.ts index c7d71185c..d94756178 100644 --- a/apps/cli/src/api/build.ts +++ b/apps/cli/src/api/build.ts @@ -17,7 +17,7 @@ import type { ImageInfo, NvramConfig, WithdrawalConfig, -} from "../config.js"; +} from "../config/index.js"; import { bootMachine } from "../machine.js"; import { type AccountsDriveLayout, resolveWithdrawal } from "../withdrawal.js"; import { @@ -153,8 +153,8 @@ export const build = async ( ): Promise => { const { debug = false, drivesOnly = false, progress = "silent" } = options; - // get application configuration, from a Config object or 'cartesi.toml' - const config = resolveConfig(options.config); + // get application configuration, written inline or read from a file + const config = await resolveConfig(options.config, "build"); // destination directory for image and intermediate files const destination = path.resolve(getContextPath()); diff --git a/apps/cli/src/api/doctor.ts b/apps/cli/src/api/doctor.ts index 046862d43..154622fa9 100644 --- a/apps/cli/src/api/doctor.ts +++ b/apps/cli/src/api/doctor.ts @@ -1,6 +1,6 @@ import { execa } from "execa"; import semver from "semver"; -import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config.js"; +import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config/index.js"; import { cartesiMachine } from "../exec/index.js"; const MINIMUM_DOCKER_VERSION = "25.0.0"; // Replace with our minimum required Docker version diff --git a/apps/cli/src/api/run.ts b/apps/cli/src/api/run.ts index c095cf1bd..4f3854b46 100644 --- a/apps/cli/src/api/run.ts +++ b/apps/cli/src/api/run.ts @@ -13,8 +13,9 @@ import { getContextPath, getMachineHash, getProjectName } from "../base.js"; import { DEFAULT_SDK_VERSION, PREFERRED_PORT, + type RunConfig, type WithdrawalConfig, -} from "../config.js"; +} from "../config/index.js"; import { deployApplication, host, @@ -35,6 +36,11 @@ import { resolveConfig, } from "./types.js"; +/** + * Options of {@link run}. Every option that is not about this particular + * invocation can also be set in the `run` section of the application + * configuration, which these take precedence over. + */ export type RunOptions = ConfigOptions & ProgressOptions & { /** @@ -267,6 +273,39 @@ const deployMachine = async (options: { return application; }; +/** + * Layer the three sources of every option of the environment: the options + * given to {@link run} (which is where the command line options end up), the + * `run` section of the application configuration, and the defaults. + * + * @param options options given to {@link run} + * @param config `run` section of the application configuration + * @returns the option values the environment is started with + */ +export const resolveRunOptions = ( + options: RunOptions, + config: RunConfig = {}, +) => ({ + blockTime: options.blockTime ?? config.blockTime ?? 2, + claimStagingPeriod: + options.claimStagingPeriod ?? config.claimStagingPeriod ?? 0, + cpus: options.cpus ?? config.cpus, + defaultBlock: options.defaultBlock ?? config.defaultBlock ?? "latest", + epochLength: options.epochLength ?? config.epochLength ?? 720, + forkBlockNumber: options.forkBlockNumber ?? config.forkBlockNumber, + forkUrl: options.forkUrl ?? config.forkUrl, + memory: options.memory ?? config.memory, + // a port of zero is not a port, so the first free one is resolved later + port: options.port || config.port, + projectName: options.projectName ?? config.projectName, + prt: options.prt ?? config.prt ?? false, + runtimeVersion: + options.runtimeVersion ?? config.runtimeVersion ?? DEFAULT_SDK_VERSION, + services: options.services ?? config.services ?? [], + verbose: + options.verbose ?? config.verbose ?? options.progress === "verbose", +}); + /** * Run a local Cartesi node for the application, and deploy to it the machine * snapshot built at `.cartesi/image`, if there is one. @@ -279,32 +318,46 @@ const deployMachine = async (options: { */ export const run = async (options: RunOptions = {}): Promise => { const { - blockTime = 2, - claimStagingPeriod = 0, - cpus, - defaultBlock = "latest", deploy: deployOnStart = true, detach = true, dryRun = false, - epochLength = 720, - memory, progress = "silent", - prt = false, - runtimeVersion = DEFAULT_SDK_VERSION, - services = [], } = options; - const verbose = options.verbose ?? progress === "verbose"; - - // project name explicitly defined or the current directory name - const projectName = getProjectName(options); - // get application configuration (e.g. use withdrawal config if present) - const applicationConfig = resolveConfig(options.config); + const applicationConfig = await resolveConfig(options.config, "run"); + + // the 'run' section of the configuration defines project level defaults, + // which the options given to this function always take precedence over + const { + blockTime, + claimStagingPeriod, + cpus, + defaultBlock, + epochLength, + memory, + prt, + runtimeVersion, + services, + verbose, + ...resolved + } = resolveRunOptions(options, applicationConfig.run); + + // project name explicitly defined, defined by the configuration, or the + // current directory name + const projectName = getProjectName(resolved); + + if (defaultBlock !== "finalized" && progress !== "silent") { + console.warn( + chalk.yellow( + `WARNING: default block is set to '${defaultBlock}', production configuration will likely use 'finalized'`, + ), + ); + } // resolve port number, using the first free port in a range, unless explicitly set const port = - options.port || + resolved.port || (await getPort({ port: portNumbers(PREFERRED_PORT, PREFERRED_PORT + 10), })); @@ -313,7 +366,7 @@ export const run = async (options: RunOptions = {}): Promise => { const url = `${host}:${port}`; // configure optional anvil fork - const forkConfig = await configureFork(options); + const forkConfig = await configureFork(resolved); if (forkConfig) { await assertForkConfig(forkConfig, { includePRT: prt }); @@ -322,14 +375,16 @@ export const run = async (options: RunOptions = {}): Promise => { // emergency withdrawal configuration, derived from the accounts drive of the built // machine. The configuration is resolved again, as a rebuild may have changed it. const imagePath = getContextPath("image"); - const resolveWithdrawalConfig = () => - getWithdrawalConfig(resolveConfig(options.config), imagePath, { - fork: forkConfig !== undefined, - }); + const resolveWithdrawalConfig = async () => + getWithdrawalConfig( + await resolveConfig(options.config, "run"), + imagePath, + { fork: forkConfig !== undefined }, + ); // fail before starting the environment if it can't be derived if (fs.existsSync(imagePath)) { - resolveWithdrawalConfig(); + await resolveWithdrawalConfig(); } // run compose environment @@ -373,7 +428,7 @@ export const run = async (options: RunOptions = {}): Promise => { // the accounts drive may have moved with a rebuild, resolved before // undeploying in case it is now invalid - const withdrawalConfig = resolveWithdrawalConfig(); + const withdrawalConfig = await resolveWithdrawalConfig(); // keep the consensus of the previous deployment, if there is one const consensus = deployment?.consensus; diff --git a/apps/cli/src/api/shell.ts b/apps/cli/src/api/shell.ts index 5b87221d5..643bd8219 100644 --- a/apps/cli/src/api/shell.ts +++ b/apps/cli/src/api/shell.ts @@ -2,7 +2,7 @@ import { ExecaError } from "execa"; import fs from "fs-extra"; import path from "node:path"; import { getContextPath } from "../base.js"; -import { nvramHasImage, nvramImageFilename } from "../config.js"; +import { nvramHasImage, nvramImageFilename } from "../config/index.js"; import { bootMachine } from "../machine.js"; import { type ConfigOptions, resolveConfig } from "./types.js"; @@ -32,8 +32,8 @@ export type ShellOptions = ConfigOptions & { export const shell = async (options: ShellOptions = {}): Promise => { const { command = "/bin/sh", runAsRoot = false } = options; - // get application configuration, from a Config object or 'cartesi.toml' - const config = resolveConfig(options.config); + // get application configuration, written inline or read from a file + const config = await resolveConfig(options.config, "shell"); // destination directory for image and intermediate files const destination = path.resolve(getContextPath()); diff --git a/apps/cli/src/api/types.ts b/apps/cli/src/api/types.ts index 3b3fe59cb..9ca4b0574 100644 --- a/apps/cli/src/api/types.ts +++ b/apps/cli/src/api/types.ts @@ -1,5 +1,10 @@ -import { getApplicationConfig } from "../base.js"; -import type { Config } from "../config.js"; +import { + type Config, + type ConfigCommand, + loadConfig, + normalizeConfig, + type UserConfig, +} from "../config/index.js"; /** * Verbosity of the progress information written to the terminal while an API @@ -23,38 +28,47 @@ export type ProgressOptions = { }; /** - * Application configuration, either already parsed, or a path (or list of - * paths) of TOML configuration files to be read and merged, in order. + * Application configuration, either written inline in the same shape a + * `cartesi.config.ts` file exports, or a path (or list of paths) of + * configuration files to be read and merged, in order. Files of any of the + * supported formats are accepted. An already resolved {@link Config} is a + * valid inline configuration as well. */ -export type ConfigInput = Config | string | string[]; +export type ConfigInput = UserConfig | string | string[]; export type ConfigOptions = { /** - * Application configuration, or path of the configuration file(s). - * @default "cartesi.toml" + * Application configuration, or path of the configuration file(s). When + * not given, the configuration file of the project is looked up by name. */ config?: ConfigInput; }; /** * Resolve the application configuration from the several ways it can be - * provided to the API: an already parsed {@link Config}, one configuration file - * path, a list of configuration file paths, or nothing (which falls back to - * `cartesi.toml` of the current directory). + * provided to the API: a configuration written inline, one configuration file + * path, a list of configuration file paths, or nothing (which looks the + * configuration file of the project up, and falls back to the defaults when + * there is none). * @param config configuration or path of configuration file(s) - * @returns parsed application configuration + * @param command command being run, given to a configuration file that exports + * a function + * @returns resolved application configuration */ -export const resolveConfig = (config?: ConfigInput): Config => { +export const resolveConfig = async ( + config?: ConfigInput, + command: ConfigCommand = "build", +): Promise => { if (config === undefined) { - return getApplicationConfig(["cartesi.toml"]); + return loadConfig({ command }); } if (typeof config === "string") { - return getApplicationConfig([config]); + return loadConfig({ command, files: [config] }); } if (Array.isArray(config)) { - return getApplicationConfig(config); + return loadConfig({ command, files: config }); } - return config; + return normalizeConfig(config); }; /** diff --git a/apps/cli/src/base.ts b/apps/cli/src/base.ts index b522e8472..2f95ef801 100644 --- a/apps/cli/src/base.ts +++ b/apps/cli/src/base.ts @@ -2,6 +2,7 @@ import { InvalidArgumentError } from "@commander-js/extra-typings"; import { execa } from "execa"; import fs from "node:fs"; import path from "node:path"; +import { loadConfig } from "./config/index.js"; import { type Address, getAddress, @@ -11,7 +12,6 @@ import { zeroHash, } from "viem"; import { foundry } from "viem/chains"; -import { type Config, parse } from "./config.js"; import { applicationFactoryAddress, authorityFactoryAddress, @@ -43,9 +43,9 @@ export const getContextPath = (...paths: string[]): string => { /** * SDK image of the project, which built its machine snapshot */ -const getProjectSdk = (): string | undefined => { +const getProjectSdk = async (): Promise => { try { - return getApplicationConfig(["cartesi.toml"]).sdk; + return (await loadConfig()).sdk; } catch { // an invalid config is reported by the commands that build with it return undefined; @@ -63,7 +63,7 @@ export const getMachineHash = async (options?: { }): Promise => { const imagePath = getContextPath("image"); if (fs.existsSync(imagePath)) { - const image = options?.sdk ?? getProjectSdk(); + const image = options?.sdk ?? (await getProjectSdk()); return await cartesiMachineStoredHash.computeHash( imagePath, image ? { image } : undefined, @@ -72,19 +72,6 @@ export const getMachineHash = async (options?: { return undefined; }; -export const getApplicationConfig = (configPaths: string[]): Config => { - const tomls = configPaths.map((configPath) => { - if (fs.existsSync(configPath)) { - return fs.readFileSync(configPath).toString(); - } - if (configPath === "cartesi.toml") { - return ""; - } - throw new Error(`Config file ${configPath} does not exist`); - }); - return parse(tomls); -}; - export const getProjectName = (options: { projectName?: string }) => { return options.projectName ?? path.basename(process.cwd()); }; diff --git a/apps/cli/src/builder/directory.ts b/apps/cli/src/builder/directory.ts index 55eb53984..041b097e8 100644 --- a/apps/cli/src/builder/directory.ts +++ b/apps/cli/src/builder/directory.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import type { DirectoryDriveConfig } from "../config.js"; +import type { DirectoryDriveConfig } from "../config/index.js"; import { genext2fs, mksquashfs } from "../exec/index.js"; import type { Reporter } from "../exec/util.js"; diff --git a/apps/cli/src/builder/docker.ts b/apps/cli/src/builder/docker.ts index cf734bebc..b8ac00882 100644 --- a/apps/cli/src/builder/docker.ts +++ b/apps/cli/src/builder/docker.ts @@ -2,7 +2,7 @@ import { execa } from "execa"; import fs from "fs-extra"; import os from "node:os"; import path from "node:path"; -import type { DockerDriveConfig } from "../config.js"; +import type { DockerDriveConfig } from "../config/index.js"; import { genext2fs, mksquashfs } from "../exec/index.js"; import type { Reporter } from "../exec/util.js"; import type { BuildxMetadata } from "../types/docker.js"; diff --git a/apps/cli/src/builder/empty.ts b/apps/cli/src/builder/empty.ts index 9ddc0475a..8deba7335 100644 --- a/apps/cli/src/builder/empty.ts +++ b/apps/cli/src/builder/empty.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import type { EmptyDriveConfig } from "../config.js"; +import type { EmptyDriveConfig } from "../config/index.js"; import { genext2fs } from "../exec/index.js"; export const build = async ( diff --git a/apps/cli/src/builder/none.ts b/apps/cli/src/builder/none.ts index fe8733a59..33aa9dfd3 100644 --- a/apps/cli/src/builder/none.ts +++ b/apps/cli/src/builder/none.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import { type ExistingDriveConfig, getDriveFormat } from "../config.js"; +import { type ExistingDriveConfig, getDriveFormat } from "../config/index.js"; export const build = async ( name: string, diff --git a/apps/cli/src/builder/nvram.ts b/apps/cli/src/builder/nvram.ts index 5ef5e8b76..3315b220f 100644 --- a/apps/cli/src/builder/nvram.ts +++ b/apps/cli/src/builder/nvram.ts @@ -6,7 +6,7 @@ import { type NvramConfig, nvramHasImage, nvramImageFilename, -} from "../config.js"; +} from "../config/index.js"; export const build = async ( label: string, diff --git a/apps/cli/src/builder/tar.ts b/apps/cli/src/builder/tar.ts index 34633ecc4..b189f45ee 100644 --- a/apps/cli/src/builder/tar.ts +++ b/apps/cli/src/builder/tar.ts @@ -1,6 +1,6 @@ import fs from "fs-extra"; import path from "node:path"; -import type { TarDriveConfig } from "../config.js"; +import type { TarDriveConfig } from "../config/index.js"; import { genext2fs, mksquashfs } from "../exec/index.js"; import type { Reporter } from "../exec/util.js"; diff --git a/apps/cli/src/commands/build.ts b/apps/cli/src/commands/build.ts index d9728011d..c19af5305 100755 --- a/apps/cli/src/commands/build.ts +++ b/apps/cli/src/commands/build.ts @@ -7,11 +7,16 @@ export const createBuildCommand = () => { .description( "Build application by building Cartesi machine drives, configuring a machine and booting it.", ) - .option( - "-c, --config ", - "path to the configuration file", - (value, prev) => prev.concat([value]), - ["cartesi.toml"], + .addOption( + new Option( + "-c, --config ", + "path to the configuration file", + ) + .argParser((value, prev) => prev.concat([value])) + .default( + [] as string[], + "the configuration file of the project", + ), ) .addOption( new Option( diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index e310afc27..7cbae650f 100755 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -7,7 +7,6 @@ import { build } from "../api/build.js"; import { logs } from "../api/logs.js"; import { run, type RunResult } from "../api/run.js"; import { nodeAllowedEnvironmentVariables } from "../compose/node.js"; -import { DEFAULT_SDK_VERSION } from "../config.js"; import { AVAILABLE_SERVICES, getDeployments, @@ -52,7 +51,7 @@ const reportApplicationStatus = (deployment: RollupsDeployment) => { const shell = async (options: { config: string[]; node: RunResult; - verbose: boolean; + verbose?: boolean; }) => { const { config, node, verbose } = options; const { projectName } = node; @@ -166,19 +165,12 @@ const shell = async (options: { export const createRunCommand = () => { return new Command("run") .description("Run a local cartesi node for the application.") - .addOption( - new Option( - "--prt", - "deploy application with PRT consensus", - ).default(false), - ) + .addOption(new Option("--prt", "deploy application with PRT consensus")) .addOption( new Option( "--block-time ", - "interval between blocks (in seconds)", - ) - .argParser(Number) - .default(2), + "interval between blocks (in seconds) (default: 2)", + ).argParser(Number), ) .addOption( new Option( @@ -189,10 +181,8 @@ export const createRunCommand = () => { .addOption( new Option( "--default-block ", - "default block to be used when fetching new blocks.", - ) - .choices(["latest", "safe", "pending", "finalized"]) - .default("latest"), + "default block to be used when fetching new blocks. (default: latest)", + ).choices(["latest", "safe", "pending", "finalized"]), ) .option("--dry-run", "show the docker compose configuration", false) .option( @@ -216,33 +206,32 @@ export const createRunCommand = () => { .addOption( new Option( "--epoch-length ", - "length of an epoch (in blocks)", - ) - .argParser(Number) - .default(720), + "length of an epoch (in blocks) (default: 720)", + ).argParser(Number), ) .option("-p, --port ", "port to listen on", Number) .addOption( new Option( "--claim-staging-period ", - "claim staging period (in blocks). Number of blocks between a claim being submitted and accepted", - ) - .argParser(Number) - .default(0), + "claim staging period (in blocks). Number of blocks between a claim being submitted and accepted (default: 0)", + ).argParser(Number), ) - .option( - "-c, --config ", - "Path to the configuration file (.toml)", - (value, prev) => prev.concat([value]), - ["cartesi.toml"], + .addOption( + new Option( + "-c, --config ", + "path to the configuration file", + ) + .argParser((value, prev) => prev.concat([value])) + .default( + [] as string[], + "the configuration file of the project", + ), ) .addOption( new Option( "--runtime-version ", "version for Cartesi Rollups Runtime to use", - ) - .default(DEFAULT_SDK_VERSION) - .hideHelp(), + ).hideHelp(), ) .option( "--project-name ", @@ -252,9 +241,8 @@ export const createRunCommand = () => { "--services ", `optional services to start, comma separated list from [${AVAILABLE_SERVICES.join(", ")}]`, commaSeparatedList, - [], ) - .option("-v, --verbose", "verbose output", false) + .option("-v, --verbose", "verbose output") .action(async (options) => { const { prt, @@ -288,14 +276,6 @@ export const createRunCommand = () => { return; } - if (defaultBlock !== "finalized") { - console.warn( - chalk.yellow( - `WARNING: default block is set to '${defaultBlock}', production configuration will likely use 'finalized'`, - ), - ); - } - // if TTY is not attached, run on foreground (not detached) const detach = !!process.stdin.isTTY; diff --git a/apps/cli/src/commands/shell.ts b/apps/cli/src/commands/shell.ts index 7131971b6..81ac94598 100755 --- a/apps/cli/src/commands/shell.ts +++ b/apps/cli/src/commands/shell.ts @@ -1,14 +1,19 @@ -import { Command } from "@commander-js/extra-typings"; +import { Command, Option } from "@commander-js/extra-typings"; import { shell } from "../api/shell.js"; export const createShellCommand = () => { return new Command("shell") .option("--command ", "shell command to run", "/bin/sh") - .option( - "-c, --config ", - "path to the configuration file", - (value, prev) => prev.concat([value]), - ["cartesi.toml"], + .addOption( + new Option( + "-c, --config ", + "path to the configuration file", + ) + .argParser((value, prev) => prev.concat([value])) + .default( + [] as string[], + "the configuration file of the project", + ), ) .option("--run-as-root", "run as root user", false) .action(async (options) => { diff --git a/apps/cli/src/config.ts b/apps/cli/src/config.ts deleted file mode 100644 index 5cdcfa7ab..000000000 --- a/apps/cli/src/config.ts +++ /dev/null @@ -1,1109 +0,0 @@ -import { extname } from "node:path"; -import { parse as parseToml, type TomlPrimitive } from "smol-toml"; -import { getAddress, isAddress, type Address } from "viem"; - -export const NVRAM_ALIGNMENT = 4096; // cartesi-machine requires length % 4Ki == 0 -const MAX_NVRAMS = 8; // guest exposes nvrams as /dev/uio0 to /dev/uio7 - -/** - * Typed Errors - */ - -// smol-toml creates tables with a null prototype, which can't be coerced to a -// string by template literals, so format objects (tables, arrays) as JSON. -const formatValue = (value: TomlPrimitive): string => - typeof value === "object" && value !== null && !(value instanceof Date) - ? JSON.stringify(value, (_, v) => - typeof v === "bigint" ? v.toString() : v, - ) - : String(value); - -export class InvalidBuilderError extends Error { - constructor(builder: TomlPrimitive) { - super(`Invalid builder: ${formatValue(builder)}`); - this.name = "InvalidBuilder"; - } -} - -export class InvalidDriveFormatError extends Error { - constructor(format: TomlPrimitive) { - super(`Invalid drive format: ${formatValue(format)}`); - this.name = "InvalidDriveFormatError"; - } -} - -export class InvalidEmptyDriveFormatError extends Error { - constructor(format: TomlPrimitive) { - super(`Invalid empty drive format: ${formatValue(format)}`); - this.name = "InvalidEmptyDriveFormatError"; - } -} - -export class InvalidStringValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid string value: ${formatValue(value)}`); - this.name = "InvalidStringValueError"; - } -} - -export class InvalidBooleanValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid boolean value: ${formatValue(value)}`); - this.name = "InvalidBooleanValueError"; - } -} - -export class InvalidNumberValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { - super( - `Invalid number value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, - ); - this.name = "InvalidNumberValueError"; - } -} - -export class InvalidAddressValueError extends Error { - constructor(value: TomlPrimitive, key?: string) { - super( - `Invalid address value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, - ); - this.name = "InvalidAddressValueError"; - } -} - -export class InvalidBytesValueError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid bytes value: ${formatValue(value)}`); - this.name = "InvalidBytesValueError"; - } -} - -export class RequiredFieldError extends Error { - constructor(key: TomlPrimitive) { - super(`Missing required field: ${formatValue(key)}`); - this.name = "RequiredFieldError"; - } -} - -export class InvalidStringArrayError extends Error { - constructor() { - super("Invalid string array"); - this.name = "InvalidStringArrayError"; - } -} - -export class InvalidEnvError extends Error { - constructor(value: TomlPrimitive) { - super(`Invalid env configuration: ${formatValue(value)}`); - this.name = "InvalidEnvError"; - } -} - -export class MissingNvramSourceError extends Error { - constructor(label: string) { - super(`Nvram '${label}' must define either 'size' or 'filename'`); - this.name = "MissingNvramSourceError"; - } -} - -export class InvalidNvramSizeError extends Error { - constructor(label: string, size: number) { - super( - `Invalid size ${size} for nvram '${label}': must be a positive multiple of ${NVRAM_ALIGNMENT}`, - ); - this.name = "InvalidNvramSizeError"; - } -} - -export class TooManyNvramsError extends Error { - constructor(count: number) { - super(`Too many nvrams: ${count}, maximum is ${MAX_NVRAMS}`); - this.name = "TooManyNvramsError"; - } -} - -export class DuplicateLabelError extends Error { - constructor(label: string) { - super(`Label '${label}' is used by both a drive and an nvram`); - this.name = "DuplicateLabelError"; - } -} - -export class MultipleAccountsDrivesError extends Error { - constructor(labels: string[]) { - super( - `Only one drive or nvram can be the accounts drive, but ${labels.map((label) => `'${label}'`).join(", ")} are marked with accounts_drive = true`, - ); - this.name = "MultipleAccountsDrivesError"; - } -} - -export class InvalidAccountsDriveError extends Error { - constructor(label: string, reason: string) { - super(`Invalid accounts drive '${label}': ${reason}`); - this.name = "InvalidAccountsDriveError"; - } -} - -export class InvalidWithdrawalConfigError extends Error { - constructor(reason: string) { - super(`Invalid [withdrawal]: ${reason}`); - this.name = "InvalidWithdrawalConfigError"; - } -} - -/** - * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder - */ -const DEFAULT_FORMAT = "ext2"; -const DEFAULT_RAM = "128Mi"; -export const DEFAULT_SDK_VERSION = "0.12.0-alpha.44"; -export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; -export const PREFERRED_PORT = 6751; - -type Builder = "directory" | "docker" | "empty" | "none" | "tar"; -export type DriveFormat = "ext2" | "sqfs"; - -export type ImageInfo = { - cmd: string[]; - entrypoint: string[]; - env: string[]; - workdir: string; -}; - -export type DriveResult = ImageInfo | undefined; - -export type DirectoryDriveConfig = { - builder: "directory"; - extraSize: number; // default is 0 (no extra size) - format: DriveFormat; - directory: string; // required -}; - -export type DockerDriveConfig = { - builder: "docker"; - buildArgs: string[]; // default is empty array - context: string; - dockerfile: string; - extraSize: number; // default is 0 (no extra size) - format: DriveFormat; - image?: string; // default is to build an image from a Dockerfile - tags: string[]; // default is empty array - target?: string; // default is last stage of multi-stage -}; - -export type EmptyDriveConfig = { - builder: "empty"; - format: "ext2" | "raw"; - size: number; // in bytes -}; - -export type ExistingDriveConfig = { - builder: "none"; - filename: string; // required - format: DriveFormat; -}; - -export type TarDriveConfig = { - builder: "tar"; - filename: string; // required - format: DriveFormat; - extraSize: number; // default is 0 (no extra size) -}; - -/** - * Marks the drive or nvram that holds the accounts of emergency withdrawal, whose layout is - * derived from the built machine. - */ -type AccountsDriveMarker = { - accountsDrive?: boolean; // holds the accounts of emergency withdrawal - accountsDriveSize?: number; // accounts only in this many bytes at the beginning, default is all of it -}; - -export type DriveConfig = ( - | DirectoryDriveConfig - | DockerDriveConfig - | EmptyDriveConfig - | ExistingDriveConfig - | TarDriveConfig -) & - AccountsDriveMarker & { - mount?: string | boolean; // default given by cartesi-machine - shared?: boolean; // default given by cartesi-machine - user?: string; // default given by cartesi-machine - }; - -/** - * Configuration for an NVRAM of a Cartesi Machine. Unlike a flash drive, an nvram is a raw - * range of bytes exposed to the guest as a /dev/uio* device, with no filesystem and no mount - * point. Either `size` or `filename` must be defined. - */ -export type NvramConfig = AccountsDriveMarker & { - filename?: string; // path to an existing raw image with the initial contents - size?: number; // in bytes, a positive multiple of 4Ki - shared?: boolean; // default given by cartesi-machine - user?: string; // default given by cartesi-machine -}; - -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 - useDockerEnv: boolean; // inject docker image ENV into cartesi-machine ENV - useDockerWorkdir: boolean; // inject docker image WORKDIR into cartesi-machine WORKDIR - user?: string; // default given by cartesi-machine -}; - -/** - * Configuration for Emergercy-withdrawals that will be passed down to the - * cartesi-rollups-cli. This is a All or nothing kind of configuration. - * The properties are kept snake_case to match the expected input in the cartesi-rollups-cli. - */ -export type WithdrawalConfig = { - guardian: Address; - log2_leaves_per_account: number; - log2_max_num_of_accounts: number; - accounts_drive_start_index: number; - withdrawal_output_builder: Address; -}; - -/** - * The optional [withdrawal] section of an application with an accounts drive. The accounts - * drive layout is derived from the built machine; these keys override the defaults. - */ -export type WithdrawalOptions = { - guardian?: Address; // default is a devnet account - withdrawal_output_builder?: Address; // default is the devnet TestUsdWithdrawalOutputBuilder - account_size?: number; // in bytes, default is 32 -}; - -export type Config = { - drives: Record; - machine: MachineConfig; - nvrams: Record; - sdk: string; - withdrawal?: WithdrawalOptions; -}; - -export const LOG2_LEAF_SIZE = 5; // accounts are made of 32-byte leaves - -/** - * Number of 32-byte leaves of each account, in log2, from `account_size`. Defaults to one leaf. - */ -export const getLog2LeavesPerAccount = (options?: WithdrawalOptions): number => - options?.account_size !== undefined - ? Math.log2(options.account_size) - LOG2_LEAF_SIZE // a power of two, so exact - : 0; - -/** - * The drive or nvram marked with `accounts_drive = true`. - */ -export type AccountsDrive = { - kind: "flash_drive" | "nvram"; // section of the stored machine config that holds it - label: string; - config: DriveConfig | NvramConfig; // its entry in cartesi.toml -}; - -/** - * Every drive and nvram marked with `accounts_drive = true`. Parsing rejects more than one, - * and lists them all to say which. - */ -const getMarkedDrives = (config: Config): AccountsDrive[] => - [ - ...Object.entries(config.drives).map(([label, drive]) => ({ - kind: "flash_drive" as const, - label, - config: drive, - })), - ...Object.entries(config.nvrams).map(([label, nvram]) => ({ - kind: "nvram" as const, - label, - config: nvram, - })), - ].filter((drive) => drive.config.accountsDrive); - -export const getAccountsDrive = (config: Config): AccountsDrive | undefined => - getMarkedDrives(config)[0]; - -/** - * Splits a number of bytes into the largest IEC unit that divides it exactly, e.g. 4194304 is - * 4 "Mi", and 1000 is 1000 "". - */ -export const splitSize = (bytes: number): { value: number; unit: string } => { - let value = bytes; - let unit = ""; - for (const next of ["Ki", "Mi", "Gi", "Ti"]) { - if (value === 0 || value % 1024 !== 0) { - break; - } - value /= 1024; - unit = next; - } - return { value, unit }; -}; - -/** - * Formats a number of bytes as a size cartesi.toml accepts, e.g. "4Mi". - */ -const formatSize = (bytes: number): string => { - const { value, unit } = splitSize(bytes); - return `${value}${unit}`; -}; - -/** - * The log2 of a positive power of two, or undefined for any other number. It reads the bits, - * which is exact for every safe integer, where `&` wraps at 32 bits and Math.log2 rounds near - * 2^53. - */ -const log2OfPowerOfTwo = (value: number): number | undefined => { - const bits = Number.isInteger(value) ? value.toString(2) : ""; - return /^10*$/.test(bits) ? bits.length - 1 : undefined; -}; - -const nextPowerOfTwo = (value: number): number => - 2 ** (value - 1).toString(2).length; - -/** - * Checks the accounts drive can be divided into accounts. The accounts drive is either all of - * the drive or nvram marked with `accounts_drive = true`, or its first `accounts_drive_size` - * bytes, the rest being free for other application state. - * @param driveLength length of the whole drive, if known - * @returns the length of the accounts drive and its log2, if known - */ -export const assertAccountsDriveLength = ( - config: Config, - accountsDrive: AccountsDrive, - driveLength?: number, -): { length: number; log2Length: number } | undefined => { - const { label } = accountsDrive; - const { accountsDriveSize } = accountsDrive.config; - const log2LeavesPerAccount = getLog2LeavesPerAccount(config.withdrawal); - const length = accountsDriveSize ?? driveLength; - if (length === undefined) { - return undefined; - } - - const log2Length = log2OfPowerOfTwo(length); - if (log2Length === undefined) { - const suggestion = - length > 0 ? `, use "${formatSize(nextPowerOfTwo(length))}"` : ""; - throw new InvalidAccountsDriveError( - label, - accountsDriveSize !== undefined - ? `accounts_drive_size ${length} is not a power of two${suggestion}` - : `size ${length} is not a power of two${suggestion}, or set accounts_drive_size to keep the accounts at the beginning of a larger drive`, - ); - } - const log2AccountSize = LOG2_LEAF_SIZE + log2LeavesPerAccount; - if (log2Length < log2AccountSize) { - throw new InvalidAccountsDriveError( - label, - `size ${length} is smaller than one account of ${2 ** log2AccountSize} bytes`, - ); - } - if ( - accountsDriveSize !== undefined && - driveLength !== undefined && - accountsDriveSize > driveLength - ) { - throw new InvalidAccountsDriveError( - label, - `accounts_drive_size ${accountsDriveSize} is larger than the drive itself (${driveLength} bytes)`, - ); - } - return { length, log2Length }; -}; - -type TomlTable = { [key: string]: TomlPrimitive }; - -export const defaultRootDriveConfig = (): DriveConfig => ({ - builder: "docker", - buildArgs: [], - context: ".", - dockerfile: "Dockerfile", // file on current working directory - extraSize: 0, - format: DEFAULT_FORMAT, - tags: [], -}); - -export const defaultMachineConfig = (): MachineConfig => ({ - assertRollingTemplate: undefined, - bootargs: [], - entrypoint: undefined, - env: {}, - envFile: undefined, - maxMCycle: undefined, - ramLength: DEFAULT_RAM, - useDockerEnv: true, - useDockerWorkdir: true, - user: undefined, -}); - -export const defaultConfig = (): Config => ({ - drives: { root: defaultRootDriveConfig() }, - machine: defaultMachineConfig(), - nvrams: {}, - sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, - withdrawal: undefined, -}); - -const parseBoolean = (value: TomlPrimitive, defaultValue: boolean): boolean => { - if (value === undefined) { - return defaultValue; - } - if (typeof value === "boolean") { - return value; - } - throw new InvalidBooleanValueError(value); -}; - -const parseOptionalBoolean = (value: TomlPrimitive): boolean | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "boolean") { - return value; - } - throw new InvalidBooleanValueError(value); -}; - -const parseString = (value: TomlPrimitive, defaultValue: string): string => { - if (value === undefined) { - return defaultValue; - } - if (typeof value === "string") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseStringArray = (value: TomlPrimitive): string[] => { - if (value === undefined) { - return []; - } - if (typeof value === "string") { - return [value]; - } - if (typeof value === "object" && Array.isArray(value)) { - return value.map((v) => { - if (typeof v === "string") { - return v; - } - throw new InvalidStringValueError(v); - }); - } - 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); - } - if (typeof value === "string") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseRequiredAddress = (value: TomlPrimitive, key: string): Address => { - if (value === undefined) { - throw new RequiredFieldError(key); - } - if (typeof value === "string" && isAddress(value)) { - return getAddress(value); - } - throw new InvalidAddressValueError(value, key); -}; - -const parseOptionalString = (value: TomlPrimitive): string | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "string") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseOptionalStringBoolean = ( - value: TomlPrimitive, -): string | boolean | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "string") { - return value; - } - if (typeof value === "boolean") { - return value; - } - throw new InvalidStringValueError(value); -}; - -const parseOptionalNumber = (value: TomlPrimitive): bigint | undefined => { - if (value === undefined) { - return undefined; - } - if (typeof value === "bigint") { - return value; - } - if (typeof value === "number") { - return BigInt(value); - } - throw new InvalidNumberValueError(value); -}; - -const IEC_MULTIPLIERS: Record = { - "": 1, - b: 1, - k: 1024, - ki: 1024, - kb: 1024, - kib: 1024, - m: 1024 ** 2, - mi: 1024 ** 2, - mb: 1024 ** 2, - mib: 1024 ** 2, - g: 1024 ** 3, - gi: 1024 ** 3, - gb: 1024 ** 3, - gib: 1024 ** 3, - t: 1024 ** 4, - ti: 1024 ** 4, - tb: 1024 ** 4, - tib: 1024 ** 4, - p: 1024 ** 5, - pi: 1024 ** 5, - pb: 1024 ** 5, - pib: 1024 ** 5, -}; - -/** - * Parses a byte size of a drive or nvram, accepting the IEC suffixes used by cartesi-machine - * ("4Ki", "1MiB") and their decimal spellings ("4kb", "100Mb"), all as powers of 1024, up to - * petabytes. Sizes are numbers, so the ones beyond 2^53 bytes, which they can't hold exactly, - * are rejected. - */ -const parseSize = (value: TomlPrimitive): number | undefined => { - if (value === undefined) { - return undefined; - } - let size: number | undefined; - if (typeof value === "number") { - size = value; - } else if (typeof value === "string") { - const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(value); - const multiplier = match - ? IEC_MULTIPLIERS[match[2].toLowerCase()] - : undefined; - if (match && multiplier !== undefined) { - size = Number(match[1]) * multiplier; - } - } - // a whole number of bytes, e.g. "1.5Ki" is but "1.1Ki" isn't - if ( - size !== undefined && - Number.isInteger(size) && - size <= Number.MAX_SAFE_INTEGER - ) { - return size; - } - throw new InvalidBytesValueError(value); -}; - -const parseBytes = (value: TomlPrimitive, defaultValue: number): number => - parseSize(value) ?? defaultValue; - -/** - * Parses the keys that mark a drive or nvram as the accounts drive, leaving them out when unset. - */ -const parseAccountsDriveMarker = (toml: TomlTable): AccountsDriveMarker => { - const accountsDrive = parseOptionalBoolean(toml.accounts_drive); - const accountsDriveSize = parseSize(toml.accounts_drive_size); - return { - ...(accountsDrive !== undefined && { accountsDrive }), - ...(accountsDriveSize !== undefined && { accountsDriveSize }), - }; -}; - -const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { - const toml = isTomlTable(value) ? value : {}; - const size = parseSize(toml.size); - const filename = parseOptionalString(toml.filename); - - if (size === undefined && filename === undefined) { - throw new MissingNvramSourceError(label); - } - if (size !== undefined && (size <= 0 || size % NVRAM_ALIGNMENT !== 0)) { - throw new InvalidNvramSizeError(label, size); - } - - return { - ...parseAccountsDriveMarker(toml), - filename, - size, - shared: parseOptionalBoolean(toml.shared), - user: parseOptionalString(toml.user), - }; -}; - -const parseNvrams = (config: TomlPrimitive): Record => { - const entries = Object.entries((config as TomlTable) ?? {}); - if (entries.length > MAX_NVRAMS) { - throw new TooManyNvramsError(entries.length); - } - return entries.reduce>( - (acc, [label, nvram]) => { - acc[label] = parseNvram(label, nvram); - return acc; - }, - {}, - ); -}; - -/** - * Filename, relative to the build destination directory, of the image backing an nvram. - */ -export const nvramImageFilename = (label: string): string => `${label}.raw`; - -/** - * Whether an nvram is backed by an image file. A pristine nvram needs no image, as - * cartesi-machine fills its range with zeros. A `shared` one does, as there must be a file for - * the guest writes to be persisted to. - */ -export const nvramHasImage = (nvram: NvramConfig): boolean => - nvram.filename !== undefined || nvram.shared === true; - -const parseBuilder = (value: TomlPrimitive): Builder => { - if (value === undefined) { - return "docker"; - } - if (typeof value === "string") { - switch (value) { - case "directory": - return "directory"; - case "docker": - return "docker"; - case "empty": - return "empty"; - case "none": - return "none"; - case "tar": - return "tar"; - } - } - throw new InvalidBuilderError(value); -}; - -const parseFormat = (value: TomlPrimitive): DriveFormat => { - if (value === undefined) { - return DEFAULT_FORMAT; - } - if (typeof value === "string") { - switch (value) { - case "ext2": - return "ext2"; - case "sqfs": - return "sqfs"; - } - } - throw new InvalidDriveFormatError(value); -}; - -const parseEmptyFormat = (value: TomlPrimitive): "ext2" | "raw" => { - if (value === undefined) { - return DEFAULT_FORMAT; - } - if (typeof value === "string") { - switch (value) { - case "ext2": - return "ext2"; - case "raw": - return "raw"; - } - } - throw new InvalidEmptyDriveFormatError(value); -}; - -const parseMachine = (value: TomlPrimitive): MachineConfig => { - if (value === undefined) { - // default machine - return defaultMachineConfig(); - } - if (typeof value !== "object") { - throw new Error(`Invalid machine configuration: ${value}`); - } - const toml = value as TomlTable; - - return { - assertRollingTemplate: parseOptionalBoolean( - toml.assert_rolling_template, - ), - 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), - useDockerEnv: parseBoolean(toml.use_docker_env, true), - useDockerWorkdir: parseBoolean(toml.use_docker_workdir, true), - user: parseOptionalString(toml.user), - }; -}; - -export const getDriveFormat = (filename: string): DriveFormat => { - const extension = extname(filename); - switch (extension) { - case ".ext2": - return "ext2"; - case ".sqfs": - return "sqfs"; - default: - throw new InvalidDriveFormatError(extension); - } -}; - -const parseDrive = (drive: TomlPrimitive): DriveConfig => { - const toml = drive as TomlTable; - return { ...parseBuilderDrive(toml), ...parseAccountsDriveMarker(toml) }; -}; - -const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { - const builder = parseBuilder((drive as TomlTable).builder); - switch (builder) { - case "directory": { - const { extra_size, format, mount, directory, shared, user } = - drive as TomlTable; - return { - builder: "directory", - extraSize: parseBytes(extra_size, 0), - format: parseFormat(format), - mount: parseOptionalStringBoolean(mount), - directory: parseRequiredString(directory, "directory"), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - }; - } - case "docker": { - const { - build_args, - context, - dockerfile, - extra_size, - format, - image, - mount, - shared, - tags, - target, - user, - } = drive as TomlTable; - return { - builder: "docker", - buildArgs: parseStringArray(build_args), - image: parseOptionalString(image), - context: parseString(context, "."), - dockerfile: parseString(dockerfile, "Dockerfile"), - extraSize: parseBytes(extra_size, 0), - format: parseFormat(format), - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - tags: parseStringArray(tags), - target: parseOptionalString(target), - }; - } - case "empty": { - const { format, mount, size, shared, user } = drive as TomlTable; - return { - builder: "empty", - format: parseEmptyFormat(format), - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - size: parseBytes(size, 0), - user: parseOptionalString(user), - }; - } - case "tar": { - const { extra_size, filename, format, mount, shared, user } = - drive as TomlTable; - return { - builder: "tar", - extraSize: parseBytes(extra_size, 0), - filename: parseRequiredString(filename, "filename"), - format: parseFormat(format), - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - }; - } - case "none": { - const { shared, mount, user } = drive as TomlTable; - const filename = parseRequiredString( - (drive as TomlTable).filename, - "filename", - ); - const format = getDriveFormat(filename); - return { - builder: "none", - filename, - format, - mount: parseOptionalStringBoolean(mount), - shared: parseOptionalBoolean(shared), - user: parseOptionalString(user), - }; - } - } -}; - -const parseDrives = (config: TomlPrimitive): Record => { - // load drives from configuration - const drives = Object.entries((config as TomlTable) ?? {}).reduce< - Record - >((acc, [name, drive]) => { - acc[name] = parseDrive(drive); - return acc; - }, {}); - - // check if there is a root drive - const hasRoot = drives.root !== undefined; - if (!hasRoot) { - // there is no root drive, add a default one - drives.root = defaultRootDriveConfig(); - } - return drives; -}; - -const parseAccountSize = (value: TomlPrimitive): number => { - const size = parseSize(value); - if ( - size === undefined || - size < 2 ** LOG2_LEAF_SIZE || - log2OfPowerOfTwo(size) === undefined - ) { - throw new InvalidWithdrawalConfigError( - `account_size ${formatValue(value)} must be a power of two of at least 32 bytes, such as 32 or 64`, - ); - } - return size; -}; - -/** - * Parses [withdrawal], which only applies to an application with an accounts drive. Like the - * other tables, it reads the keys it uses and leaves any other key alone. - */ -const parseOptionalWithdrawal = ( - withdrawal: TomlPrimitive, -): WithdrawalOptions | undefined => { - if (withdrawal === undefined) { - return undefined; - } - if (!isTomlTable(withdrawal)) { - throw new InvalidWithdrawalConfigError( - `expected a table, got ${formatValue(withdrawal)}`, - ); - } - - const optional = ( - key: string, - parser: (value: TomlPrimitive, key: string) => T, - ): T | undefined => - withdrawal[key] === undefined - ? undefined - : parser(withdrawal[key], key); - - const options: WithdrawalOptions = { - guardian: optional("guardian", parseRequiredAddress), - withdrawal_output_builder: optional( - "withdrawal_output_builder", - parseRequiredAddress, - ), - account_size: optional("account_size", parseAccountSize), - }; - return options; -}; - -/** - * Checks what can be known about the accounts drive before the machine is built. Its placement - * is only known after, see `resolveAccountsDriveLayout`. - */ -const assertAccountsDrive = (config: Config) => { - const marked = getMarkedDrives(config); - if (marked.length > 1) { - throw new MultipleAccountsDrivesError(marked.map(({ label }) => label)); - } - - for (const [label, drive] of [ - ...Object.entries(config.drives), - ...Object.entries(config.nvrams), - ]) { - if (drive.accountsDriveSize !== undefined && !drive.accountsDrive) { - throw new InvalidAccountsDriveError( - label, - "accounts_drive_size requires accounts_drive = true", - ); - } - } - - const [accountsDrive] = marked; - if (!accountsDrive) { - if (config.withdrawal) { - throw new InvalidWithdrawalConfigError( - "emergency withdrawal needs an accounts drive, mark a raw drive or an nvram with accounts_drive = true", - ); - } - return; - } - - const { label } = accountsDrive; - - let size: number | undefined; - if (accountsDrive.kind === "flash_drive") { - const drive = config.drives[label]; - if (drive.builder !== "empty" || drive.format !== "raw") { - throw new InvalidAccountsDriveError( - label, - `it must not have a filesystem, use builder = "empty" and format = "raw", or declare it under [nvrams.${label}]`, - ); - } - if (drive.mount !== undefined && drive.mount !== false) { - throw new InvalidAccountsDriveError( - label, - "it can't be mounted, remove 'mount' or set it to false", - ); - } - size = drive.size; - } else { - // the size of an nvram backed by an existing image is only known once built - size = config.nvrams[label].size; - } - - assertAccountsDriveLength(config, accountsDrive, size); -}; - -export const parse = (str: string[]): Config => { - let toml: TomlTable = {}; - for (const s of str) { - toml = mergeTomlTables(toml, parseToml(s)); - } - - const drives = parseDrives(toml.drives); - const nvrams = parseNvrams(toml.nvrams); - - // drives and nvrams share the DTB /aliases namespace, so labels cannot collide - for (const label of Object.keys(nvrams)) { - if (drives[label] !== undefined) { - throw new DuplicateLabelError(label); - } - } - - const config: Config = { - withdrawal: parseOptionalWithdrawal(toml.withdrawal), - drives, - machine: parseMachine(toml.machine), - nvrams, - sdk: parseString( - toml.sdk, - `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, - ), - }; - - assertAccountsDrive(config); - - return config; -}; - -/** - * Checks if a value is a plain object (TOML table) - */ -function isTomlTable(value: TomlPrimitive): value is TomlTable { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - !("toISOString" in value) - ); // Check for TomlDate (has toISOString method) -} - -/** - * Recursively merges two TOML table objects - * Values from 'other' take precedence over 'base' - * - * @param base - The base TOML table - * @param other - The TOML table to merge into base (takes precedence) - * @returns A new merged TOML table - */ -export function mergeTomlTables(base: TomlTable, other: TomlTable): TomlTable { - const result: TomlTable = { ...base }; - - for (const [key, otherValue] of Object.entries(other)) { - const baseValue = result[key]; - - // If both values are tables, merge them recursively - if (isTomlTable(baseValue) && isTomlTable(otherValue)) { - result[key] = mergeTomlTables(baseValue, otherValue); - } else { - // For all other cases, other value takes precedence - result[key] = otherValue; - } - } - - return result; -} - -/** - * Merges two TOML values of any type - * - * @param base - The base TOML value - * @param other - The TOML value to merge into base (takes precedence) - * @returns The merged TOML value - */ -export function mergeTomlValues( - base: TomlPrimitive, - other: TomlPrimitive, -): TomlPrimitive { - // If both are tables, merge recursively - if (isTomlTable(base) && isTomlTable(other)) { - return mergeTomlTables(base, other); - } - - // For arrays, replaces entirely - return other; -} diff --git a/apps/cli/src/config/errors.ts b/apps/cli/src/config/errors.ts new file mode 100644 index 000000000..d4b7947c9 --- /dev/null +++ b/apps/cli/src/config/errors.ts @@ -0,0 +1,220 @@ +import { MAX_NVRAMS, NVRAM_ALIGNMENT } from "./limits.js"; + +/** + * Typed errors thrown while reading and validating the configuration of an + * application, regardless of the format it was written in. + */ + +// smol-toml creates tables with a null prototype, which can't be coerced to a +// string by template literals, so format objects (tables, arrays) as JSON. +export const formatValue = (value: unknown): string => + typeof value === "object" && value !== null && !(value instanceof Date) + ? JSON.stringify(value, (_, v) => + typeof v === "bigint" ? v.toString() : v, + ) + : String(value); + +export class InvalidBuilderError extends Error { + constructor(builder: unknown) { + super(`Invalid builder: ${formatValue(builder)}`); + this.name = "InvalidBuilder"; + } +} + +export class InvalidDriveFormatError extends Error { + constructor(format: unknown) { + super(`Invalid drive format: ${formatValue(format)}`); + this.name = "InvalidDriveFormatError"; + } +} + +export class InvalidEmptyDriveFormatError extends Error { + constructor(format: unknown) { + super(`Invalid empty drive format: ${formatValue(format)}`); + this.name = "InvalidEmptyDriveFormatError"; + } +} + +export class InvalidStringValueError extends Error { + constructor(value: unknown) { + super(`Invalid string value: ${formatValue(value)}`); + this.name = "InvalidStringValueError"; + } +} + +export class InvalidBooleanValueError extends Error { + constructor(value: unknown) { + super(`Invalid boolean value: ${formatValue(value)}`); + this.name = "InvalidBooleanValueError"; + } +} + +export class InvalidNumberValueError extends Error { + constructor(value: unknown, key?: string) { + super( + `Invalid number value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, + ); + this.name = "InvalidNumberValueError"; + } +} + +export class InvalidAddressValueError extends Error { + constructor(value: unknown, key?: string) { + super( + `Invalid address value: ${formatValue(value)}${key ? ` for key: ${key}` : ""}`, + ); + this.name = "InvalidAddressValueError"; + } +} + +export class InvalidBytesValueError extends Error { + constructor(value: unknown) { + super(`Invalid bytes value: ${formatValue(value)}`); + this.name = "InvalidBytesValueError"; + } +} + +export class RequiredFieldError extends Error { + constructor(key: unknown) { + super(`Missing required field: ${formatValue(key)}`); + this.name = "RequiredFieldError"; + } +} + +export class InvalidStringArrayError extends Error { + constructor() { + super("Invalid string array"); + this.name = "InvalidStringArrayError"; + } +} + +export class InvalidEnvError extends Error { + constructor(value: unknown) { + super(`Invalid env configuration: ${formatValue(value)}`); + this.name = "InvalidEnvError"; + } +} + +export class MissingNvramSourceError extends Error { + constructor(label: string) { + super(`Nvram '${label}' must define either 'size' or 'filename'`); + this.name = "MissingNvramSourceError"; + } +} + +export class InvalidNvramSizeError extends Error { + constructor(label: string, size: number) { + super( + `Invalid size ${size} for nvram '${label}': must be a positive multiple of ${NVRAM_ALIGNMENT}`, + ); + this.name = "InvalidNvramSizeError"; + } +} + +export class TooManyNvramsError extends Error { + constructor(count: number) { + super(`Too many nvrams: ${count}, maximum is ${MAX_NVRAMS}`); + this.name = "TooManyNvramsError"; + } +} + +export class DuplicateLabelError extends Error { + constructor(label: string) { + super(`Label '${label}' is used by both a drive and an nvram`); + this.name = "DuplicateLabelError"; + } +} + +export class MultipleAccountsDrivesError extends Error { + constructor(labels: string[]) { + super( + `Only one drive or nvram can be the accounts drive, but ${labels.map((label) => `'${label}'`).join(", ")} are marked with accounts_drive = true`, + ); + this.name = "MultipleAccountsDrivesError"; + } +} + +export class InvalidAccountsDriveError extends Error { + constructor(label: string, reason: string) { + super(`Invalid accounts drive '${label}': ${reason}`); + this.name = "InvalidAccountsDriveError"; + } +} + +export class InvalidWithdrawalConfigError extends Error { + constructor(reason: string) { + super(`Invalid [withdrawal]: ${reason}`); + this.name = "InvalidWithdrawalConfigError"; + } +} + +/** + * Thrown when a section of the configuration is expected to be an object, but + * is something else. + */ +export class InvalidSectionError extends Error { + constructor(section: string, value: unknown) { + super(`Invalid '${section}' configuration: ${formatValue(value)}`); + this.name = "InvalidSectionError"; + } +} + +/** + * Thrown when a field only accepts a fixed set of values, and is given + * something else. + */ +export class InvalidEnumValueError extends Error { + constructor(key: string, value: unknown, allowed: readonly string[]) { + super( + `Invalid value for '${key}': ${formatValue(value)}, must be one of ${allowed.join(", ")}`, + ); + this.name = "InvalidEnumValueError"; + } +} + +/** + * Thrown when a configuration file explicitly asked for does not exist. + */ +export class ConfigFileNotFoundError extends Error { + constructor(filename: string) { + super(`Config file ${filename} does not exist`); + this.name = "ConfigFileNotFoundError"; + } +} + +/** + * Thrown when a configuration file has an extension the CLI cannot read. + */ +export class UnsupportedConfigFormatError extends Error { + constructor(filename: string) { + super(`Unsupported configuration file format: ${filename}`); + this.name = "UnsupportedConfigFormatError"; + } +} + +/** + * Thrown when a TypeScript or JavaScript configuration file does not export a + * configuration as its default export. + */ +export class InvalidConfigExportError extends Error { + constructor(filename: string) { + super( + `Configuration file ${filename} must have a default export with the application configuration, ` + + "as in 'export default defineConfig({ ... })'", + ); + this.name = "InvalidConfigExportError"; + } +} + +/** + * Thrown when the runtime cannot import a TypeScript configuration file. + */ +export class TypeScriptNotSupportedError extends Error { + constructor(filename: string) { + super( + `Cannot import ${filename}: this runtime does not strip TypeScript types. ` + + "Run on node 24 or later without '--no-experimental-strip-types', or write the configuration as cartesi.config.js, .json or .yaml", + ); + this.name = "TypeScriptNotSupportedError"; + } +} diff --git a/apps/cli/src/config/index.ts b/apps/cli/src/config/index.ts new file mode 100644 index 000000000..6095a9c2d --- /dev/null +++ b/apps/cli/src/config/index.ts @@ -0,0 +1,29 @@ +/** + * Configuration of a Cartesi application. + * + * An application is configured by a `cartesi.config.ts` file that exports a + * configuration built with {@link defineConfig}, which gives it type checking + * and editor completion. Applications not written in TypeScript or JavaScript + * describe the same configuration as plain data, in a `cartesi.config.json`, + * `cartesi.config.yaml` or `cartesi.config` file. + * + * The deprecated `cartesi.toml` file is still read when no other configuration + * file is present. + */ + +export * from "./errors.js"; +export { + CONFIG_FILES, + findConfigFile, + LEGACY_CONFIG_FILE, + loadConfig, + type LoadConfigOptions, + loadConfigFile, +} from "./load.js"; +export { mergeConfig } from "./merge.js"; +export { normalizeConfig } from "./normalize.js"; +export { parseBytes, parseSize } from "./size.js"; +export { mergeTomlTables, mergeTomlValues, parse } from "./toml.js"; +export * from "./types.js"; +export * from "./user.js"; +export { assertAccountSize, assertAccountsDrive } from "./validate.js"; diff --git a/apps/cli/src/config/limits.ts b/apps/cli/src/config/limits.ts new file mode 100644 index 000000000..5bab7ee97 --- /dev/null +++ b/apps/cli/src/config/limits.ts @@ -0,0 +1,10 @@ +/** + * Limits of a Cartesi machine, in a module of their own because both the + * configuration types and the errors reporting them need them. + */ + +/** cartesi-machine requires the length of an nvram to be a multiple of 4Ki. */ +export const NVRAM_ALIGNMENT = 4096; + +/** The guest exposes nvrams as `/dev/uio0` to `/dev/uio7`. */ +export const MAX_NVRAMS = 8; diff --git a/apps/cli/src/config/load.ts b/apps/cli/src/config/load.ts new file mode 100644 index 000000000..2cd3ef577 --- /dev/null +++ b/apps/cli/src/config/load.ts @@ -0,0 +1,258 @@ +import chalk from "chalk"; +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse as parseYaml } from "yaml"; +import { + ConfigFileNotFoundError, + InvalidConfigExportError, + TypeScriptNotSupportedError, + UnsupportedConfigFormatError, +} from "./errors.js"; +import { mergeConfig } from "./merge.js"; +import { normalizeConfig } from "./normalize.js"; +import { parse as parseTomlConfig } from "./toml.js"; +import type { Config } from "./types.js"; +import type { + ConfigCommand, + ConfigEnv, + UserConfig, + UserConfigFn, +} from "./user.js"; + +/** + * Names of the configuration files looked up in the project directory, in + * order of precedence. The first one that exists is the configuration of the + * application. + * + * TypeScript and JavaScript files export their configuration with + * `defineConfig`. The remaining formats are for applications not written in + * TypeScript or JavaScript, and describe the very same configuration as plain + * data. A file with no format in its name (`cartesi.config`) is read as YAML, + * which also makes it valid JSON. + */ +export const CONFIG_FILES = [ + "cartesi.config.ts", + "cartesi.config.mts", + "cartesi.config.cts", + "cartesi.config.js", + "cartesi.config.mjs", + "cartesi.config.cjs", + "cartesi.config.json", + "cartesi.config.yaml", + "cartesi.config.yml", + "cartesi.config", + "cartesi.toml", // deprecated +] as const; + +/** Name of the deprecated configuration file. */ +export const LEGACY_CONFIG_FILE = "cartesi.toml"; + +const MODULE_EXTENSIONS = [".ts", ".mts", ".cts", ".js", ".mjs", ".cjs"]; +const TYPESCRIPT_EXTENSIONS = [".ts", ".mts", ".cts"]; + +const isLegacyConfigFile = (filename: string): boolean => + path.extname(filename) === ".toml"; + +let legacyWarned = false; + +/** + * Warn, once per process, that the legacy configuration file is deprecated. + */ +const warnLegacyConfig = (filename: string): void => { + if (legacyWarned) { + return; + } + legacyWarned = true; + console.warn( + chalk.yellow( + `WARNING: the TOML configuration format (${path.basename(filename)}) is deprecated, migrate to cartesi.config.ts, or to cartesi.config.yaml for applications not written in TypeScript`, + ), + ); +}; + +/** + * Find the configuration file of the application. + * @param cwd directory to look the configuration file up at + * @returns absolute path of the configuration file, or `undefined` if the + * application has no configuration file, in which case the defaults apply + */ +export const findConfigFile = ( + cwd: string = process.cwd(), +): string | undefined => { + for (const name of CONFIG_FILES) { + const filename = path.resolve(cwd, name); + if (fs.existsSync(filename)) { + return filename; + } + } + return undefined; +}; + +/** + * Whether the runtime can import a TypeScript file directly. Bun always can, + * and so does node, which this package requires at version 24 or later, unless + * type stripping was explicitly turned off. + */ +const canImportTypeScript = (): boolean => + process.versions.bun !== undefined || Boolean(process.features.typescript); + +/** + * Import a TypeScript or JavaScript configuration file and return its default + * export. The file is imported by the runtime itself, so there is no build + * step: the standalone binaries run on Bun, and node strips the types. + */ +const importConfigFile = async (filename: string): Promise => { + if ( + TYPESCRIPT_EXTENSIONS.includes(path.extname(filename)) && + !canImportTypeScript() + ) { + throw new TypeScriptNotSupportedError(filename); + } + + // the query string busts the module cache, so a configuration file edited + // between two builds of the same process is picked up + const url = `${pathToFileURL(filename).href}?t=${Date.now()}`; + const module: unknown = await import(url); + + // a CommonJS file assigning to 'module.exports' also lands on 'default' + return (module as { default?: unknown })?.default; +}; + +/** + * Read one configuration file, of any of the supported formats, without + * applying any default. + * @param filename path of the configuration file + * @param env context given to a configuration file that exports a function + * @returns configuration as written in the file + */ +export const loadConfigFile = async ( + filename: string, + env: ConfigEnv, +): Promise => { + const extension = path.extname(filename); + + if (MODULE_EXTENSIONS.includes(extension)) { + const exported = await importConfigFile(filename); + if (exported === undefined || exported === null) { + throw new InvalidConfigExportError(filename); + } + + // a configuration file may export the configuration itself, a promise + // of one, or a function computing it from the environment + const config = + typeof exported === "function" + ? await (exported as UserConfigFn)(env) + : await exported; + + if (typeof config !== "object" || config === null) { + throw new InvalidConfigExportError(filename); + } + return config as UserConfig; + } + + const contents = fs.readFileSync(filename, "utf8"); + + switch (extension) { + case ".json": + return (JSON.parse(contents) ?? {}) as UserConfig; + case ".yaml": + case ".yml": + // a file with no format in its name, such as 'cartesi.config', is read + // as YAML, which accepts JSON as well + case ".config": + return (parseYaml(contents) ?? {}) as UserConfig; + case ".toml": + warnLegacyConfig(filename); + return parseTomlConfig([contents]); + default: + throw new UnsupportedConfigFormatError(filename); + } +}; + +export type LoadConfigOptions = { + /** + * Command being run, given to a configuration file that exports a + * function. + * @default "build" + */ + command?: ConfigCommand; + + /** + * Directory the configuration is resolved from. + * @default process.cwd() + */ + cwd?: string; + + /** + * Configuration files to read, merged in order. When empty, the + * configuration file of the project is looked up by name. + */ + files?: string[]; + + /** + * Mode the application is being built or run in. + * @default `CARTESI_ENV`, `NODE_ENV`, or "development" + */ + mode?: string; +}; + +/** + * Load the configuration of an application. + * + * Unless configuration files are explicitly given, the project directory is + * searched for one of the {@link CONFIG_FILES}. An application without a + * configuration file is perfectly valid, and gets the default configuration. + * + * @param options where to load the configuration from + * @returns resolved application configuration + */ +export const loadConfig = async ( + options: LoadConfigOptions = {}, +): Promise => { + const cwd = options.cwd ?? process.cwd(); + const env: ConfigEnv = { + command: options.command ?? "build", + cwd, + mode: + options.mode ?? + process.env.CARTESI_ENV ?? + process.env.NODE_ENV ?? + "development", + }; + + let files: string[]; + if (options.files?.length) { + files = options.files.map((file) => path.resolve(cwd, file)); + for (const file of files) { + if (!fs.existsSync(file)) { + throw new ConfigFileNotFoundError(file); + } + } + } else { + const found = findConfigFile(cwd); + files = found ? [found] : []; + } + + if (files.length === 0) { + // an application without a configuration file uses the defaults + return normalizeConfig(undefined); + } + + if (files.every(isLegacyConfigFile)) { + // legacy files are merged as TOML tables before being parsed, which is + // how the deprecated format has always behaved + for (const file of files) { + warnLegacyConfig(file); + } + return parseTomlConfig( + files.map((file) => fs.readFileSync(file, "utf8")), + ); + } + + let config: UserConfig = {}; + for (const file of files) { + config = mergeConfig(config, await loadConfigFile(file, env)); + } + return normalizeConfig(config); +}; diff --git a/apps/cli/src/config/merge.ts b/apps/cli/src/config/merge.ts new file mode 100644 index 000000000..8007fe852 --- /dev/null +++ b/apps/cli/src/config/merge.ts @@ -0,0 +1,63 @@ +import type { UserConfig } from "./user.js"; + +/** + * Checks if a value is a plain object, and not an array, a class instance or + * any other exotic object. + */ +const isPlainObject = (value: unknown): value is Record => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const merge = ( + base: Record, + other: Record, +): Record => { + const result: Record = { ...base }; + + for (const [key, otherValue] of Object.entries(other)) { + if (otherValue === undefined) { + // an undefined value does not override anything, so a partial + // configuration can be built with optional fields left out + continue; + } + + const baseValue = result[key]; + if (isPlainObject(baseValue) && isPlainObject(otherValue)) { + result[key] = merge(baseValue, otherValue); + } else { + // arrays and scalars are replaced, not concatenated + result[key] = otherValue; + } + } + + return result; +}; + +/** + * Deep merge two configurations, with the values of `other` taking precedence + * over the ones of `base`. Objects are merged recursively, while arrays and + * scalars are replaced. Useful to share a base configuration between + * applications, or to layer environment specific overrides: + * + * ```ts + * export default defineConfig( + * mergeConfig(base, { machine: { ramLength: "256Mi" } }), + * ); + * ``` + * + * @param base base configuration + * @param other configuration merged on top of `base` + * @returns a new merged configuration + */ +export const mergeConfig = ( + base: T, + other: UserConfig, +): T => + merge( + base as Record, + other as Record, + ) as T; diff --git a/apps/cli/src/config/normalize.ts b/apps/cli/src/config/normalize.ts new file mode 100644 index 000000000..23f7b61dd --- /dev/null +++ b/apps/cli/src/config/normalize.ts @@ -0,0 +1,508 @@ +import { type Address, getAddress, isAddress } from "viem"; +import { + DuplicateLabelError, + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBuilderError, + InvalidDriveFormatError, + InvalidEmptyDriveFormatError, + InvalidEnumValueError, + InvalidEnvError, + InvalidNumberValueError, + InvalidNvramSizeError, + InvalidSectionError, + InvalidStringArrayError, + InvalidStringValueError, + MissingNvramSourceError, + RequiredFieldError, + TooManyNvramsError, +} from "./errors.js"; +import { parseBytes, parseSize } from "./size.js"; +import { + type AccountsDriveMarker, + type Builder, + type Config, + DEFAULT_BLOCKS, + DEFAULT_FORMAT, + DEFAULT_RAM, + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + type DefaultBlock, + defaultRootDriveConfig, + type DriveConfig, + type DriveFormat, + getDriveFormat, + type MachineConfig, + MAX_NVRAMS, + NVRAM_ALIGNMENT, + type NvramConfig, + type RunConfig, + type WithdrawalOptions, +} from "./types.js"; +import { assertAccountSize, assertAccountsDrive } from "./validate.js"; + +/** + * Validation and defaulting of a configuration written by a developer, for + * every format but the deprecated TOML one. + */ + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const asRecord = (section: string, value: unknown): Record => { + if (value === undefined || value === null) { + return {}; + } + if (!isRecord(value)) { + throw new InvalidSectionError(section, value); + } + return value; +}; + +const asBoolean = (value: unknown, defaultValue: boolean): boolean => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +const asOptionalBoolean = (value: unknown): boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +const asString = (value: unknown, defaultValue: string): string => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asOptionalString = (value: unknown): string | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asRequiredString = (value: unknown, key: string): string => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asOptionalStringBoolean = ( + value: unknown, +): string | boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string" || typeof value === "boolean") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const asStringArray = (value: unknown): string[] => { + if (value === undefined) { + return []; + } + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.map((v) => { + if (typeof v === "string") { + return v; + } + throw new InvalidStringValueError(v); + }); + } + throw new InvalidStringArrayError(); +}; + +const asOptionalStringArray = (value: unknown): string[] | undefined => + value === undefined ? undefined : asStringArray(value); + +/** + * Coerce a table of environment variables into a record of strings, since the + * value of an environment variable is always a string. + */ +const asStringRecord = (value: unknown): Record => { + if (value === undefined) { + return {}; + } + if (!isRecord(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 asOptionalNumber = (value: unknown, key?: string): number | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "bigint") { + return Number(value); + } + throw new InvalidNumberValueError(value, key); +}; + +const asOptionalBigInt = (value: unknown): bigint | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number" && Number.isInteger(value)) { + return BigInt(value); + } + throw new InvalidNumberValueError(value); +}; + +const asOptionalAddress = ( + value: unknown, + key: string, +): Address | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string" && isAddress(value)) { + return getAddress(value); + } + throw new InvalidAddressValueError(value, key); +}; + +const asBuilder = (value: unknown): Builder => { + if (value === undefined) { + return "docker"; + } + if (typeof value === "string") { + switch (value) { + case "directory": + case "docker": + case "empty": + case "none": + case "tar": + return value; + } + } + throw new InvalidBuilderError(value); +}; + +const asFormat = (value: unknown): DriveFormat => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (value === "ext2" || value === "sqfs") { + return value; + } + throw new InvalidDriveFormatError(value); +}; + +const asEmptyFormat = (value: unknown): "ext2" | "raw" => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (value === "ext2" || value === "raw") { + return value; + } + throw new InvalidEmptyDriveFormatError(value); +}; + +/** + * Reads the keys that mark a drive or nvram as the accounts drive, leaving them + * out when unset, so they do not show up as explicit `undefined`. + */ +const normalizeAccountsDriveMarker = ( + drive: Record, +): AccountsDriveMarker => { + const accountsDrive = asOptionalBoolean(drive.accountsDrive); + const accountsDriveSize = parseSize(drive.accountsDriveSize); + return { + ...(accountsDrive !== undefined && { accountsDrive }), + ...(accountsDriveSize !== undefined && { accountsDriveSize }), + }; +}; + +const normalizeDrive = (value: unknown): DriveConfig => { + const drive = asRecord("drive", value); + const marker = normalizeAccountsDriveMarker(drive); + const mount = asOptionalStringBoolean(drive.mount); + const shared = asOptionalBoolean(drive.shared); + const user = asOptionalString(drive.user); + + switch (asBuilder(drive.builder)) { + case "directory": + return { + ...marker, + builder: "directory", + directory: asRequiredString(drive.directory, "directory"), + extraSize: parseBytes(drive.extraSize, 0), + format: asFormat(drive.format), + mount, + shared, + user, + }; + case "docker": + return { + ...marker, + builder: "docker", + buildArgs: asStringArray(drive.buildArgs), + context: asString(drive.context, "."), + dockerfile: asString(drive.dockerfile, "Dockerfile"), + extraSize: parseBytes(drive.extraSize, 0), + format: asFormat(drive.format), + image: asOptionalString(drive.image), + mount, + shared, + tags: asStringArray(drive.tags), + target: asOptionalString(drive.target), + user, + }; + case "empty": + return { + ...marker, + builder: "empty", + format: asEmptyFormat(drive.format), + mount, + shared, + size: parseBytes(drive.size, 0), + user, + }; + case "tar": + return { + ...marker, + builder: "tar", + extraSize: parseBytes(drive.extraSize, 0), + filename: asRequiredString(drive.filename, "filename"), + format: asFormat(drive.format), + mount, + shared, + user, + }; + case "none": { + const filename = asRequiredString(drive.filename, "filename"); + return { + ...marker, + builder: "none", + filename, + // the format of an existing drive comes from its extension, + // unless it is explicitly given + format: + drive.format === undefined + ? getDriveFormat(filename) + : asFormat(drive.format), + mount, + shared, + user, + }; + } + } +}; + +const normalizeDrives = (value: unknown): Record => { + const drives = Object.entries(asRecord("drives", value)).reduce< + Record + >((acc, [name, drive]) => { + acc[name] = normalizeDrive(drive); + return acc; + }, {}); + + if (drives.root === undefined) { + // every machine needs a root drive, add a default one + drives.root = defaultRootDriveConfig(); + } + return drives; +}; + +const normalizeNvram = (label: string, value: unknown): NvramConfig => { + const nvram = asRecord("nvram", value); + const size = parseSize(nvram.size); + const filename = asOptionalString(nvram.filename); + + if (size === undefined && filename === undefined) { + throw new MissingNvramSourceError(label); + } + if (size !== undefined && (size <= 0 || size % NVRAM_ALIGNMENT !== 0)) { + throw new InvalidNvramSizeError(label, size); + } + + return { + ...normalizeAccountsDriveMarker(nvram), + filename, + size, + shared: asOptionalBoolean(nvram.shared), + user: asOptionalString(nvram.user), + }; +}; + +const normalizeNvrams = (value: unknown): Record => { + const entries = Object.entries(asRecord("nvrams", value)); + if (entries.length > MAX_NVRAMS) { + throw new TooManyNvramsError(entries.length); + } + return entries.reduce>( + (acc, [label, nvram]) => { + acc[label] = normalizeNvram(label, nvram); + return acc; + }, + {}, + ); +}; + +const normalizeMachine = (value: unknown): MachineConfig => { + const machine = asRecord("machine", value); + return { + assertRollingTemplate: asOptionalBoolean(machine.assertRollingTemplate), + bootargs: asStringArray(machine.bootargs), + entrypoint: asOptionalString(machine.entrypoint), + env: asStringRecord(machine.env), + envFile: asOptionalString(machine.envFile), + maxMCycle: asOptionalBigInt(machine.maxMCycle), + ramLength: asString(machine.ramLength, DEFAULT_RAM), + ramImage: asOptionalString(machine.ramImage), + useDockerEnv: asBoolean(machine.useDockerEnv, true), + useDockerWorkdir: asBoolean(machine.useDockerWorkdir, true), + user: asOptionalString(machine.user), + }; +}; + +const asDefaultBlock = (value: unknown): DefaultBlock | undefined => { + if (value === undefined) { + return undefined; + } + if (DEFAULT_BLOCKS.includes(value as DefaultBlock)) { + return value as DefaultBlock; + } + throw new InvalidEnumValueError("defaultBlock", value, DEFAULT_BLOCKS); +}; + +const normalizeRun = (value: unknown): RunConfig => { + const run = asRecord("run", value); + return { + blockTime: asOptionalNumber(run.blockTime, "blockTime"), + claimStagingPeriod: asOptionalNumber( + run.claimStagingPeriod, + "claimStagingPeriod", + ), + cpus: asOptionalNumber(run.cpus, "cpus"), + defaultBlock: asDefaultBlock(run.defaultBlock), + epochLength: asOptionalNumber(run.epochLength, "epochLength"), + forkBlockNumber: asOptionalNumber( + run.forkBlockNumber, + "forkBlockNumber", + ), + forkUrl: asOptionalString(run.forkUrl), + memory: asOptionalNumber(run.memory, "memory"), + port: asOptionalNumber(run.port, "port"), + projectName: asOptionalString(run.projectName), + prt: asOptionalBoolean(run.prt), + runtimeVersion: asOptionalString(run.runtimeVersion), + services: asOptionalStringArray(run.services), + verbose: asOptionalBoolean(run.verbose), + }; +}; + +const normalizeWithdrawal = (value: unknown): WithdrawalOptions | undefined => { + if (value === undefined || value === null) { + return undefined; + } + const withdrawal = asRecord("withdrawal", value); + return { + guardian: asOptionalAddress(withdrawal.guardian, "guardian"), + withdrawal_output_builder: asOptionalAddress( + withdrawal.withdrawal_output_builder, + "withdrawal_output_builder", + ), + account_size: + withdrawal.account_size === undefined + ? undefined + : assertAccountSize( + parseSize(withdrawal.account_size), + withdrawal.account_size, + ), + }; +}; + +/** + * Validate a configuration written by a developer and resolve it into the + * fully defaulted {@link Config} the rest of the CLI works with. + * + * The input is deliberately typed as `unknown`: a `cartesi.config.ts` file is + * type checked at author time, but a `cartesi.config.json` or + * `cartesi.config.yaml` file is not, so every field is validated here. + * + * An already resolved {@link Config} is a valid input, and normalizing it again + * yields an equivalent configuration. + * + * @param input configuration as written, or `undefined` for the defaults + * @returns resolved application configuration + */ +export const normalizeConfig = (input: unknown): Config => { + const input_ = asRecord("config", input); + + const drives = normalizeDrives(input_.drives); + const nvrams = normalizeNvrams(input_.nvrams); + + // drives and nvrams share the DTB /aliases namespace, so labels cannot collide + for (const label of Object.keys(nvrams)) { + if (drives[label] !== undefined) { + throw new DuplicateLabelError(label); + } + } + + const config: Config = { + drives, + machine: normalizeMachine(input_.machine), + nvrams, + run: normalizeRun(input_.run), + sdk: asString( + input_.sdk, + `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, + ), + withdrawal: normalizeWithdrawal(input_.withdrawal), + }; + + assertAccountsDrive(config); + + return config; +}; diff --git a/apps/cli/src/config/size.ts b/apps/cli/src/config/size.ts new file mode 100644 index 000000000..58da02d7f --- /dev/null +++ b/apps/cli/src/config/size.ts @@ -0,0 +1,68 @@ +import { InvalidBytesValueError } from "./errors.js"; + +/** + * Sizes of drives and nvrams, shared by every configuration format. + */ + +const IEC_MULTIPLIERS: Record = { + "": 1, + b: 1, + k: 1024, + ki: 1024, + kb: 1024, + kib: 1024, + m: 1024 ** 2, + mi: 1024 ** 2, + mb: 1024 ** 2, + mib: 1024 ** 2, + g: 1024 ** 3, + gi: 1024 ** 3, + gb: 1024 ** 3, + gib: 1024 ** 3, + t: 1024 ** 4, + ti: 1024 ** 4, + tb: 1024 ** 4, + tib: 1024 ** 4, + p: 1024 ** 5, + pi: 1024 ** 5, + pb: 1024 ** 5, + pib: 1024 ** 5, +}; + +/** + * Parses a byte size of a drive or nvram, accepting the IEC suffixes used by cartesi-machine + * ("4Ki", "1MiB") and their decimal spellings ("4kb", "100Mb"), all as powers of 1024, up to + * petabytes. Sizes are numbers, so the ones beyond 2^53 bytes, which they can't hold exactly, + * are rejected. + */ +export const parseSize = (value: unknown): number | undefined => { + if (value === undefined) { + return undefined; + } + let size: number | undefined; + if (typeof value === "number") { + size = value; + } else if (typeof value === "bigint") { + size = Number(value); + } else if (typeof value === "string") { + const match = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(value); + const multiplier = match + ? IEC_MULTIPLIERS[match[2].toLowerCase()] + : undefined; + if (match && multiplier !== undefined) { + size = Number(match[1]) * multiplier; + } + } + // a whole number of bytes, e.g. "1.5Ki" is but "1.1Ki" isn't + if ( + size !== undefined && + Number.isInteger(size) && + size <= Number.MAX_SAFE_INTEGER + ) { + return size; + } + throw new InvalidBytesValueError(value); +}; + +export const parseBytes = (value: unknown, defaultValue: number): number => + parseSize(value) ?? defaultValue; diff --git a/apps/cli/src/config/toml.ts b/apps/cli/src/config/toml.ts new file mode 100644 index 000000000..cd9b5302e --- /dev/null +++ b/apps/cli/src/config/toml.ts @@ -0,0 +1,555 @@ +import { parse as parseToml, type TomlPrimitive } from "smol-toml"; +import { type Address, getAddress, isAddress } from "viem"; +import { + DuplicateLabelError, + formatValue, + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBuilderError, + InvalidDriveFormatError, + InvalidEmptyDriveFormatError, + InvalidEnvError, + InvalidNumberValueError, + InvalidNvramSizeError, + InvalidStringArrayError, + InvalidStringValueError, + InvalidWithdrawalConfigError, + MissingNvramSourceError, + RequiredFieldError, + TooManyNvramsError, +} from "./errors.js"; +import { parseBytes, parseSize } from "./size.js"; +import { + type AccountsDriveMarker, + type Builder, + type Config, + DEFAULT_FORMAT, + DEFAULT_RAM, + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + defaultMachineConfig, + defaultRootDriveConfig, + defaultRunConfig, + type DriveConfig, + type DriveFormat, + getDriveFormat, + type MachineConfig, + MAX_NVRAMS, + NVRAM_ALIGNMENT, + type NvramConfig, + type WithdrawalOptions, +} from "./types.js"; +import { assertAccountSize, assertAccountsDrive } from "./validate.js"; + +/** + * Parser of the deprecated `cartesi.toml` configuration file. + * + * This format is superseded by `cartesi.config.ts` (and its plain data + * equivalents), which is why it is kept apart here: it uses snake_case keys + * and has no `[run]` section. It is still fully supported, so existing + * applications keep building. + */ + +type TomlTable = { [key: string]: TomlPrimitive }; + +const parseBoolean = (value: TomlPrimitive, defaultValue: boolean): boolean => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +const parseOptionalBoolean = (value: TomlPrimitive): boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidBooleanValueError(value); +}; + +const parseString = (value: TomlPrimitive, defaultValue: string): string => { + if (value === undefined) { + return defaultValue; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const parseStringArray = (value: TomlPrimitive): string[] => { + if (value === undefined) { + return []; + } + if (typeof value === "string") { + return [value]; + } + if (typeof value === "object" && Array.isArray(value)) { + return value.map((v) => { + if (typeof v === "string") { + return v; + } + throw new InvalidStringValueError(v); + }); + } + 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); + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const parseRequiredAddress = (value: TomlPrimitive, key: string): Address => { + if (value === undefined) { + throw new RequiredFieldError(key); + } + if (typeof value === "string" && isAddress(value)) { + return getAddress(value); + } + throw new InvalidAddressValueError(value, key); +}; + +const parseOptionalString = (value: TomlPrimitive): string | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const parseOptionalStringBoolean = ( + value: TomlPrimitive, +): string | boolean | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "boolean") { + return value; + } + throw new InvalidStringValueError(value); +}; + +const parseOptionalNumber = (value: TomlPrimitive): bigint | undefined => { + if (value === undefined) { + return undefined; + } + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number") { + return BigInt(value); + } + throw new InvalidNumberValueError(value); +}; + +/** + * Parses the keys that mark a drive or nvram as the accounts drive, leaving them out when unset. + */ +const parseAccountsDriveMarker = (toml: TomlTable): AccountsDriveMarker => { + const accountsDrive = parseOptionalBoolean(toml.accounts_drive); + const accountsDriveSize = parseSize(toml.accounts_drive_size); + return { + ...(accountsDrive !== undefined && { accountsDrive }), + ...(accountsDriveSize !== undefined && { accountsDriveSize }), + }; +}; + +const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => { + const toml = isTomlTable(value) ? value : {}; + const size = parseSize(toml.size); + const filename = parseOptionalString(toml.filename); + + if (size === undefined && filename === undefined) { + throw new MissingNvramSourceError(label); + } + if (size !== undefined && (size <= 0 || size % NVRAM_ALIGNMENT !== 0)) { + throw new InvalidNvramSizeError(label, size); + } + + return { + ...parseAccountsDriveMarker(toml), + filename, + size, + shared: parseOptionalBoolean(toml.shared), + user: parseOptionalString(toml.user), + }; +}; + +const parseNvrams = (config: TomlPrimitive): Record => { + const entries = Object.entries((config as TomlTable) ?? {}); + if (entries.length > MAX_NVRAMS) { + throw new TooManyNvramsError(entries.length); + } + return entries.reduce>( + (acc, [label, nvram]) => { + acc[label] = parseNvram(label, nvram); + return acc; + }, + {}, + ); +}; + +const parseBuilder = (value: TomlPrimitive): Builder => { + if (value === undefined) { + return "docker"; + } + if (typeof value === "string") { + switch (value) { + case "directory": + return "directory"; + case "docker": + return "docker"; + case "empty": + return "empty"; + case "none": + return "none"; + case "tar": + return "tar"; + } + } + throw new InvalidBuilderError(value); +}; + +const parseFormat = (value: TomlPrimitive): DriveFormat => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (typeof value === "string") { + switch (value) { + case "ext2": + return "ext2"; + case "sqfs": + return "sqfs"; + } + } + throw new InvalidDriveFormatError(value); +}; + +const parseEmptyFormat = (value: TomlPrimitive): "ext2" | "raw" => { + if (value === undefined) { + return DEFAULT_FORMAT; + } + if (typeof value === "string") { + switch (value) { + case "ext2": + return "ext2"; + case "raw": + return "raw"; + } + } + throw new InvalidEmptyDriveFormatError(value); +}; + +const parseMachine = (value: TomlPrimitive): MachineConfig => { + if (value === undefined) { + // default machine + return defaultMachineConfig(); + } + if (typeof value !== "object") { + throw new Error(`Invalid machine configuration: ${value}`); + } + const toml = value as TomlTable; + + return { + assertRollingTemplate: parseOptionalBoolean( + toml.assert_rolling_template, + ), + 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), + useDockerEnv: parseBoolean(toml.use_docker_env, true), + useDockerWorkdir: parseBoolean(toml.use_docker_workdir, true), + user: parseOptionalString(toml.user), + }; +}; + +const parseDrive = (drive: TomlPrimitive): DriveConfig => { + const toml = drive as TomlTable; + return { ...parseBuilderDrive(toml), ...parseAccountsDriveMarker(toml) }; +}; + +const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => { + const builder = parseBuilder((drive as TomlTable).builder); + switch (builder) { + case "directory": { + const { extra_size, format, mount, directory, shared, user } = + drive as TomlTable; + return { + builder: "directory", + extraSize: parseBytes(extra_size, 0), + format: parseFormat(format), + mount: parseOptionalStringBoolean(mount), + directory: parseRequiredString(directory, "directory"), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + }; + } + case "docker": { + const { + build_args, + context, + dockerfile, + extra_size, + format, + image, + mount, + shared, + tags, + target, + user, + } = drive as TomlTable; + return { + builder: "docker", + buildArgs: parseStringArray(build_args), + image: parseOptionalString(image), + context: parseString(context, "."), + dockerfile: parseString(dockerfile, "Dockerfile"), + extraSize: parseBytes(extra_size, 0), + format: parseFormat(format), + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + tags: parseStringArray(tags), + target: parseOptionalString(target), + }; + } + case "empty": { + const { format, mount, size, shared, user } = drive as TomlTable; + return { + builder: "empty", + format: parseEmptyFormat(format), + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + size: parseBytes(size, 0), + user: parseOptionalString(user), + }; + } + case "tar": { + const { extra_size, filename, format, mount, shared, user } = + drive as TomlTable; + return { + builder: "tar", + extraSize: parseBytes(extra_size, 0), + filename: parseRequiredString(filename, "filename"), + format: parseFormat(format), + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + }; + } + case "none": { + const { shared, mount, user } = drive as TomlTable; + const filename = parseRequiredString( + (drive as TomlTable).filename, + "filename", + ); + const format = getDriveFormat(filename); + return { + builder: "none", + filename, + format, + mount: parseOptionalStringBoolean(mount), + shared: parseOptionalBoolean(shared), + user: parseOptionalString(user), + }; + } + } +}; + +const parseDrives = (config: TomlPrimitive): Record => { + // load drives from configuration + const drives = Object.entries((config as TomlTable) ?? {}).reduce< + Record + >((acc, [name, drive]) => { + acc[name] = parseDrive(drive); + return acc; + }, {}); + + // check if there is a root drive + const hasRoot = drives.root !== undefined; + if (!hasRoot) { + // there is no root drive, add a default one + drives.root = defaultRootDriveConfig(); + } + return drives; +}; + +const parseAccountSize = (value: TomlPrimitive): number => + assertAccountSize(parseSize(value), value); + +/** + * Parses [withdrawal], which only applies to an application with an accounts drive. Like the + * other tables, it reads the keys it uses and leaves any other key alone. + */ +const parseOptionalWithdrawal = ( + withdrawal: TomlPrimitive, +): WithdrawalOptions | undefined => { + if (withdrawal === undefined) { + return undefined; + } + if (!isTomlTable(withdrawal)) { + throw new InvalidWithdrawalConfigError( + `expected a table, got ${formatValue(withdrawal)}`, + ); + } + + const optional = ( + key: string, + parser: (value: TomlPrimitive, key: string) => T, + ): T | undefined => + withdrawal[key] === undefined + ? undefined + : parser(withdrawal[key], key); + + const options: WithdrawalOptions = { + guardian: optional("guardian", parseRequiredAddress), + withdrawal_output_builder: optional( + "withdrawal_output_builder", + parseRequiredAddress, + ), + account_size: optional("account_size", parseAccountSize), + }; + return options; +}; + +export const parse = (str: string[]): Config => { + let toml: TomlTable = {}; + for (const s of str) { + toml = mergeTomlTables(toml, parseToml(s)); + } + + const drives = parseDrives(toml.drives); + const nvrams = parseNvrams(toml.nvrams); + + // drives and nvrams share the DTB /aliases namespace, so labels cannot collide + for (const label of Object.keys(nvrams)) { + if (drives[label] !== undefined) { + throw new DuplicateLabelError(label); + } + } + + const config: Config = { + withdrawal: parseOptionalWithdrawal(toml.withdrawal), + drives, + machine: parseMachine(toml.machine), + nvrams, + run: defaultRunConfig(), + sdk: parseString( + toml.sdk, + `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, + ), + }; + + assertAccountsDrive(config); + + return config; +}; + +/** + * Checks if a value is a plain object (TOML table) + */ +function isTomlTable(value: TomlPrimitive): value is TomlTable { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !("toISOString" in value) + ); // Check for TomlDate (has toISOString method) +} + +/** + * Recursively merges two TOML table objects + * Values from 'other' take precedence over 'base' + * + * @param base - The base TOML table + * @param other - The TOML table to merge into base (takes precedence) + * @returns A new merged TOML table + */ +export function mergeTomlTables(base: TomlTable, other: TomlTable): TomlTable { + const result: TomlTable = { ...base }; + + for (const [key, otherValue] of Object.entries(other)) { + const baseValue = result[key]; + + // If both values are tables, merge them recursively + if (isTomlTable(baseValue) && isTomlTable(otherValue)) { + result[key] = mergeTomlTables(baseValue, otherValue); + } else { + // For all other cases, other value takes precedence + result[key] = otherValue; + } + } + + return result; +} + +/** + * Merges two TOML values of any type + * + * @param base - The base TOML value + * @param other - The TOML value to merge into base (takes precedence) + * @returns The merged TOML value + */ +export function mergeTomlValues( + base: TomlPrimitive, + other: TomlPrimitive, +): TomlPrimitive { + // If both are tables, merge recursively + if (isTomlTable(base) && isTomlTable(other)) { + return mergeTomlTables(base, other); + } + + // For arrays, replaces entirely + return other; +} diff --git a/apps/cli/src/config/types.ts b/apps/cli/src/config/types.ts new file mode 100644 index 000000000..c6239c6d8 --- /dev/null +++ b/apps/cli/src/config/types.ts @@ -0,0 +1,430 @@ +import { extname } from "node:path"; +import type { Address } from "viem"; +import { + InvalidAccountsDriveError, + InvalidDriveFormatError, +} from "./errors.js"; +export { MAX_NVRAMS, NVRAM_ALIGNMENT } from "./limits.js"; + +/** + * Resolved configuration of an application. This is the fully normalized and + * validated shape the rest of the CLI works with, produced from a + * {@link UserConfig} written in a configuration file of any of the supported + * formats, or built programmatically. + */ + +/** + * Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder + */ +export const DEFAULT_FORMAT = "ext2"; +export const DEFAULT_RAM = "128Mi"; +export const DEFAULT_SDK_VERSION = "0.12.0-alpha.44"; +export const DEFAULT_SDK_IMAGE = "cartesi/sdk"; +export const PREFERRED_PORT = 6751; + +export type Builder = "directory" | "docker" | "empty" | "none" | "tar"; +export type DriveFormat = "ext2" | "sqfs"; + +export type ImageInfo = { + cmd: string[]; + entrypoint: string[]; + env: string[]; + workdir: string; +}; + +export type DriveResult = ImageInfo | undefined; + +export type DirectoryDriveConfig = { + builder: "directory"; + extraSize: number; // default is 0 (no extra size) + format: DriveFormat; + directory: string; // required +}; + +export type DockerDriveConfig = { + builder: "docker"; + buildArgs: string[]; // default is empty array + context: string; + dockerfile: string; + extraSize: number; // default is 0 (no extra size) + format: DriveFormat; + image?: string; // default is to build an image from a Dockerfile + tags: string[]; // default is empty array + target?: string; // default is last stage of multi-stage +}; + +export type EmptyDriveConfig = { + builder: "empty"; + format: "ext2" | "raw"; + size: number; // in bytes +}; + +export type ExistingDriveConfig = { + builder: "none"; + filename: string; // required + format: DriveFormat; +}; + +export type TarDriveConfig = { + builder: "tar"; + filename: string; // required + format: DriveFormat; + extraSize: number; // default is 0 (no extra size) +}; + +/** + * Marks the drive or nvram that holds the accounts of emergency withdrawal, whose layout is + * derived from the built machine. + */ +export type AccountsDriveMarker = { + accountsDrive?: boolean; // holds the accounts of emergency withdrawal + accountsDriveSize?: number; // accounts only in this many bytes at the beginning, default is all of it +}; + +export type DriveConfig = ( + | DirectoryDriveConfig + | DockerDriveConfig + | EmptyDriveConfig + | ExistingDriveConfig + | TarDriveConfig +) & + AccountsDriveMarker & { + mount?: string | boolean; // default given by cartesi-machine + shared?: boolean; // default given by cartesi-machine + user?: string; // default given by cartesi-machine + }; + +/** + * Configuration for an NVRAM of a Cartesi Machine. Unlike a flash drive, an nvram is a raw + * range of bytes exposed to the guest as a /dev/uio* device, with no filesystem and no mount + * point. Either `size` or `filename` must be defined. + */ +export type NvramConfig = AccountsDriveMarker & { + filename?: string; // path to an existing raw image with the initial contents + size?: number; // in bytes, a positive multiple of 4Ki + shared?: boolean; // default given by cartesi-machine + user?: string; // default given by cartesi-machine +}; + +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 + useDockerEnv: boolean; // inject docker image ENV into cartesi-machine ENV + useDockerWorkdir: boolean; // inject docker image WORKDIR into cartesi-machine WORKDIR + user?: string; // default given by cartesi-machine +}; + +/** + * Configuration for Emergercy-withdrawals that will be passed down to the + * cartesi-rollups-cli. This is a All or nothing kind of configuration. + * The properties are kept snake_case to match the expected input in the cartesi-rollups-cli. + */ +export type WithdrawalConfig = { + guardian: Address; + log2_leaves_per_account: number; + log2_max_num_of_accounts: number; + accounts_drive_start_index: number; + withdrawal_output_builder: Address; +}; + +/** + * The optional [withdrawal] section of an application with an accounts drive. The accounts + * drive layout is derived from the built machine; these keys override the defaults. + */ +export type WithdrawalOptions = { + guardian?: Address; // default is a devnet account + withdrawal_output_builder?: Address; // default is the devnet TestUsdWithdrawalOutputBuilder + account_size?: number; // in bytes, default is 32 +}; + +/** Block used by the node when fetching new blocks. */ +export type DefaultBlock = "latest" | "safe" | "pending" | "finalized"; + +export const DEFAULT_BLOCKS: readonly DefaultBlock[] = [ + "latest", + "safe", + "pending", + "finalized", +]; + +/** + * Configuration of the local development environment started by `cartesi run`. + * + * Every field is optional: it defines a project level default for the + * equivalent command line option, which always takes precedence when given. + */ +export type RunConfig = { + /** + * Interval between blocks, in seconds. + * @default 2 + */ + blockTime?: number; + + /** + * Number of blocks between a claim being submitted and accepted + * (Authority/Quorum only). + * @default 0 + */ + claimStagingPeriod?: number; + + /** Number of cpu limits for the rollups-node. */ + cpus?: number; + + /** + * Block used when fetching new blocks. + * @default "latest" + */ + defaultBlock?: DefaultBlock; + + /** + * Length of an epoch, in blocks. + * @default 720 + */ + epochLength?: number; + + /** Block number to fork from. */ + forkBlockNumber?: number; + + /** RPC URL to fork from. */ + forkUrl?: string; + + /** Memory limit for the rollups-node, in MB. */ + memory?: number; + + /** + * Port to listen on. + * @default first free port from 6751 + */ + port?: number; + + /** + * Name of the project, used by docker compose and by the rollups node. + * @default basename of the current working directory + */ + projectName?: string; + + /** + * Deploy the application with PRT consensus. + * @default false + */ + prt?: boolean; + + /** Version of the Cartesi Rollups Runtime to use. */ + runtimeVersion?: string; + + /** + * Optional services to start. The single value `all` starts every optional + * service. + * @default [] + */ + services?: string[]; + + /** + * Increase the log level of the environment services. + * @default false + */ + verbose?: boolean; +}; + +export type Config = { + drives: Record; + machine: MachineConfig; + nvrams: Record; + run: RunConfig; + sdk: string; + withdrawal?: WithdrawalOptions; +}; + +export const LOG2_LEAF_SIZE = 5; // accounts are made of 32-byte leaves + +/** + * Number of 32-byte leaves of each account, in log2, from `account_size`. Defaults to one leaf. + */ +export const getLog2LeavesPerAccount = (options?: WithdrawalOptions): number => + options?.account_size !== undefined + ? Math.log2(options.account_size) - LOG2_LEAF_SIZE // a power of two, so exact + : 0; + +/** + * The drive or nvram marked with `accounts_drive = true`. + */ +export type AccountsDrive = { + kind: "flash_drive" | "nvram"; // section of the stored machine config that holds it + label: string; + config: DriveConfig | NvramConfig; // its entry in cartesi.toml +}; + +/** + * Every drive and nvram marked with `accounts_drive = true`. Parsing rejects more than one, + * and lists them all to say which. + */ +export const getMarkedDrives = (config: Config): AccountsDrive[] => + [ + ...Object.entries(config.drives).map(([label, drive]) => ({ + kind: "flash_drive" as const, + label, + config: drive, + })), + ...Object.entries(config.nvrams).map(([label, nvram]) => ({ + kind: "nvram" as const, + label, + config: nvram, + })), + ].filter((drive) => drive.config.accountsDrive); + +export const getAccountsDrive = (config: Config): AccountsDrive | undefined => + getMarkedDrives(config)[0]; + +/** + * Splits a number of bytes into the largest IEC unit that divides it exactly, e.g. 4194304 is + * 4 "Mi", and 1000 is 1000 "". + */ +export const splitSize = (bytes: number): { value: number; unit: string } => { + let value = bytes; + let unit = ""; + for (const next of ["Ki", "Mi", "Gi", "Ti"]) { + if (value === 0 || value % 1024 !== 0) { + break; + } + value /= 1024; + unit = next; + } + return { value, unit }; +}; + +/** + * Formats a number of bytes as a size cartesi.toml accepts, e.g. "4Mi". + */ +export const formatSize = (bytes: number): string => { + const { value, unit } = splitSize(bytes); + return `${value}${unit}`; +}; + +/** + * The log2 of a positive power of two, or undefined for any other number. It reads the bits, + * which is exact for every safe integer, where `&` wraps at 32 bits and Math.log2 rounds near + * 2^53. + */ +export const log2OfPowerOfTwo = (value: number): number | undefined => { + const bits = Number.isInteger(value) ? value.toString(2) : ""; + return /^10*$/.test(bits) ? bits.length - 1 : undefined; +}; + +export const nextPowerOfTwo = (value: number): number => + 2 ** (value - 1).toString(2).length; + +/** + * Checks the accounts drive can be divided into accounts. The accounts drive is either all of + * the drive or nvram marked with `accounts_drive = true`, or its first `accounts_drive_size` + * bytes, the rest being free for other application state. + * @param driveLength length of the whole drive, if known + * @returns the length of the accounts drive and its log2, if known + */ +export const assertAccountsDriveLength = ( + config: Config, + accountsDrive: AccountsDrive, + driveLength?: number, +): { length: number; log2Length: number } | undefined => { + const { label } = accountsDrive; + const { accountsDriveSize } = accountsDrive.config; + const log2LeavesPerAccount = getLog2LeavesPerAccount(config.withdrawal); + const length = accountsDriveSize ?? driveLength; + if (length === undefined) { + return undefined; + } + + const log2Length = log2OfPowerOfTwo(length); + if (log2Length === undefined) { + const suggestion = + length > 0 ? `, use "${formatSize(nextPowerOfTwo(length))}"` : ""; + throw new InvalidAccountsDriveError( + label, + accountsDriveSize !== undefined + ? `accounts_drive_size ${length} is not a power of two${suggestion}` + : `size ${length} is not a power of two${suggestion}, or set accounts_drive_size to keep the accounts at the beginning of a larger drive`, + ); + } + const log2AccountSize = LOG2_LEAF_SIZE + log2LeavesPerAccount; + if (log2Length < log2AccountSize) { + throw new InvalidAccountsDriveError( + label, + `size ${length} is smaller than one account of ${2 ** log2AccountSize} bytes`, + ); + } + if ( + accountsDriveSize !== undefined && + driveLength !== undefined && + accountsDriveSize > driveLength + ) { + throw new InvalidAccountsDriveError( + label, + `accounts_drive_size ${accountsDriveSize} is larger than the drive itself (${driveLength} bytes)`, + ); + } + return { length, log2Length }; +}; + +export const defaultRootDriveConfig = (): DriveConfig => ({ + builder: "docker", + buildArgs: [], + context: ".", + dockerfile: "Dockerfile", // file on current working directory + extraSize: 0, + format: DEFAULT_FORMAT, + tags: [], +}); + +export const defaultMachineConfig = (): MachineConfig => ({ + assertRollingTemplate: undefined, + bootargs: [], + entrypoint: undefined, + env: {}, + envFile: undefined, + maxMCycle: undefined, + ramLength: DEFAULT_RAM, + useDockerEnv: true, + useDockerWorkdir: true, + user: undefined, +}); + +export const defaultRunConfig = (): RunConfig => ({}); + +export const defaultConfig = (): Config => ({ + drives: { root: defaultRootDriveConfig() }, + machine: defaultMachineConfig(), + nvrams: {}, + run: defaultRunConfig(), + sdk: `${DEFAULT_SDK_IMAGE}:${DEFAULT_SDK_VERSION}`, + withdrawal: undefined, +}); + +/** + * Filename, relative to the build destination directory, of the image backing an nvram. + */ +export const nvramImageFilename = (label: string): string => `${label}.raw`; + +/** + * Whether an nvram is backed by an image file. A pristine nvram needs no image, as + * cartesi-machine fills its range with zeros. A `shared` one does, as there must be a file for + * the guest writes to be persisted to. + */ +export const nvramHasImage = (nvram: NvramConfig): boolean => + nvram.filename !== undefined || nvram.shared === true; + +export const getDriveFormat = (filename: string): DriveFormat => { + const extension = extname(filename); + switch (extension) { + case ".ext2": + return "ext2"; + case ".sqfs": + return "sqfs"; + default: + throw new InvalidDriveFormatError(extension); + } +}; diff --git a/apps/cli/src/config/user.ts b/apps/cli/src/config/user.ts new file mode 100644 index 000000000..564b51c53 --- /dev/null +++ b/apps/cli/src/config/user.ts @@ -0,0 +1,344 @@ +import type { Address } from "viem"; +import type { DriveFormat, RunConfig, WithdrawalOptions } from "./types.js"; + +/** + * Input types of the application configuration: the shape a developer actually + * writes, either in a `cartesi.config.ts` file through {@link defineConfig}, or + * in one of the plain data formats (`cartesi.config.json`, + * `cartesi.config.yaml`, `cartesi.config`). + * + * Everything that has a default is optional here. Sizes accept human readable + * strings (`"64Mi"`) as well as a number of bytes. + */ + +/** A size, either a number of bytes or a human readable string like `"64Mi"`. */ +export type Size = number | string; + +/** + * Keys that mark the drive or nvram holding the accounts of emergency + * withdrawal. At most one drive or nvram may carry them. + */ +export type UserAccountsDriveMarker = { + /** Whether this drive or nvram holds the accounts of emergency withdrawal. */ + accountsDrive?: boolean; + + /** + * Keep the accounts in this many bytes at the beginning of the drive, + * leaving the rest for other application state. + * @default all of the drive + */ + accountsDriveSize?: Size; +}; + +/** A drive built from a directory of the host filesystem. */ +export type UserDirectoryDriveConfig = { + builder: "directory"; + + /** Directory to copy into the drive. */ + directory: string; + + /** + * Extra free space to add to the drive, beyond the size of its contents. + * @default 0 + */ + extraSize?: Size; + + /** @default "ext2" */ + format?: DriveFormat; +}; + +/** A drive built from the filesystem of a docker image. */ +export type UserDockerDriveConfig = { + /** @default "docker" */ + builder?: "docker"; + + /** + * Arguments passed to the docker build, as `NAME=value` strings. + * @default [] + */ + buildArgs?: string[]; + + /** @default "." */ + context?: string; + + /** @default "Dockerfile" */ + dockerfile?: string; + + /** + * Extra free space to add to the drive, beyond the size of its contents. + * @default 0 + */ + extraSize?: Size; + + /** @default "ext2" */ + format?: DriveFormat; + + /** Use an existing image instead of building one from a Dockerfile. */ + image?: string; + + /** + * Tags applied to the image built. + * @default [] + */ + tags?: string[]; + + /** Stage to build, defaults to the last stage of a multi-stage build. */ + target?: string; +}; + +/** An empty drive, to be used as writable storage by the application. */ +export type UserEmptyDriveConfig = { + builder: "empty"; + + /** @default "ext2" */ + format?: "ext2" | "raw"; + + /** + * Size of the drive. + * @default 0 + */ + size?: Size; +}; + +/** A drive that already exists as a filesystem image on disk. */ +export type UserExistingDriveConfig = { + builder: "none"; + + /** Name of the drive file, its extension defines the format. */ + filename: string; + + /** @default inferred from the extension of `filename` */ + format?: DriveFormat; +}; + +/** A drive built from the contents of a tar archive. */ +export type UserTarDriveConfig = { + builder: "tar"; + + /** Name of the tar file. */ + filename: string; + + /** + * Extra free space to add to the drive, beyond the size of its contents. + * @default 0 + */ + extraSize?: Size; + + /** @default "ext2" */ + format?: DriveFormat; +}; + +export type UserDriveConfig = ( + | UserDirectoryDriveConfig + | UserDockerDriveConfig + | UserEmptyDriveConfig + | UserExistingDriveConfig + | UserTarDriveConfig +) & + UserAccountsDriveMarker & { + /** Where the drive is mounted inside the machine. */ + mount?: string | boolean; + + /** Whether writes to the drive are visible to the host. */ + shared?: boolean; + + /** User that owns the files of the drive. */ + user?: string; + }; + +/** + * An nvram of the machine: a raw range of bytes exposed to the guest as a + * `/dev/uio*` device, with no filesystem and no mount point. Either `size` or + * `filename` must be given. + */ +export type UserNvramConfig = UserAccountsDriveMarker & { + /** Path of an existing raw image with the initial contents. */ + filename?: string; + + /** Size of the nvram, a positive multiple of 4Ki. */ + size?: Size; + + /** Whether writes by the guest are persisted to the host. */ + shared?: boolean; + + /** User that owns the image of the nvram. */ + user?: string; +}; + +/** Configuration of the Cartesi machine of the application. */ +export type UserMachineConfig = { + assertRollingTemplate?: boolean; + + /** + * Extra arguments appended to the kernel command line. + * @default [] + */ + bootargs?: string[]; + + /** Command the machine runs on boot, overriding the docker image one. */ + entrypoint?: string; + + /** + * Environment variables injected into the machine. Values that are not + * strings are coerced to strings. + * @default {} + */ + env?: Record; + + /** Path of a `.env` file with environment variables injected into the machine. */ + envFile?: string; + + /** Maximum number of machine cycles to run. */ + maxMCycle?: bigint | number; + + /** + * Amount of RAM of the machine. + * @default "128Mi" + */ + ramLength?: string; + + /** Kernel image of the machine. */ + ramImage?: string; + + /** + * Inject the `ENV` of the docker image of the root drive into the machine. + * @default true + */ + useDockerEnv?: boolean; + + /** + * Inject the `WORKDIR` of the docker image of the root drive into the machine. + * @default true + */ + useDockerWorkdir?: boolean; + + /** User the entrypoint runs as. */ + user?: string; +}; + +/** + * Overrides of the emergency withdrawal configuration, which otherwise is + * derived from the accounts drive of the built machine. Only applies to an + * application that marks a drive or an nvram with `accountsDrive: true`. + * + * The keys are the ones of the resolved configuration, which the + * cartesi-rollups-cli expects in snake_case. + */ +export type UserWithdrawalOptions = { + /** @default a devnet account */ + guardian?: Address; + + /** @default the devnet TestUsdWithdrawalOutputBuilder */ + withdrawal_output_builder?: Address; + + /** + * Size of one account, a power of two of at least 32 bytes. + * @default 32 + */ + account_size?: Size; +}; + +/** + * Configuration of an application, as written by a developer. + * + * @see {@link defineConfig} + */ +export type UserConfig = { + /** + * Drives of the Cartesi machine. A `root` drive built from a `Dockerfile` + * of the current directory is used when none is defined. + */ + drives?: Record; + + /** Configuration of the Cartesi machine. */ + machine?: UserMachineConfig; + + /** Nvrams of the Cartesi machine, at most eight. */ + nvrams?: Record; + + /** + * Project level defaults of the local development environment started by + * `cartesi run`. Command line options always take precedence. + */ + run?: RunConfig; + + /** + * Docker image with the build tools used to build the drives and the + * machine. + * @default "cartesi/sdk:" + */ + sdk?: string; + + /** Overrides of the emergency withdrawal configuration. */ + withdrawal?: UserWithdrawalOptions | WithdrawalOptions; +}; + +/** Command being run, given to a configuration file that exports a function. */ +export type ConfigCommand = "build" | "run" | "shell"; + +/** + * Context given to a configuration file that exports a function instead of an + * object, so the configuration can depend on what is being run. + */ +export type ConfigEnv = { + /** Command being run. */ + command: ConfigCommand; + + /** Directory the configuration is being resolved from. */ + cwd: string; + + /** + * Mode the application is being built or run in, from `CARTESI_ENV` or + * `NODE_ENV`. + * @default "development" + */ + mode: string; +}; + +/** A configuration file that computes its configuration from the environment. */ +export type UserConfigFn = (env: ConfigEnv) => UserConfig | Promise; + +/** Anything a configuration file is allowed to export as its default export. */ +export type UserConfigExport = UserConfig | Promise | UserConfigFn; + +/** + * Define the configuration of a Cartesi application. + * + * This is an identity function that exists purely to give a `cartesi.config.ts` + * file type checking and editor completion, without the developer having to + * annotate the exported value: + * + * ```ts + * import { defineConfig } from "@cartesi/cli/config"; + * + * export default defineConfig({ + * drives: { + * root: { builder: "docker", dockerfile: "Dockerfile" }, + * }, + * machine: { ramLength: "256Mi" }, + * run: { epochLength: 10 }, + * }); + * ``` + * + * A function can be exported instead, to configure the application differently + * depending on the command being run: + * + * ```ts + * export default defineConfig(({ command }) => ({ + * run: { epochLength: command === "run" ? 10 : 720 }, + * })); + * ``` + * + * The function may be asynchronous. + * + * @param config configuration object, promise of one, or a function that + * computes one from the {@link ConfigEnv} + * @returns the configuration, unchanged + */ +export function defineConfig(config: UserConfig): UserConfig; +export function defineConfig(config: Promise): Promise; +export function defineConfig(config: UserConfigFn): UserConfigFn; +export function defineConfig(config: UserConfigExport): UserConfigExport; +export function defineConfig(config: UserConfigExport): UserConfigExport { + return config; +} diff --git a/apps/cli/src/config/validate.ts b/apps/cli/src/config/validate.ts new file mode 100644 index 000000000..bd2e4af45 --- /dev/null +++ b/apps/cli/src/config/validate.ts @@ -0,0 +1,99 @@ +import { + formatValue, + InvalidAccountsDriveError, + InvalidWithdrawalConfigError, + MultipleAccountsDrivesError, +} from "./errors.js"; +import { + assertAccountsDriveLength, + type Config, + getMarkedDrives, + LOG2_LEAF_SIZE, + log2OfPowerOfTwo, +} from "./types.js"; + +/** + * Validation that spans several sections of the configuration, and so applies + * to every format it can be written in. + */ + +/** + * Checks `account_size`, which divides the accounts drive into accounts and so + * must be a power of two of at least one 32-byte leaf. + * @param size parsed size, or `undefined` when it could not be read + * @param value size as written, for the error message + * @returns the account size in bytes + */ +export const assertAccountSize = ( + size: number | undefined, + value: unknown, +): number => { + if ( + size === undefined || + size < 2 ** LOG2_LEAF_SIZE || + log2OfPowerOfTwo(size) === undefined + ) { + throw new InvalidWithdrawalConfigError( + `account_size ${formatValue(value)} must be a power of two of at least 32 bytes, such as 32 or 64`, + ); + } + return size; +}; + +/** + * Checks what can be known about the accounts drive before the machine is built. Its placement + * is only known after, see `resolveAccountsDriveLayout`. + */ +export const assertAccountsDrive = (config: Config) => { + const marked = getMarkedDrives(config); + if (marked.length > 1) { + throw new MultipleAccountsDrivesError(marked.map(({ label }) => label)); + } + + for (const [label, drive] of [ + ...Object.entries(config.drives), + ...Object.entries(config.nvrams), + ]) { + if (drive.accountsDriveSize !== undefined && !drive.accountsDrive) { + throw new InvalidAccountsDriveError( + label, + "accounts_drive_size requires accounts_drive = true", + ); + } + } + + const [accountsDrive] = marked; + if (!accountsDrive) { + if (config.withdrawal) { + throw new InvalidWithdrawalConfigError( + "emergency withdrawal needs an accounts drive, mark a raw drive or an nvram with accounts_drive = true", + ); + } + return; + } + + const { label } = accountsDrive; + + let size: number | undefined; + if (accountsDrive.kind === "flash_drive") { + const drive = config.drives[label]; + if (drive.builder !== "empty" || drive.format !== "raw") { + throw new InvalidAccountsDriveError( + label, + `it must not have a filesystem, use builder = "empty" and format = "raw", or declare it under [nvrams.${label}]`, + ); + } + if (drive.mount !== undefined && drive.mount !== false) { + throw new InvalidAccountsDriveError( + label, + "it can't be mounted, remove 'mount' or set it to false", + ); + } + size = drive.size; + } else { + // the size of an nvram backed by an existing image is only known once built + size = config.nvrams[label].size; + } + + assertAccountsDriveLength(config, accountsDrive, size); +}; diff --git a/apps/cli/src/defineConfig.ts b/apps/cli/src/defineConfig.ts new file mode 100644 index 000000000..bc6507288 --- /dev/null +++ b/apps/cli/src/defineConfig.ts @@ -0,0 +1,66 @@ +/** + * Entrypoint of `@cartesi/cli/config`, the module a `cartesi.config.ts` file + * imports: + * + * ```ts + * import { defineConfig } from "@cartesi/cli/config"; + * + * export default defineConfig({ + * machine: { ramLength: "256Mi" }, + * }); + * ``` + * + * It only carries the configuration types and the helpers that operate on + * them, so importing it from a configuration file does not pull in the rest of + * the CLI. + */ + +export { mergeConfig } from "./config/merge.js"; +export type { + AccountsDriveMarker, + Config, + DefaultBlock, + DirectoryDriveConfig, + DockerDriveConfig, + DriveConfig, + DriveFormat, + EmptyDriveConfig, + ExistingDriveConfig, + ImageInfo, + MachineConfig, + NvramConfig, + RunConfig, + TarDriveConfig, + WithdrawalConfig, + WithdrawalOptions, +} from "./config/types.js"; +export { + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, + defaultConfig, + defaultMachineConfig, + defaultRootDriveConfig, + defaultRunConfig, + MAX_NVRAMS, + NVRAM_ALIGNMENT, + PREFERRED_PORT, +} from "./config/types.js"; +export { + type ConfigCommand, + type ConfigEnv, + defineConfig, + type Size, + type UserAccountsDriveMarker, + type UserConfig, + type UserConfigExport, + type UserConfigFn, + type UserDirectoryDriveConfig, + type UserDockerDriveConfig, + type UserDriveConfig, + type UserEmptyDriveConfig, + type UserExistingDriveConfig, + type UserMachineConfig, + type UserNvramConfig, + type UserTarDriveConfig, + type UserWithdrawalOptions, +} from "./config/user.js"; diff --git a/apps/cli/src/exec/cartesi-machine-stored-hash.ts b/apps/cli/src/exec/cartesi-machine-stored-hash.ts index 004affd9f..53ab23e37 100644 --- a/apps/cli/src/exec/cartesi-machine-stored-hash.ts +++ b/apps/cli/src/exec/cartesi-machine-stored-hash.ts @@ -1,5 +1,5 @@ import { isHash, type Hash } from "viem"; -import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config.js"; +import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../config/index.js"; import { execaDockerFallback, type DockerFallbackOptions } from "./util.js"; type ComputeHashOptions = { cwd?: string } & DockerFallbackOptions; diff --git a/apps/cli/src/exec/rollups.ts b/apps/cli/src/exec/rollups.ts index 66799cb01..48e1d2aa9 100644 --- a/apps/cli/src/exec/rollups.ts +++ b/apps/cli/src/exec/rollups.ts @@ -30,7 +30,7 @@ import node from "../compose/node.js"; import passkey from "../compose/passkey.js"; import paymaster from "../compose/paymaster.js"; import proxy from "../compose/proxy.js"; -import type { WithdrawalConfig } from "../config.js"; +import type { WithdrawalConfig } from "../config/index.js"; import type { ForkConfig } from "../types/chain.js"; import type { AccountsDriveConfig } from "./cartesi-rollups-machine-tool.js"; import { execNodeCommand } from "./node-container.js"; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 3d7a8e8e8..299c9c1c9 100755 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -49,14 +49,26 @@ const program = new Command() .addCommand(createWithdrawCommand()); // Global error handling -process.on("uncaughtException", (err) => { - if (process.env.NODE_ENV === "development") { +// +// read through an alias of 'process.env', because the bundler replaces +// 'process.env.NODE_ENV' with its value at build time, which would make this a +// constant and always print the stack trace +const { env } = process; + +const reportError = (err: unknown) => { + if (env.NODE_ENV === "development") { console.error(err); } else { // in production, only print the error message, not the stack trace - console.error(err.message); + console.error(err instanceof Error ? err.message : String(err)); } process.exit(1); -}); +}; + +process.on("uncaughtException", reportError); + +// command actions are asynchronous, and commander does not await them, so an +// error thrown by one surfaces here rather than as an uncaught exception +process.on("unhandledRejection", reportError); program.parse(); diff --git a/apps/cli/src/lib.ts b/apps/cli/src/lib.ts index 21f9e3595..27cb833d6 100644 --- a/apps/cli/src/lib.ts +++ b/apps/cli/src/lib.ts @@ -21,29 +21,57 @@ */ export * from "./api/index.js"; -// configuration of an application ('cartesi.toml') +// configuration of an application ('cartesi.config.ts' and friends) export { + CONFIG_FILES, type Config, + type ConfigCommand, + type ConfigEnv, DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION, + type DefaultBlock, defaultConfig, defaultMachineConfig, defaultRootDriveConfig, + defaultRunConfig, + defineConfig, type DirectoryDriveConfig, type DockerDriveConfig, type DriveConfig, type DriveFormat, type EmptyDriveConfig, type ExistingDriveConfig, + findConfigFile, type ImageInfo, + LEGACY_CONFIG_FILE, + loadConfig, + type LoadConfigOptions, + loadConfigFile, type MachineConfig, + mergeConfig, + normalizeConfig, type NvramConfig, parse as parseConfig, + parseSize, PREFERRED_PORT, + type RunConfig, + type Size, type TarDriveConfig, + type UserConfig, + type UserConfigExport, + type UserConfigFn, + type UserDirectoryDriveConfig, + type UserDockerDriveConfig, + type UserDriveConfig, + type UserEmptyDriveConfig, + type UserExistingDriveConfig, + type UserMachineConfig, + type UserNvramConfig, + type UserTarDriveConfig, + type UserWithdrawalOptions, type WithdrawalConfig, type WithdrawalOptions, -} from "./config.js"; +} from "./config/index.js"; // runtime environment export { diff --git a/apps/cli/src/machine.ts b/apps/cli/src/machine.ts index b45ff54b1..953aa279a 100644 --- a/apps/cli/src/machine.ts +++ b/apps/cli/src/machine.ts @@ -7,7 +7,7 @@ import { type NvramConfig, nvramHasImage, nvramImageFilename, -} from "./config.js"; +} from "./config/index.js"; import { cartesiMachine } from "./exec/index.js"; import type { ExecaOptionsDockerFallback } from "./exec/util.js"; diff --git a/apps/cli/src/wallet.ts b/apps/cli/src/wallet.ts index cfbaf296c..e87275921 100644 --- a/apps/cli/src/wallet.ts +++ b/apps/cli/src/wallet.ts @@ -15,7 +15,7 @@ import { import { mnemonicToAccount } from "viem/accounts"; import { anvil } from "viem/chains"; import { getProjectName } from "./base.js"; -import { PREFERRED_PORT } from "./config.js"; +import { PREFERRED_PORT } from "./config/index.js"; import { getProjectPort } from "./exec/rollups.js"; export const cartesi = defineChain({ diff --git a/apps/cli/src/withdrawal.ts b/apps/cli/src/withdrawal.ts index 15d26edb2..f3a9b1aa1 100644 --- a/apps/cli/src/withdrawal.ts +++ b/apps/cli/src/withdrawal.ts @@ -13,7 +13,7 @@ import { LOG2_LEAF_SIZE, splitSize, type WithdrawalConfig, -} from "./config.js"; +} from "./config/index.js"; import { testUsdWithdrawalOutputBuilderAddress } from "./contracts.js"; /** diff --git a/apps/cli/tests/integration/builder/directory.test.ts b/apps/cli/tests/integration/builder/directory.test.ts index c2664dd66..2e53503d8 100644 --- a/apps/cli/tests/integration/builder/directory.test.ts +++ b/apps/cli/tests/integration/builder/directory.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/directory.js"; -import type { DirectoryDriveConfig } from "../../../src/config.js"; +import type { DirectoryDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/docker.test.ts b/apps/cli/tests/integration/builder/docker.test.ts index 0e4c3cbca..55b98e18b 100644 --- a/apps/cli/tests/integration/builder/docker.test.ts +++ b/apps/cli/tests/integration/builder/docker.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/docker.js"; -import type { DockerDriveConfig } from "../../../src/config.js"; +import type { DockerDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/empty.test.ts b/apps/cli/tests/integration/builder/empty.test.ts index 88186ca49..9563cb1f8 100644 --- a/apps/cli/tests/integration/builder/empty.test.ts +++ b/apps/cli/tests/integration/builder/empty.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/empty.js"; -import type { EmptyDriveConfig } from "../../../src/config.js"; +import type { EmptyDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/none.test.ts b/apps/cli/tests/integration/builder/none.test.ts index 729cf4583..b7b8f0892 100644 --- a/apps/cli/tests/integration/builder/none.test.ts +++ b/apps/cli/tests/integration/builder/none.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/none.js"; -import type { ExistingDriveConfig } from "../../../src/config.js"; +import type { ExistingDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/builder/nvram.test.ts b/apps/cli/tests/integration/builder/nvram.test.ts index 64dbe22c5..5a83ac33f 100644 --- a/apps/cli/tests/integration/builder/nvram.test.ts +++ b/apps/cli/tests/integration/builder/nvram.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/nvram.js"; -import type { NvramConfig } from "../../../src/config.js"; +import type { NvramConfig } from "../../../src/config/index.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; describe("when building an nvram", () => { diff --git a/apps/cli/tests/integration/builder/tar.test.ts b/apps/cli/tests/integration/builder/tar.test.ts index 5690d063a..2f52af52e 100644 --- a/apps/cli/tests/integration/builder/tar.test.ts +++ b/apps/cli/tests/integration/builder/tar.test.ts @@ -9,7 +9,7 @@ import { import fs from "fs-extra"; import path from "node:path"; import { build } from "../../../src/builder/tar.js"; -import type { TarDriveConfig } from "../../../src/config.js"; +import type { TarDriveConfig } from "../../../src/config/index.js"; import { setupIntegrationTests, TEST_SDK } from "../config.js"; import { cleanupTempDir, createTempDir } from "./tmpdirTest.js"; diff --git a/apps/cli/tests/integration/config.ts b/apps/cli/tests/integration/config.ts index dfdb0af46..a6ad66390 100644 --- a/apps/cli/tests/integration/config.ts +++ b/apps/cli/tests/integration/config.ts @@ -2,7 +2,10 @@ import { execa } from "execa"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { DEFAULT_SDK_IMAGE, DEFAULT_SDK_VERSION } from "../../src/config.js"; +import { + DEFAULT_SDK_IMAGE, + DEFAULT_SDK_VERSION, +} from "../../src/config/index.js"; // CARTESI_TEST_SDK lets CI and local runs point the suite at an unreleased // SDK image, which is how changes are verified against the matching node. diff --git a/apps/cli/tests/integration/machine/nvram.test.ts b/apps/cli/tests/integration/machine/nvram.test.ts index db55bed19..d50b08abd 100644 --- a/apps/cli/tests/integration/machine/nvram.test.ts +++ b/apps/cli/tests/integration/machine/nvram.test.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import fs from "fs-extra"; import path from "node:path"; import { satisfies } from "semver"; -import { parse } from "../../../src/config.js"; +import { parse } from "../../../src/config/index.js"; import { cartesiMachine } from "../../../src/exec/index.js"; import { bootMachine } from "../../../src/machine.js"; import { diff --git a/apps/cli/tests/unit/api/run.test.ts b/apps/cli/tests/unit/api/run.test.ts new file mode 100644 index 000000000..74be3f753 --- /dev/null +++ b/apps/cli/tests/unit/api/run.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "bun:test"; +import { resolveRunOptions } from "../../../src/api/run.js"; +import { DEFAULT_SDK_VERSION } from "../../../src/config/index.js"; + +describe("api/run", () => { + describe("resolveRunOptions", () => { + it("should fall back to the defaults", () => { + expect(resolveRunOptions({})).toEqual({ + blockTime: 2, + claimStagingPeriod: 0, + cpus: undefined, + defaultBlock: "latest", + epochLength: 720, + forkBlockNumber: undefined, + forkUrl: undefined, + memory: undefined, + port: undefined, + projectName: undefined, + prt: false, + runtimeVersion: DEFAULT_SDK_VERSION, + services: [], + verbose: false, + }); + }); + + it("should take the values of the configuration", () => { + const resolved = resolveRunOptions( + {}, + { + blockTime: 1, + defaultBlock: "finalized", + epochLength: 10, + forkUrl: "https://rpc.example.com", + port: 8080, + projectName: "my-app", + prt: true, + services: ["explorer"], + }, + ); + expect(resolved).toMatchObject({ + blockTime: 1, + defaultBlock: "finalized", + epochLength: 10, + forkUrl: "https://rpc.example.com", + port: 8080, + projectName: "my-app", + prt: true, + services: ["explorer"], + }); + // not set anywhere, so still the default + expect(resolved.claimStagingPeriod).toBe(0); + }); + + it("should let the options take precedence over the configuration", () => { + const resolved = resolveRunOptions( + { + blockTime: 5, + epochLength: 100, + projectName: "from-option", + services: [], + }, + { + blockTime: 1, + epochLength: 10, + projectName: "from-config", + services: ["explorer"], + }, + ); + expect(resolved).toMatchObject({ + blockTime: 5, + epochLength: 100, + projectName: "from-option", + // an empty list is a value, and does not fall back + services: [], + }); + }); + + it("should let a falsy option override the configuration", () => { + expect( + resolveRunOptions( + { blockTime: 0, prt: false }, + { blockTime: 1, prt: true }, + ), + ).toMatchObject({ blockTime: 0, prt: false }); + }); + + it("should resolve a free port later when none is given", () => { + // zero is not a usable port, and neither is it a request for one + expect(resolveRunOptions({ port: 0 }, { port: 0 }).port).toBe(0); + expect(resolveRunOptions({}, {}).port).toBeUndefined(); + }); + + it("should be verbose when the progress is verbose", () => { + expect(resolveRunOptions({ progress: "verbose" }).verbose).toBe( + true, + ); + expect( + resolveRunOptions({ progress: "verbose" }, { verbose: false }) + .verbose, + ).toBe(false); + expect(resolveRunOptions({}, { verbose: true }).verbose).toBe(true); + }); + }); +}); diff --git a/apps/cli/tests/unit/api/types.test.ts b/apps/cli/tests/unit/api/types.test.ts index 3c055456a..a2aed31fe 100644 --- a/apps/cli/tests/unit/api/types.test.ts +++ b/apps/cli/tests/unit/api/types.test.ts @@ -1,26 +1,34 @@ import { describe, expect, it } from "bun:test"; import * as path from "node:path"; import { listrRenderer, resolveConfig } from "../../../src/api/types.js"; -import { defaultConfig } from "../../../src/config.js"; +import { defaultConfig } from "../../../src/config/index.js"; const fixture = (...paths: string[]) => path.join(__dirname, "..", "config", "fixtures", ...paths); describe("api/types", () => { describe("resolveConfig", () => { - it("should default to the default configuration", () => { - // there is no cartesi.toml at the root of the repository - expect(resolveConfig()).toEqual(defaultConfig()); + it("should default to the default configuration", async () => { + // there is no configuration file at the root of the repository + expect(await resolveConfig()).toEqual(defaultConfig()); }); - it("should return a configuration object as is", () => { + it("should normalize a configuration given inline", async () => { + const config = await resolveConfig({ sdk: "my/sdk:1.0.0" }); + expect(config).toEqual({ + ...defaultConfig(), + sdk: "my/sdk:1.0.0", + }); + }); + + it("should accept an already resolved configuration", async () => { const config = defaultConfig(); config.sdk = "my/sdk:1.0.0"; - expect(resolveConfig(config)).toBe(config); + expect(await resolveConfig(config)).toEqual(config); }); - it("should read a configuration file", () => { - const config = resolveConfig(fixture("drives", "rives.toml")); + it("should read a configuration file", async () => { + const config = await resolveConfig(fixture("drives", "rives.toml")); expect(Object.keys(config.drives)).toEqual([ "root", "doom", @@ -29,8 +37,8 @@ describe("api/types", () => { expect(config.nvrams).toEqual({}); }); - it("should merge a list of configuration files", () => { - const config = resolveConfig([ + it("should merge a list of configuration files", async () => { + const config = await resolveConfig([ fixture("drives", "rives.toml"), fixture("withdrawal", "config.toml"), ]); @@ -42,9 +50,9 @@ describe("api/types", () => { expect(config.nvrams.accounts?.accountsDrive).toBe(true); }); - it("should fail for a configuration file that does not exist", () => { - expect(() => resolveConfig("undefined.toml")).toThrow( - "Config file undefined.toml does not exist", + it("should fail for a configuration file that does not exist", async () => { + expect(resolveConfig("undefined.toml")).rejects.toThrow( + "does not exist", ); }); }); diff --git a/apps/cli/tests/unit/config.test.ts b/apps/cli/tests/unit/config.test.ts index 1d04adb5f..928587087 100644 --- a/apps/cli/tests/unit/config.test.ts +++ b/apps/cli/tests/unit/config.test.ts @@ -23,7 +23,7 @@ import { parse, RequiredFieldError, TooManyNvramsError, -} from "../../src/config.js"; +} from "../../src/config/index.js"; const loadFixture = (...segments: string[]) => { const filePath = path.join(__dirname, "config", "fixtures", ...segments); diff --git a/apps/cli/tests/unit/config/fixtures/files/bare.config b/apps/cli/tests/unit/config/fixtures/files/bare.config new file mode 100644 index 000000000..151d74fa8 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/bare.config @@ -0,0 +1,3 @@ +# a configuration file with no format in its name, read as YAML +machine: + ramLength: 256Mi diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.js b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.js new file mode 100644 index 000000000..90e7cac32 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.js @@ -0,0 +1,3 @@ +export default { + machine: { ramLength: "512Mi" }, +}; diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.json b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.json new file mode 100644 index 000000000..82089bd5d --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://cartesi.io/schema/cartesi.config.json", + "machine": { "ramLength": "256Mi" }, + "run": { "epochLength": 10 } +} diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts new file mode 100644 index 000000000..c3ff59446 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "../../../../../src/defineConfig.js"; + +export default defineConfig({ + drives: { + data: { builder: "empty", size: "64Mb" }, + }, + machine: { entrypoint: "dapp", ramLength: "256Mi" }, + run: { epochLength: 10 }, + sdk: "my/sdk:1.0.0", +}); diff --git a/apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml new file mode 100644 index 000000000..22a6a92af --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/cartesi.config.yaml @@ -0,0 +1,10 @@ +machine: + ramLength: 256Mi +drives: + data: + builder: empty + size: 64Mb +run: + epochLength: 10 + services: + - explorer diff --git a/apps/cli/tests/unit/config/fixtures/files/empty.config b/apps/cli/tests/unit/config/fixtures/files/empty.config new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cli/tests/unit/config/fixtures/files/function.config.ts b/apps/cli/tests/unit/config/fixtures/files/function.config.ts new file mode 100644 index 000000000..a0ba6f99f --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/function.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "../../../../../src/defineConfig.js"; + +export default defineConfig(async ({ command, mode }) => ({ + machine: { entrypoint: `${command}:${mode}` }, + run: { epochLength: command === "run" ? 10 : 720 }, +})); diff --git a/apps/cli/tests/unit/config/fixtures/files/invalid.config.ts b/apps/cli/tests/unit/config/fixtures/files/invalid.config.ts new file mode 100644 index 000000000..ddfdfea9d --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/invalid.config.ts @@ -0,0 +1 @@ +export const config = { sdk: "my/sdk:1.0.0" }; diff --git a/apps/cli/tests/unit/config/fixtures/files/override.yaml b/apps/cli/tests/unit/config/fixtures/files/override.yaml new file mode 100644 index 000000000..4b00cb5dd --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/override.yaml @@ -0,0 +1,4 @@ +machine: + entrypoint: overridden +run: + epochLength: 20 diff --git a/apps/cli/tests/unit/config/fixtures/files/unsupported.ini b/apps/cli/tests/unit/config/fixtures/files/unsupported.ini new file mode 100644 index 000000000..3b8d5eb22 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/files/unsupported.ini @@ -0,0 +1 @@ +ram_length = 256Mi diff --git a/apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml b/apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml new file mode 100644 index 000000000..b866db650 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/project-legacy/cartesi.toml @@ -0,0 +1,2 @@ +[machine] +ram_length = "256Mi" diff --git a/apps/cli/tests/unit/config/fixtures/project-none/.keep b/apps/cli/tests/unit/config/fixtures/project-none/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml b/apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml new file mode 100644 index 000000000..3f01502a8 --- /dev/null +++ b/apps/cli/tests/unit/config/fixtures/project-yaml/cartesi.config.yaml @@ -0,0 +1,4 @@ +machine: + ramLength: 256Mi +run: + epochLength: 10 diff --git a/apps/cli/tests/unit/config/load.test.ts b/apps/cli/tests/unit/config/load.test.ts new file mode 100644 index 000000000..fbadfb7c5 --- /dev/null +++ b/apps/cli/tests/unit/config/load.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from "bun:test"; +import * as path from "node:path"; +import { + CONFIG_FILES, + ConfigFileNotFoundError, + defaultConfig, + findConfigFile, + InvalidConfigExportError, + loadConfig, + loadConfigFile, + UnsupportedConfigFormatError, +} from "../../../src/config/index.js"; + +const fixtures = path.join(__dirname, "fixtures"); +const files = path.join(fixtures, "files"); +const file = (name: string) => path.join(files, name); + +const env = { command: "build", cwd: files, mode: "test" } as const; + +describe("findConfigFile", () => { + it("should find a configuration file by name", () => { + expect(findConfigFile(path.join(fixtures, "project-yaml"))).toBe( + path.join(fixtures, "project-yaml", "cartesi.config.yaml"), + ); + }); + + it("should find the deprecated configuration file", () => { + expect(findConfigFile(path.join(fixtures, "project-legacy"))).toBe( + path.join(fixtures, "project-legacy", "cartesi.toml"), + ); + }); + + it("should return undefined when the project has no configuration file", () => { + expect(findConfigFile(path.join(fixtures, "project-none"))).toBe( + undefined, + ); + }); + + it("should prefer the first of the supported names", () => { + // the fixture directory has a file of every supported format + expect(findConfigFile(files)).toBe(file(CONFIG_FILES[0])); + }); +}); + +describe("loadConfigFile", () => { + it("should load a TypeScript configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.ts"), env); + expect(config).toEqual({ + drives: { data: { builder: "empty", size: "64Mb" } }, + machine: { entrypoint: "dapp", ramLength: "256Mi" }, + run: { epochLength: 10 }, + sdk: "my/sdk:1.0.0", + }); + }); + + it("should load a JavaScript configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.js"), env); + expect(config).toEqual({ machine: { ramLength: "512Mi" } }); + }); + + it("should call a configuration file that exports a function", async () => { + const config = await loadConfigFile(file("function.config.ts"), { + command: "run", + cwd: files, + mode: "production", + }); + expect(config).toEqual({ + machine: { entrypoint: "run:production" }, + run: { epochLength: 10 }, + }); + }); + + it("should load a JSON configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.json"), env); + expect(config).toMatchObject({ + machine: { ramLength: "256Mi" }, + run: { epochLength: 10 }, + }); + }); + + it("should load a YAML configuration file", async () => { + const config = await loadConfigFile(file("cartesi.config.yaml"), env); + expect(config).toEqual({ + drives: { data: { builder: "empty", size: "64Mb" } }, + machine: { ramLength: "256Mi" }, + run: { epochLength: 10, services: ["explorer"] }, + }); + }); + + it("should read a file with no format in its name as YAML", async () => { + expect(await loadConfigFile(file("bare.config"), env)).toEqual({ + machine: { ramLength: "256Mi" }, + }); + }); + + it("should load an empty configuration file", async () => { + expect(await loadConfigFile(file("empty.config"), env)).toEqual({}); + }); + + it("should fail for a module without a default export", async () => { + expect(loadConfigFile(file("invalid.config.ts"), env)).rejects.toThrow( + new InvalidConfigExportError(file("invalid.config.ts")), + ); + }); + + it("should fail for an unsupported format", async () => { + expect(loadConfigFile(file("unsupported.ini"), env)).rejects.toThrow( + new UnsupportedConfigFormatError(file("unsupported.ini")), + ); + }); +}); + +describe("loadConfig", () => { + it("should use the defaults when the project has no configuration file", async () => { + const config = await loadConfig({ + cwd: path.join(fixtures, "project-none"), + }); + expect(config).toEqual(defaultConfig()); + }); + + it("should find and resolve the configuration file of the project", async () => { + const config = await loadConfig({ + cwd: path.join(fixtures, "project-yaml"), + }); + expect(config.machine.ramLength).toBe("256Mi"); + expect(config.run.epochLength).toBe(10); + }); + + it("should still read the deprecated configuration file", async () => { + const config = await loadConfig({ + cwd: path.join(fixtures, "project-legacy"), + }); + expect(config.machine.ramLength).toBe("256Mi"); + }); + + it("should resolve and validate an explicit configuration file", async () => { + const config = await loadConfig({ + cwd: files, + files: ["cartesi.config.ts"], + }); + expect(config.sdk).toBe("my/sdk:1.0.0"); + expect(config.machine.ramLength).toBe("256Mi"); + expect(config.run.epochLength).toBe(10); + // sizes written as strings are resolved to a number of bytes + expect(config.drives.data).toMatchObject({ size: 64 * 1024 * 1024 }); + // a root drive is always present + expect(config.drives.root).toMatchObject({ builder: "docker" }); + }); + + it("should merge a list of configuration files, in order", async () => { + const config = await loadConfig({ + cwd: files, + files: ["cartesi.config.ts", "override.yaml"], + }); + expect(config.machine.entrypoint).toBe("overridden"); + // untouched by the override + expect(config.machine.ramLength).toBe("256Mi"); + expect(config.run.epochLength).toBe(20); + }); + + it("should give the command and the mode to a configuration file", async () => { + const config = await loadConfig({ + command: "run", + cwd: files, + files: ["function.config.ts"], + mode: "production", + }); + expect(config.machine.entrypoint).toBe("run:production"); + }); + + it("should fail for a configuration file that does not exist", async () => { + expect( + loadConfig({ cwd: files, files: ["missing.config.ts"] }), + ).rejects.toThrow( + new ConfigFileNotFoundError(file("missing.config.ts")), + ); + }); +}); diff --git a/apps/cli/tests/unit/config/normalize.test.ts b/apps/cli/tests/unit/config/normalize.test.ts new file mode 100644 index 000000000..caa2f6b40 --- /dev/null +++ b/apps/cli/tests/unit/config/normalize.test.ts @@ -0,0 +1,501 @@ +import { describe, expect, it } from "bun:test"; +import { + defaultConfig, + defaultMachineConfig, + defaultRootDriveConfig, + defineConfig, + type DockerDriveConfig, + DuplicateLabelError, + getAccountsDrive, + InvalidAccountsDriveError, + InvalidAddressValueError, + InvalidBooleanValueError, + InvalidBuilderError, + InvalidBytesValueError, + InvalidDriveFormatError, + InvalidEnumValueError, + InvalidNumberValueError, + InvalidNvramSizeError, + InvalidSectionError, + InvalidStringValueError, + InvalidWithdrawalConfigError, + MAX_NVRAMS, + MissingNvramSourceError, + MultipleAccountsDrivesError, + normalizeConfig, + NVRAM_ALIGNMENT, + RequiredFieldError, + TooManyNvramsError, +} from "../../../src/config/index.js"; + +describe("defineConfig", () => { + it("should return the configuration unchanged", () => { + const config = { sdk: "my/sdk:1.0.0" }; + expect(defineConfig(config)).toBe(config); + }); + + it("should return a configuration function unchanged", () => { + const fn = () => ({ sdk: "my/sdk:1.0.0" }); + expect(defineConfig(fn)).toBe(fn); + }); +}); + +// the default root drive is built by docker, narrowed so the expectations +// below can spread it and still be checked against that member of the union +const defaultRootDrive = () => defaultRootDriveConfig() as DockerDriveConfig; + +describe("normalizeConfig", () => { + it("should apply the defaults to an empty configuration", () => { + expect(normalizeConfig(undefined)).toEqual(defaultConfig()); + expect(normalizeConfig({})).toEqual(defaultConfig()); + }); + + it("should be idempotent on an already resolved configuration", () => { + const config = normalizeConfig({ + drives: { data: { builder: "empty", size: "64Mb" } }, + machine: { entrypoint: "dapp", ramLength: "256Mi" }, + run: { epochLength: 10 }, + sdk: "my/sdk:1.0.0", + }); + expect(normalizeConfig(config)).toEqual(config); + }); + + it("should ignore the '$schema' key of a JSON configuration", () => { + expect( + normalizeConfig({ $schema: "https://cartesi.io/schema.json" }), + ).toEqual(defaultConfig()); + }); + + it("should fail for a configuration that is not an object", () => { + expect(() => normalizeConfig(42)).toThrowError( + new InvalidSectionError("config", 42), + ); + }); + + describe("drives", () => { + it("should add a default root drive", () => { + const config = normalizeConfig({ + drives: { data: { builder: "empty", size: 128 } }, + }); + expect(config.drives.root).toEqual(defaultRootDrive()); + expect(config.drives.data).toEqual({ + builder: "empty", + format: "ext2", + mount: undefined, + shared: undefined, + size: 128, + user: undefined, + }); + }); + + it("should default the builder to docker", () => { + const config = normalizeConfig({ + drives: { root: { dockerfile: "backend/Dockerfile" } }, + }); + expect(config.drives.root).toEqual({ + ...defaultRootDrive(), + dockerfile: "backend/Dockerfile", + }); + }); + + it("should parse human readable sizes", () => { + const config = normalizeConfig({ + drives: { + data: { builder: "directory", directory: "data" }, + root: { extraSize: "10Mb" }, + }, + }); + expect(config.drives.root).toEqual({ + ...defaultRootDrive(), + extraSize: 10 * 1024 * 1024, + }); + expect(config.drives.data).toMatchObject({ extraSize: 0 }); + }); + + it("should infer the format of an existing drive from its filename", () => { + const config = normalizeConfig({ + drives: { + doom: { builder: "none", filename: "doom.sqfs" }, + }, + }); + expect(config.drives.doom).toMatchObject({ format: "sqfs" }); + }); + + it("should keep the drive options common to every builder", () => { + const config = normalizeConfig({ + drives: { + data: { + builder: "tar", + filename: "data.tar", + mount: "/mnt/data", + shared: true, + user: "dapp", + }, + }, + }); + expect(config.drives.data).toEqual({ + builder: "tar", + extraSize: 0, + filename: "data.tar", + format: "ext2", + mount: "/mnt/data", + shared: true, + user: "dapp", + }); + }); + + it("should fail for an invalid builder", () => { + expect(() => + normalizeConfig({ drives: { root: { builder: "invalid" } } }), + ).toThrowError(new InvalidBuilderError("invalid")); + }); + + it("should fail for an invalid format", () => { + expect(() => + normalizeConfig({ drives: { root: { format: "invalid" } } }), + ).toThrowError(new InvalidDriveFormatError("invalid")); + }); + + it("should fail for an invalid size", () => { + expect(() => + normalizeConfig({ + drives: { root: { extraSize: "abc" } }, + }), + ).toThrowError(new InvalidBytesValueError("abc")); + }); + + it("should fail when a required field is missing", () => { + expect(() => + normalizeConfig({ drives: { data: { builder: "directory" } } }), + ).toThrowError(new RequiredFieldError("directory")); + expect(() => + normalizeConfig({ drives: { data: { builder: "tar" } } }), + ).toThrowError(new RequiredFieldError("filename")); + }); + + it("should fail when drives is not an object", () => { + expect(() => normalizeConfig({ drives: 42 })).toThrowError( + new InvalidSectionError("drives", 42), + ); + }); + }); + + describe("machine", () => { + it("should apply the machine defaults", () => { + expect(normalizeConfig({ machine: {} }).machine).toEqual( + defaultMachineConfig(), + ); + }); + + it("should coerce non-string environment variables", () => { + expect( + normalizeConfig({ + machine: { env: { ENABLED: true, PORT: 8080 } }, + }).machine.env, + ).toEqual({ ENABLED: "true", PORT: "8080" }); + }); + + it("should accept a number as the maximum cycle count", () => { + expect( + normalizeConfig({ machine: { maxMCycle: 100 } }).machine + .maxMCycle, + ).toBe(100n); + }); + + it("should fail for an invalid boolean", () => { + expect(() => + normalizeConfig({ machine: { useDockerEnv: 42 } }), + ).toThrowError(new InvalidBooleanValueError(42)); + }); + + it("should fail for an invalid entrypoint", () => { + expect(() => + normalizeConfig({ machine: { entrypoint: 42 } }), + ).toThrowError(new InvalidStringValueError(42)); + }); + }); + + describe("run", () => { + it("should be empty by default", () => { + expect(normalizeConfig({}).run).toEqual({}); + }); + + it("should keep the run options", () => { + expect( + normalizeConfig({ + run: { + blockTime: 1, + defaultBlock: "finalized", + epochLength: 10, + projectName: "my-app", + prt: true, + services: ["explorer", "bundler"], + }, + }).run, + ).toEqual({ + blockTime: 1, + claimStagingPeriod: undefined, + cpus: undefined, + defaultBlock: "finalized", + epochLength: 10, + forkBlockNumber: undefined, + forkUrl: undefined, + memory: undefined, + port: undefined, + projectName: "my-app", + prt: true, + runtimeVersion: undefined, + services: ["explorer", "bundler"], + verbose: undefined, + }); + }); + + it("should fail for an invalid default block", () => { + expect(() => + normalizeConfig({ run: { defaultBlock: "invalid" } }), + ).toThrowError( + new InvalidEnumValueError("defaultBlock", "invalid", [ + "latest", + "safe", + "pending", + "finalized", + ]), + ); + }); + + it("should fail for an invalid number", () => { + expect(() => + normalizeConfig({ run: { epochLength: "ten" } }), + ).toThrowError(new InvalidNumberValueError("ten", "epochLength")); + }); + + it("should fail for invalid services", () => { + expect(() => + normalizeConfig({ run: { services: [42] } }), + ).toThrowError(new InvalidStringValueError(42)); + }); + }); + + describe("nvrams", () => { + it("should be empty by default", () => { + expect(normalizeConfig({}).nvrams).toEqual({}); + }); + + it("should normalize a pristine nvram", () => { + expect( + normalizeConfig({ nvrams: { state: { size: "8Ki" } } }).nvrams + .state, + ).toEqual({ + filename: undefined, + shared: undefined, + size: 8192, + user: undefined, + }); + }); + + it("should normalize an nvram backed by an image", () => { + expect( + normalizeConfig({ + nvrams: { state: { filename: "state.raw", shared: true } }, + }).nvrams.state, + ).toEqual({ + filename: "state.raw", + shared: true, + size: undefined, + user: undefined, + }); + }); + + it("should fail without a size or a filename", () => { + expect(() => + normalizeConfig({ nvrams: { state: {} } }), + ).toThrowError(new MissingNvramSourceError("state")); + }); + + it("should fail for a size that is not a multiple of the alignment", () => { + expect(() => + normalizeConfig({ nvrams: { state: { size: 100 } } }), + ).toThrowError(new InvalidNvramSizeError("state", 100)); + expect(() => + normalizeConfig({ nvrams: { state: { size: 0 } } }), + ).toThrowError(new InvalidNvramSizeError("state", 0)); + }); + + it("should fail for more nvrams than the guest exposes", () => { + const nvrams = Object.fromEntries( + Array.from({ length: MAX_NVRAMS + 1 }, (_, i) => [ + `state${i}`, + { size: NVRAM_ALIGNMENT }, + ]), + ); + expect(() => normalizeConfig({ nvrams })).toThrowError( + new TooManyNvramsError(MAX_NVRAMS + 1), + ); + }); + + it("should fail when a label is used by both a drive and an nvram", () => { + expect(() => + normalizeConfig({ + drives: { state: { builder: "empty", size: "4Ki" } }, + nvrams: { state: { size: "4Ki" } }, + }), + ).toThrowError(new DuplicateLabelError("state")); + }); + }); + + describe("withdrawal", () => { + // the accounts drive is what emergency withdrawal is derived from + const accountsNvram = { + nvrams: { accounts: { accountsDrive: true, size: "4Ki" } }, + }; + + it("should be undefined when not given", () => { + expect(normalizeConfig(accountsNvram).withdrawal).toBeUndefined(); + }); + + it("should keep the overrides of an application with an accounts drive", () => { + expect( + normalizeConfig({ + ...accountsNvram, + withdrawal: { + account_size: "64", + guardian: "0x1111111111111111111111111111111111111111", + withdrawal_output_builder: + "0x2222222222222222222222222222222222222222", + }, + }).withdrawal, + ).toEqual({ + account_size: 64, + guardian: "0x1111111111111111111111111111111111111111", + withdrawal_output_builder: + "0x2222222222222222222222222222222222222222", + }); + }); + + it("should accept an empty table, the whole configuration being derived", () => { + expect( + normalizeConfig({ ...accountsNvram, withdrawal: {} }) + .withdrawal, + ).toEqual({ + account_size: undefined, + guardian: undefined, + withdrawal_output_builder: undefined, + }); + }); + + it("should fail without an accounts drive", () => { + expect(() => normalizeConfig({ withdrawal: {} })).toThrowError( + InvalidWithdrawalConfigError, + ); + }); + + it("should fail for an invalid address", () => { + expect(() => + normalizeConfig({ + ...accountsNvram, + withdrawal: { guardian: "invalid" }, + }), + ).toThrowError(new InvalidAddressValueError("invalid", "guardian")); + }); + + it("should fail for an account size that is not a power of two", () => { + expect(() => + normalizeConfig({ + ...accountsNvram, + withdrawal: { account_size: 48 }, + }), + ).toThrowError(InvalidWithdrawalConfigError); + expect(() => + normalizeConfig({ + ...accountsNvram, + withdrawal: { account_size: 16 }, + }), + ).toThrowError(InvalidWithdrawalConfigError); + }); + }); + + describe("accounts drive", () => { + it("should accept a raw empty drive", () => { + const config = normalizeConfig({ + drives: { + accounts: { + accountsDrive: true, + builder: "empty", + format: "raw", + size: "4Ki", + }, + }, + }); + expect(getAccountsDrive(config)).toMatchObject({ + kind: "flash_drive", + label: "accounts", + }); + }); + + it("should accept an nvram", () => { + const config = normalizeConfig({ + nvrams: { accounts: { accountsDrive: true, size: "4Ki" } }, + }); + expect(getAccountsDrive(config)).toMatchObject({ + kind: "nvram", + label: "accounts", + }); + }); + + it("should be undefined when nothing is marked", () => { + expect(getAccountsDrive(normalizeConfig({}))).toBeUndefined(); + }); + + it("should fail when a drive has a filesystem", () => { + expect(() => + normalizeConfig({ + drives: { + accounts: { + accountsDrive: true, + builder: "empty", + size: "4Ki", + }, + }, + }), + ).toThrowError(InvalidAccountsDriveError); + }); + + it("should fail when more than one is marked", () => { + expect(() => + normalizeConfig({ + drives: { + accounts: { + accountsDrive: true, + builder: "empty", + format: "raw", + size: "4Ki", + }, + }, + nvrams: { other: { accountsDrive: true, size: "4Ki" } }, + }), + ).toThrowError( + new MultipleAccountsDrivesError(["accounts", "other"]), + ); + }); + + it("should fail for a size that is not a power of two", () => { + expect(() => + normalizeConfig({ + nvrams: { + accounts: { accountsDrive: true, size: 3 * 4096 }, + }, + }), + ).toThrowError(InvalidAccountsDriveError); + }); + + it("should fail when the accounts size is set without the marker", () => { + expect(() => + normalizeConfig({ + nvrams: { + state: { accountsDriveSize: "4Ki", size: "4Ki" }, + }, + }), + ).toThrowError(InvalidAccountsDriveError); + }); + }); +}); diff --git a/apps/cli/tests/unit/config/size.test.ts b/apps/cli/tests/unit/config/size.test.ts new file mode 100644 index 000000000..7eb9936aa --- /dev/null +++ b/apps/cli/tests/unit/config/size.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "bun:test"; +import { + InvalidBytesValueError, + parseBytes, + parseSize, +} from "../../../src/config/index.js"; + +describe("parseSize", () => { + it("should leave a size that was not given undefined", () => { + // the caller decides what the default is, see parseBytes + expect(parseSize(undefined)).toBeUndefined(); + }); + + it("should take a number of bytes as is", () => { + expect(parseSize(0)).toBe(0); + expect(parseSize(128)).toBe(128); + expect(parseSize(128n)).toBe(128); + expect(parseSize("128")).toBe(128); + }); + + it("should parse the decimal looking units as binary multiples", () => { + expect(parseSize("1kb")).toBe(1024); + expect(parseSize("128MB")).toBe(128 * 1024 ** 2); + expect(parseSize("2Gb")).toBe(2 * 1024 ** 3); + }); + + it("should parse the IEC units", () => { + // 'bytes.parse' reads these as a plain number of bytes, which silently + // makes a drive several orders of magnitude too small + expect(parseSize("64Mi")).toBe(64 * 1024 ** 2); + expect(parseSize("64MiB")).toBe(64 * 1024 ** 2); + expect(parseSize("1Ki")).toBe(1024); + }); + + it("should parse a unit with no prefix", () => { + expect(parseSize("512b")).toBe(512); + expect(parseSize("4M")).toBe(4 * 1024 ** 2); + }); + + it("should accept fractions and spacing", () => { + expect(parseSize("1.5Mb")).toBe(Math.floor(1.5 * 1024 ** 2)); + expect(parseSize(" 10 Mb ")).toBe(10 * 1024 ** 2); + }); + + it("should reject anything it does not understand", () => { + expect(() => parseSize("abc")).toThrowError( + new InvalidBytesValueError("abc"), + ); + expect(() => parseSize("64Xy")).toThrowError( + new InvalidBytesValueError("64Xy"), + ); + expect(() => parseSize("")).toThrowError( + new InvalidBytesValueError(""), + ); + expect(() => parseSize({})).toThrowError( + new InvalidBytesValueError({}), + ); + }); +}); + +describe("parseBytes", () => { + it("should fall back to the default when no size is given", () => { + expect(parseBytes(undefined, 0)).toBe(0); + expect(parseBytes(undefined, 4096)).toBe(4096); + }); + + it("should parse a size that is given", () => { + expect(parseBytes("64Mi", 0)).toBe(64 * 1024 ** 2); + }); + + it("should reject a size it does not understand", () => { + expect(() => parseBytes("abc", 0)).toThrowError( + new InvalidBytesValueError("abc"), + ); + }); +}); diff --git a/apps/cli/tests/unit/machine.test.ts b/apps/cli/tests/unit/machine.test.ts index e86156d36..aef06a48b 100644 --- a/apps/cli/tests/unit/machine.test.ts +++ b/apps/cli/tests/unit/machine.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { parse } from "../../src/config.js"; +import { parse } from "../../src/config/index.js"; import { buildMachineArgs } from "../../src/machine.js"; const argsOf = (toml: string) => diff --git a/apps/cli/tests/unit/withdrawal.test.ts b/apps/cli/tests/unit/withdrawal.test.ts index 62bbd89a1..1484a9bb4 100644 --- a/apps/cli/tests/unit/withdrawal.test.ts +++ b/apps/cli/tests/unit/withdrawal.test.ts @@ -7,7 +7,7 @@ import { InvalidAccountsDriveError, InvalidWithdrawalConfigError, parse, -} from "../../src/config.js"; +} from "../../src/config/index.js"; import { DEVNET_GUARDIAN, DEVNET_WITHDRAWAL_OUTPUT_BUILDER,