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/stop-broken-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bootnodedev/cbn": patch
---

`stop` no longer requires a fully valid config: it reads only `composeProjectName` and tears the stack down by Compose project label (`docker compose -p <name> down --remove-orphans`), so a broken config can still stop a running stack. If `composeProjectName` itself is missing or invalid, `stop` fails with the usual config error. All other Docker commands still validate the full config.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ npm run init
canton-barebones.config.json ← the stack config you edit
splice-localnet-overrides.yaml ← local Docker Compose tweaks

npm run start (and stop / status / logs / validate — anything that reads config)
npm run start (and status / logs / validate — anything that reads config)
└─ loadConfig() (src/config.js) reads + validates your config
├─ ensureSpliceCheckout() (src/splice.js) downloads Splice on first run
└─ runDockerCompose() (src/compose.js) generates runtime files, runs compose
Expand Down Expand Up @@ -180,6 +180,8 @@ The binary is `canton-barebones <command>`; the `npm run <command>` scripts wrap
| `logs [args…]` | Show logs (`docker compose logs`); extra args pass through | none | yes |
| `compose <args…>` | Run docker compose with the configured LocalNet files; **no args prints the computed docker command** (dry run) | depends on the args | yes |

**`stop` works even with a broken config.** It only reads `composeProjectName`, which is all Docker Compose needs to find the stack's containers (it matches them by project label). If that field is missing or invalid, `stop` fails with the usual config error. Every other Docker command validates the full config first.

**Exit codes & output.** Every command exits `0` on success and `1` on failure, printing the error message to stderr.

**`--json`** (on `validate`, `setup`, `status`) switches to machine-readable output: success goes to stdout, and on failure a `{ "ok": false, "error": "…" }` object goes to stderr, still exiting `1`. Use `validate --json` to see exactly what a config resolves to **without starting anything** — its `plan` field lists the compose profiles, headless validators, disabled SV UIs, and participant env:
Expand Down
22 changes: 15 additions & 7 deletions bin/canton-barebones.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
#!/usr/bin/env node
// Command-line entry point. It reads the sub-command (init, start, stop, ...) and
// runs it. Every command except `init`/`help` first loads and validates the config
// (which also downloads Splice on first run), then shells out to Docker Compose.
// The heavy lifting lives in src/*; this file is just the dispatcher.
import { loadConfig } from '../src/config.js';
// runs it. Every command except `init`/`help`/`stop` first loads and validates the
// config (which also downloads Splice on first run), then shells out to Docker
// Compose. `stop` only reads `composeProjectName` so a broken config can still
// bring the stack down. The heavy lifting lives in src/*; this file is just the
// dispatcher.
import { loadComposeProjectName, loadConfig } from '../src/config.js';
import { init } from '../src/init.js';
import {
allLocalnetProfiles,
deriveRuntimePlan,
dockerComposeArgs,
runDockerCompose,
stopStackByProjectName,
writeLocalnetEnv,
} from '../src/compose.js';
import { isJsonMode, printError, printResult, setJsonMode } from '../src/output.js';
Expand Down Expand Up @@ -56,6 +59,14 @@ function main() {
return;
}

// `stop` deliberately skips loadConfig(): tearing the stack down must keep
// working when the config is broken, and Compose only needs the project name
// to find the containers. Every other Docker command still validates fully.
if (command === 'stop') {
stopStackByProjectName(loadComposeProjectName());
return;
}

const config = loadConfig();

switch (command) {
Expand Down Expand Up @@ -119,9 +130,6 @@ function main() {
case 'start':
runDockerCompose(config, ['up', '-d', '--remove-orphans']);
return;
case 'stop':
runDockerCompose(config, ['down', '--remove-orphans'], { profiles: allLocalnetProfiles });
return;
case 'reset':
runDockerCompose(config, ['down', '-v', '--remove-orphans'], { profiles: allLocalnetProfiles });
return;
Expand Down
43 changes: 42 additions & 1 deletion scripts/config-validation.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { parseConfig } from '../src/config.js';
import { parseComposeProjectName, parseConfig } from '../src/config.js';

// Deep-clones a config object so each case can mutate a copy without affecting
// the shared valid baseline.
Expand Down Expand Up @@ -197,6 +197,47 @@ describe('sv web UI flags', () => {
});
});

// Scenario: the minimal parse used by `stop`. `stop` must be able to tear down
// a running stack even when the config is otherwise broken, so
// parseComposeProjectName only checks the one field Docker Compose needs to
// find the containers; everything else, including the version gate, is
// deliberately ignored.
describe('parseComposeProjectName (minimal parse for stop)', () => {
// Happy path: the scaffolded default's project name comes back verbatim.
it('extracts the project name from a valid config', () => {
assert.equal(parseComposeProjectName(clone(validConfig)), 'canton-barebones');
});

// The whole point of this parse: a config that parseConfig would reject
// (unknown key, wrong-typed flag, outdated version) must still yield the
// project name so `stop` can run.
it('tolerates a config that full validation rejects', () => {
const raw = clone(validConfig);
raw.version = 0; // outdated version, would trip the version gate
raw.staleKey = true; // unknown field, would trip strict parsing
raw.networkTools.console = 'true'; // wrong type, would trip the schema
assertRejects(raw, /not compatible/); // sanity check: parseConfig does reject it
assert.equal(parseComposeProjectName(raw), 'canton-barebones');
});

// Without a usable project name Compose could target the wrong stack (or
// nothing), so a missing field must fail with the standard invalid-config
// message naming the field.
it('rejects a missing composeProjectName', () => {
const raw = clone(validConfig);
delete raw.composeProjectName;
assert.throws(() => parseComposeProjectName(raw), /composeProjectName/);
});

// An empty string would make docker compose fall back to an unrelated default
// project, so it is rejected the same way the full schema rejects it.
it('rejects an empty composeProjectName', () => {
const raw = clone(validConfig);
raw.composeProjectName = '';
assert.throws(() => parseComposeProjectName(raw), /composeProjectName/);
});
});

// Scenario: validator enabled/ui consistency. A validator's UIs are reached
// through its backend, so `ui` cannot be on while the validator is disabled.
describe('validator enabled/ui consistency', () => {
Expand Down
34 changes: 27 additions & 7 deletions src/compose.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ import { spawnSync } from 'node:child_process';

import { resolveFromPackage } from './paths.js';

// Every profile this wrapper can select. Used by teardown (stop/reset) to make
// sure containers from any profile are removed, not just the ones currently up.
// Every profile this wrapper can select. Used by `reset` to make sure containers
// from any profile are removed, not just the ones currently up. (`stop` does not
// need profiles: it tears down by project name, see stopStackByProjectName.)
export const allLocalnetProfiles = ['app-provider', 'app-user', 'sv', 'swagger-ui', 'console', 'multi-sync'];

// Compose profile that starts each validator's UI bundle.
Expand Down Expand Up @@ -193,11 +194,7 @@ export function dockerComposeArgs(config, options = {}) {
// Runs Docker Compose with inherited stdio by default so users see startup progress.
export function runDockerCompose(config, commandArgs, options = {}) {
const args = [...dockerComposeArgs(config, options), ...commandArgs];
if (options.printCommand) {
console.log(['docker', ...args].join(' '));
}

const result = spawnSync('docker', args, {
return runDocker(args, {
cwd: config.localnetDir,
env: {
...process.env,
Expand All @@ -207,6 +204,29 @@ export function runDockerCompose(config, commandArgs, options = {}) {
LOCALNET_DIR: config.localnetDir,
LOCALNET_ENV_DIR: config.localnetEnvDir,
},
...options,
});
}

// Stops the stack knowing only its Compose project name. Compose matches the
// containers by their project label, so no compose files, env files, or profiles
// are needed, which lets `stop` work even when the rest of the config (or the
// Splice checkout) is broken. Volumes are kept, same as the file-based `down`.
export function stopStackByProjectName(projectName) {
return runDocker(['compose', '--project-name', projectName, 'down', '--remove-orphans'], {});
}

// Shared `docker` spawn wrapper: inherits stdio by default so users see compose
// progress, turns a missing binary into an actionable dependency error, and
// surfaces compose's stderr on a non-zero exit.
function runDocker(args, options = {}) {
if (options.printCommand) {
console.log(['docker', ...args].join(' '));
}

const result = spawnSync('docker', args, {
cwd: options.cwd,
env: options.env,
stdio: options.stdio ?? 'inherit',
encoding: 'utf8',
});
Expand Down
23 changes: 23 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ export function parseConfig(raw) {
return result.data;
}

// Extracts `composeProjectName` from a raw config, ignoring every other field
// (including `version`: an outdated config still names a stack worth stopping).
// Pure, like parseConfig, so it can be unit tested directly.
export function parseComposeProjectName(raw) {
const name = raw?.composeProjectName;
if (typeof name !== 'string' || name.length === 0) {
throw new Error(
'Invalid canton-barebones.config.json:\n - composeProjectName: composeProjectName must be a non-empty string'
);
}
return name;
}

// Reads only `composeProjectName` from the config file, skipping full schema
// validation. Used by `stop`: a broken config must not leave a running stack
// that the tool can no longer bring down, and the project name is the one field
// Docker Compose needs to find the stack's containers (it matches them by
// project label, no compose files required).
export function loadComposeProjectName() {
assertFileExists(configPath, 'Config file (run "canton-barebones init" first)');
return parseComposeProjectName(readJson(configPath));
}

// Loads canton-barebones.config.json from the project directory, validates it,
// and resolves all runtime paths and the pinned Splice checkout. Mapping the
// config onto compose profiles/env/overrides lives in compose.js, next to the
Expand Down
Loading