diff --git a/.changeset/stop-broken-config.md b/.changeset/stop-broken-config.md new file mode 100644 index 0000000..d67a843 --- /dev/null +++ b/.changeset/stop-broken-config.md @@ -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 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. diff --git a/README.md b/README.md index 9ab1e7c..db04147 100644 --- a/README.md +++ b/README.md @@ -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 @@ -180,6 +180,8 @@ The binary is `canton-barebones `; the `npm run ` scripts wrap | `logs [args…]` | Show logs (`docker compose logs`); extra args pass through | none | yes | | `compose ` | 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: diff --git a/bin/canton-barebones.js b/bin/canton-barebones.js index a9cb32b..3bfcf18 100755 --- a/bin/canton-barebones.js +++ b/bin/canton-barebones.js @@ -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'; @@ -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) { @@ -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; diff --git a/scripts/config-validation.test.js b/scripts/config-validation.test.js index bfe998d..ff2de8e 100644 --- a/scripts/config-validation.test.js +++ b/scripts/config-validation.test.js @@ -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. @@ -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', () => { diff --git a/src/compose.js b/src/compose.js index 76aa818..abb3791 100644 --- a/src/compose.js +++ b/src/compose.js @@ -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. @@ -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, @@ -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', }); diff --git a/src/config.js b/src/config.js index d774873..4303bf5 100644 --- a/src/config.js +++ b/src/config.js @@ -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