Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/build-cli-for-production.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cartesi/cli": patch
---

print only the message of an error, as intended, instead of a minified class name and a stack trace. The CLI is now built for production unless `NODE_ENV` says otherwise; build with `NODE_ENV=development` to get the full error.
5 changes: 5 additions & 0 deletions .changeset/reject-unknown-config-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cartesi/cli": patch
---

reject unknown keys in `cartesi.toml`. A key the CLI doesn't read, such as a misspelling like `sise`, a key of another builder, or a key of a removed feature like `[withdrawal.config]`, now fails `build`, `run` and `shell` with a message naming the key, the table it is in and the keys that table accepts, e.g. `Unknown key 'sise' in [drives.root] (builder = "docker" by default), expected one of: …`. A key written after the last table header belongs to that table, so a trailing `sdk = "…"` is reported too. Drive names, nvram labels and the variables under `[machine.env]` stay free, missing keys keep their defaults, and the files merged with `-c` are checked as one, so a later file can't change the builder of a drive whose builder-specific keys an earlier file set. The commands that read the snapshot with the project `sdk`, such as `hash`, `send`, `deposit` and `withdraw`, now report an invalid `cartesi.toml` instead of falling back to the default SDK image.
11 changes: 11 additions & 0 deletions apps/cli/build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
// Bun inlines process.env.NODE_ENV in the bundle, as "development" when it isn't set.
// Default to production so errors print only their message, as in src/index.ts;
// build with NODE_ENV=development to get the full error with its stack.
const define = {
"process.env.NODE_ENV": JSON.stringify(
process.env.NODE_ENV ?? "production",
),
};

// build for npm package
await Bun.build({
banner: "#!/usr/bin/env node",
define,
entrypoints: ["./src/index.ts"],
minify: true,
outdir: "dist",
Expand All @@ -24,6 +34,7 @@ await Promise.all(
outfile: `bin/cartesi-${target.replace("bun-", "")}`,
target,
},
define,
entrypoints: ["./src/index.ts"],
minify: true,
sourcemap: "linked",
Expand Down
17 changes: 4 additions & 13 deletions apps/cli/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,10 @@ export const getContextPath = (...paths: string[]): string => {
};

/**
* SDK image of the project, which built its machine snapshot
* SDK image of the project, which built its machine snapshot. An invalid cartesi.toml is
* reported rather than falling back to the default image, whose emulator may not load it.
*/
const getProjectSdk = (): string | undefined => {
try {
return getApplicationConfig(["cartesi.toml"]).sdk;
} catch {
// an invalid config is reported by the commands that build with it
return undefined;
}
};
const getProjectSdk = (): string => getApplicationConfig(["cartesi.toml"]).sdk;

/**
* Read the hash of the cartesi machine snapshot, if one exists. Without a
Expand All @@ -64,10 +58,7 @@ export const getMachineHash = async (options?: {
const imagePath = getContextPath("image");
if (fs.existsSync(imagePath)) {
const image = options?.sdk ?? getProjectSdk();
return await cartesiMachineStoredHash.computeHash(
imagePath,
image ? { image } : undefined,
);
return await cartesiMachineStoredHash.computeHash(imagePath, { image });
}
return undefined;
};
Expand Down
178 changes: 152 additions & 26 deletions apps/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,28 @@ export class InvalidWithdrawalConfigError extends Error {
}
}

export class UnknownConfigKeyError extends Error {
/**
* @param where where the keys are, e.g. "in [machine]" or "at the top level"
*/
constructor(keys: string[], known: readonly string[], where: string) {
const list = keys.map((key) => `'${key}'`).join(", ");
super(
`Unknown ${keys.length > 1 ? "keys" : "key"} ${list} ${where}, expected one of: ${[...known].sort().join(", ")}`,
);
this.name = "UnknownConfigKeyError";
}
}

export class InvalidTableError extends Error {
constructor(location: string, value: TomlPrimitive) {
super(
`Invalid ${location}: expected a table, got ${formatValue(value)}`,
);
this.name = "InvalidTableError";
}
}

/**
* Configuration for drives of a Cartesi Machine. A drive may already exist or be built by a builder
*/
Expand Down Expand Up @@ -421,6 +443,21 @@ export const assertAccountsDriveLength = (

type TomlTable = { [key: string]: TomlPrimitive };

/**
* Fails on any key of a table that its parser doesn't read, naming the keys the table accepts.
* @param where where the table is, e.g. "in [machine]" or "at the top level"
*/
const assertKnownKeys = (
table: TomlTable,
known: readonly string[],
where: string,
): void => {
const unknown = Object.keys(table).filter((key) => !known.includes(key));
if (unknown.length > 0) {
throw new UnknownConfigKeyError(unknown, known, where);
}
};

export const defaultRootDriveConfig = (): DriveConfig => ({
builder: "docker",
buildArgs: [],
Expand Down Expand Up @@ -650,6 +687,11 @@ const parseSize = (value: TomlPrimitive): number | undefined => {
const parseBytes = (value: TomlPrimitive, defaultValue: number): number =>
parseSize(value) ?? defaultValue;

const ACCOUNTS_DRIVE_KEYS: readonly string[] = [
"accounts_drive",
"accounts_drive_size",
];

/**
* Parses the keys that mark a drive or nvram as the accounts drive, leaving them out when unset.
*/
Expand All @@ -662,8 +704,20 @@ const parseAccountsDriveMarker = (toml: TomlTable): AccountsDriveMarker => {
};
};

const NVRAM_KEYS: readonly string[] = [
...ACCOUNTS_DRIVE_KEYS,
"filename",
"shared",
"size",
"user",
];

const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => {
const toml = isTomlTable(value) ? value : {};
if (!isTomlTable(value)) {
throw new InvalidTableError(`[nvrams.${label}]`, value);
}
const toml = value;
assertKnownKeys(toml, NVRAM_KEYS, `in [nvrams.${label}]`);
const size = parseSize(toml.size);
const filename = parseOptionalString(toml.filename);

Expand All @@ -684,7 +738,10 @@ const parseNvram = (label: string, value: TomlPrimitive): NvramConfig => {
};

const parseNvrams = (config: TomlPrimitive): Record<string, NvramConfig> => {
const entries = Object.entries((config as TomlTable) ?? {});
if (config !== undefined && !isTomlTable(config)) {
throw new InvalidTableError("[nvrams]", config);
}
const entries = Object.entries(config ?? {});
if (entries.length > MAX_NVRAMS) {
throw new TooManyNvramsError(entries.length);
}
Expand Down Expand Up @@ -761,15 +818,30 @@ const parseEmptyFormat = (value: TomlPrimitive): "ext2" | "raw" => {
throw new InvalidEmptyDriveFormatError(value);
};

const MACHINE_KEYS: readonly string[] = [
"assert_rolling_template",
"boot_args",
"entrypoint",
"env",
"env_file",
"max_mcycle",
"ram_image",
"ram_length",
"use_docker_env",
"use_docker_workdir",
"user",
];

const parseMachine = (value: TomlPrimitive): MachineConfig => {
if (value === undefined) {
// default machine
return defaultMachineConfig();
}
if (typeof value !== "object") {
throw new Error(`Invalid machine configuration: ${value}`);
if (!isTomlTable(value)) {
throw new InvalidTableError("[machine]", value);
}
const toml = value as TomlTable;
assertKnownKeys(value, MACHINE_KEYS, "in [machine]");
const toml = value;

return {
assertRollingTemplate: parseOptionalBoolean(
Expand Down Expand Up @@ -800,17 +872,58 @@ export const getDriveFormat = (filename: string): DriveFormat => {
}
};

const parseDrive = (drive: TomlPrimitive): DriveConfig => {
const toml = drive as TomlTable;
return { ...parseBuilderDrive(toml), ...parseAccountsDriveMarker(toml) };
const DRIVE_KEYS: readonly string[] = [
...ACCOUNTS_DRIVE_KEYS,
"builder",
"mount",
"shared",
"user",
];

/**
* The keys each builder reads, on top of DRIVE_KEYS
*/
const BUILDER_KEYS: Record<Builder, readonly string[]> = {
directory: ["directory", "extra_size", "format"],
docker: [
"build_args",
"context",
"dockerfile",
"extra_size",
"format",
"image",
"tags",
"target",
],
empty: ["format", "size"],
none: ["filename"],
tar: ["extra_size", "filename", "format"],
};

const parseDrive = (name: string, drive: TomlPrimitive): DriveConfig => {
if (!isTomlTable(drive)) {
throw new InvalidTableError(`[drives.${name}]`, drive);
}
const toml = drive;
// the keys depend on the builder, so an invalid one is reported first
const builder = parseBuilder(toml.builder);
const implicit = toml.builder === undefined ? " by default" : "";
assertKnownKeys(
toml,
[...DRIVE_KEYS, ...BUILDER_KEYS[builder]],
`in [drives.${name}] (builder = "${builder}"${implicit})`,
);
return {
...parseBuilderDrive(builder, toml),
...parseAccountsDriveMarker(toml),
};
};

const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
const builder = parseBuilder((drive as TomlTable).builder);
const parseBuilderDrive = (builder: Builder, drive: TomlTable): DriveConfig => {
switch (builder) {
case "directory": {
const { extra_size, format, mount, directory, shared, user } =
drive as TomlTable;
drive;
return {
builder: "directory",
extraSize: parseBytes(extra_size, 0),
Expand All @@ -834,7 +947,7 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
tags,
target,
user,
} = drive as TomlTable;
} = drive;
return {
builder: "docker",
buildArgs: parseStringArray(build_args),
Expand All @@ -851,7 +964,7 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
};
}
case "empty": {
const { format, mount, size, shared, user } = drive as TomlTable;
const { format, mount, size, shared, user } = drive;
return {
builder: "empty",
format: parseEmptyFormat(format),
Expand All @@ -862,8 +975,7 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
};
}
case "tar": {
const { extra_size, filename, format, mount, shared, user } =
drive as TomlTable;
const { extra_size, filename, format, mount, shared, user } = drive;
return {
builder: "tar",
extraSize: parseBytes(extra_size, 0),
Expand All @@ -875,11 +987,8 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
};
}
case "none": {
const { shared, mount, user } = drive as TomlTable;
const filename = parseRequiredString(
(drive as TomlTable).filename,
"filename",
);
const { shared, mount, user } = drive;
const filename = parseRequiredString(drive.filename, "filename");
const format = getDriveFormat(filename);
return {
builder: "none",
Expand All @@ -894,11 +1003,14 @@ const parseBuilderDrive = (drive: TomlPrimitive): DriveConfig => {
};

const parseDrives = (config: TomlPrimitive): Record<string, DriveConfig> => {
if (config !== undefined && !isTomlTable(config)) {
throw new InvalidTableError("[drives]", config);
}
// load drives from configuration
const drives = Object.entries((config as TomlTable) ?? {}).reduce<
const drives = Object.entries(config ?? {}).reduce<
Record<string, DriveConfig>
>((acc, [name, drive]) => {
acc[name] = parseDrive(drive);
acc[name] = parseDrive(name, drive);
return acc;
}, {});

Expand All @@ -925,9 +1037,15 @@ const parseAccountSize = (value: TomlPrimitive): number => {
return size;
};

const WITHDRAWAL_KEYS: readonly string[] = [
"account_size",
"guardian",
"withdrawal_output_builder",
];

/**
* 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.
* other tables, it reads the keys it uses and rejects any other.
*/
const parseOptionalWithdrawal = (
withdrawal: TomlPrimitive,
Expand All @@ -936,10 +1054,9 @@ const parseOptionalWithdrawal = (
return undefined;
}
if (!isTomlTable(withdrawal)) {
throw new InvalidWithdrawalConfigError(
`expected a table, got ${formatValue(withdrawal)}`,
);
throw new InvalidTableError("[withdrawal]", withdrawal);
}
assertKnownKeys(withdrawal, WITHDRAWAL_KEYS, "in [withdrawal]");

const optional = <T>(
key: string,
Expand Down Expand Up @@ -1018,11 +1135,20 @@ const assertAccountsDrive = (config: Config) => {
assertAccountsDriveLength(config, accountsDrive, size);
};

const CONFIG_KEYS: readonly string[] = [
"drives",
"machine",
"nvrams",
"sdk",
"withdrawal",
];

export const parse = (str: string[]): Config => {
let toml: TomlTable = {};
for (const s of str) {
toml = mergeTomlTables(toml, parseToml(s));
}
assertKnownKeys(toml, CONFIG_KEYS, "at the top level");

const drives = parseDrives(toml.drives);
const nvrams = parseNvrams(toml.nvrams);
Expand Down
11 changes: 7 additions & 4 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,18 @@ const program = new Command()
.addCommand(createStatusCommand())
.addCommand(createWithdrawCommand());

// Global error handling
process.on("uncaughtException", (err) => {
// Global error handling. Node turns an unhandled rejection, such as an error from an async
// command, into an uncaught exception, but Bun, which runs the compiled binaries, doesn't.
const handleError = (err: unknown) => {
if (process.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", handleError);
process.on("unhandledRejection", handleError);

program.parse();
Loading
Loading