diff --git a/.changeset/cyan-walls-go.md b/.changeset/cyan-walls-go.md new file mode 100644 index 0000000..a845151 --- /dev/null +++ b/.changeset/cyan-walls-go.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/splice-contract-check.md b/.changeset/splice-contract-check.md new file mode 100644 index 0000000..edddeda --- /dev/null +++ b/.changeset/splice-contract-check.md @@ -0,0 +1,9 @@ +--- +'@bootnodedev/canton-barebones': minor +--- + +`validate` now checks that the pinned Splice release still defines everything the +wrapper's compose overrides address by name, and fails naming the mismatch. This +turns a Splice upgrade from a guess into a check: raise `splice.tag`, run +`validate`, and a renamed service or a moved nginx route template is reported +instead of silently producing a stack that starts and misbehaves. diff --git a/README.md b/README.md index fe68ff5..b5983af 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ A Canton network here has three kinds of pieces: ```jsonc { "version": 1, - "splice": { "repo": "canton-network/splice", "tag": "0.6.11" }, // which Splice version to download + "splice": { "repo": "canton-network/splice", "tag": "0.6.11" }, // which Splice version to download (see Upgrading Splice) "composeProjectName": "canton-barebones", // Docker Compose project name "dockerNetwork": "cantonBarebones", // Docker network name "persistence": { "mode": "persistent" }, // "persistent" keeps volumes; "ephemeral" wipes on reset @@ -205,7 +205,7 @@ The binary is `canton-barebones `; the `npm run ` scripts wrap | ----------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------ | | `init [--force]` | Scaffold the config and compose override into the project | Writes `canton-barebones.config.json` and `splice-localnet-overrides.yaml` (existing files are skipped unless `--force`) | no | | `setup` | Download the pinned Splice LocalNet source | Writes `.generated/splice/…` on first run | no | -| `validate` | Validate the config and resolved Splice paths | Writes `.generated/localnet.env`; downloads Splice on first run | no | +| `validate` | Validate the config, the resolved Splice paths, and that the pinned Splice still fits this wrapper | Writes `.generated/localnet.env`; downloads Splice on first run | no | | `start` | Start the stack (`docker compose up -d`) | Starts containers, creates volumes and the Docker network | yes | | `stop` | Stop containers, keep volumes (`docker compose down`) | Removes containers; data volumes are preserved | yes | | `reset` | Stop containers and remove volumes (`docker compose down -v`) | **Deletes all stack data** | yes | @@ -246,6 +246,15 @@ Non-obvious behaviors worth knowing before automating against the stack: - **UIs go through nginx**, published on ports `2000`/`3000`/`4000`, not as per-UI host ports. `*.localhost` hostnames resolve to `127.0.0.1` automatically. - **Config changes apply on the next `start`** — nothing reacts to the file while the stack is running. +## Upgrading Splice + +Point `splice.tag` in `canton-barebones.config.json` at the new version, then: + +```bash +canton-barebones validate # checks that the new Splice works with canton-barebones +canton-barebones start # if validate passed +``` + ## Troubleshooting | Symptom | Cause | Fix | @@ -256,6 +265,7 @@ Non-obvious behaviors worth knowing before automating against the stack: | Port already in use (e.g. `2000`, `4903`, `5432`) | Another stack (or the same one) is already up on that port | `stop` the running stack, or free the port | | Containers are up but a participant does not respond | A bound port is not the same as a live backend | Check `readyz` (see [Verifying the stack](#verifying-the-stack)) | | Stale or corrupted state after config churn | Volumes hold old data | `reset` to wipe volumes, then `start` | +| `The pinned Splice …@X.Y.Z is not compatible …` | That Splice release moved something this wrapper addresses by name | Pin a known-good tag, or open an issue (see [Upgrading Splice](#upgrading-splice)) | | Anything under `.generated/` looks wrong | It is disposable | Delete `.generated/` — it is rebuilt on the next `start` | ## Development diff --git a/bin/canton-barebones.js b/bin/canton-barebones.js index 3bfcf18..e28c9bf 100755 --- a/bin/canton-barebones.js +++ b/bin/canton-barebones.js @@ -15,6 +15,7 @@ import { stopStackByProjectName, writeLocalnetEnv, } from '../src/compose.js'; +import { checkSpliceContract } from '../src/splice-contract.js'; import { isJsonMode, printError, printResult, setJsonMode } from '../src/output.js'; // Prints the CLI contract without requiring Docker or a valid LocalNet checkout. @@ -24,7 +25,7 @@ function usage() { Commands: init Scaffold config and compose overrides into the project setup Fetch the pinned Splice LocalNet source - validate Validate config and LocalNet paths + validate Validate config, LocalNet paths and Splice compatibility compose Run docker compose with the configured LocalNet files start Start the stack stop Stop the stack and keep volumes @@ -86,12 +87,28 @@ function main() { return; case 'validate': { const runtimeEnvPath = writeLocalnetEnv(config); + // Fail before reporting the plan: a plan is meaningless if the pinned + // Splice no longer defines what the overrides address (see + // src/splice-contract.js), and the mismatch is far easier to act on here + // than as a Docker error, or a silent no-op, at start time. + const mismatches = checkSpliceContract(config); + if (mismatches.length > 0) { + throw new Error( + `The pinned Splice ${config.splice.repo}@${config.splice.tag} is not compatible with this version of canton-barebones:\n` + + mismatches.map(mismatch => ` - ${mismatch}`).join('\n') + ); + } // The resolved runtime plan is included so a consumer can see exactly what // the config will launch (profiles, headless validators, participant env) // without having to start the stack. printResult( { - splice: { repo: config.splice.repo, tag: config.splice.tag, imageTag: config.imageTag }, + splice: { + repo: config.splice.repo, + tag: config.splice.tag, + imageTag: config.imageTag, + compatible: true, + }, config: { version: config.version, composeProjectName: config.composeProjectName, @@ -111,7 +128,7 @@ function main() { }, () => { console.log(`Config OK: ${config.configPath}`); - console.log(`Splice: ${config.splice.repo}@${config.splice.tag}`); + console.log(`Splice: ${config.splice.repo}@${config.splice.tag} (compatible)`); console.log(`LocalNet: ${config.localnetDir}`); console.log(`Runtime env: ${runtimeEnvPath}`); console.log(`LocalNet override: ${config.localnetOverridePath}`); diff --git a/package-lock.json b/package-lock.json index 9b71c18..6fa6b6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { "name": "@bootnodedev/canton-barebones", - "version": "0.2.2", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bootnodedev/canton-barebones", - "version": "0.2.2", + "version": "0.3.0", + "license": "Apache-2.0", "dependencies": { + "js-yaml": "^4.1.0", "zod": "^4.4.3" }, "bin": { @@ -428,7 +430,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-union": { @@ -761,7 +762,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, "funding": [ { "type": "github", diff --git a/package.json b/package.json index e84a979..d95091c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "node": ">=22" }, "dependencies": { + "js-yaml": "^4.1.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/scripts/config-validation.test.js b/scripts/config-validation.test.js index ff2de8e..6185a07 100644 --- a/scripts/config-validation.test.js +++ b/scripts/config-validation.test.js @@ -2,6 +2,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { parseComposeProjectName, parseConfig } from '../src/config.js'; +import scaffoldedConfig from '../templates/canton-barebones.config.json' with { type: 'json' }; // Deep-clones a config object so each case can mutate a copy without affecting // the shared valid baseline. @@ -22,25 +23,9 @@ function assertRejects(raw, expectedFragment) { ); } -// The scaffolded default shipped by `init`: version 1, a pinned Splice source, -// persistent volumes, app-provider off, app-user enabled headless (backend on, -// UIs off), every SV web UI flag present (the SV backend itself is not in the config — it -// is required infrastructure that always runs), and all network tools off. This -// is the baseline; every negative case below clones it and breaks a single rule, -// so a failure points to one validation concern. -const validConfig = { - version: 1, - splice: { repo: 'canton-network/splice', tag: '0.6.11' }, - composeProjectName: 'canton-barebones', - dockerNetwork: 'cantonBarebones', - persistence: { mode: 'persistent' }, - validators: { - appProvider: { enabled: false, ui: false }, - appUser: { enabled: true, ui: false }, - }, - sv: { scanUI: true, svUI: true, walletUI: true }, - networkTools: { console: false, multiSync: false, swaggerUI: false }, -}; +// Baseline for every case below: the real template `init` scaffolds, so the +// happy path asserts on the config users actually get. +const validConfig = scaffoldedConfig; // A richer config: app-user enabled with its UIs on, and every network tool on. // Exercises the enabled+ui combination and the tool flags together. diff --git a/scripts/runtime-plan.test.js b/scripts/runtime-plan.test.js index 1ab15f7..411ed26 100644 --- a/scripts/runtime-plan.test.js +++ b/scripts/runtime-plan.test.js @@ -15,7 +15,7 @@ import { deriveRuntimePlan, writeLocalnetEnv } from '../src/compose.js'; // disable flags one at a time from a fully-on baseline. function baseConfig(generatedDir) { return { - imageTag: '0.6.11', + imageTag: 'test-image-tag', // placeholder: no case here asserts IMAGE_TAG composeProjectName: 'canton-barebones', dockerNetwork: 'cantonBarebones', localnetDir: '/tmp/localnet', diff --git a/scripts/smoke.js b/scripts/smoke.js index ff1dfda..bcdd794 100644 --- a/scripts/smoke.js +++ b/scripts/smoke.js @@ -3,6 +3,8 @@ import fs from 'node:fs'; import { loadConfig } from '../src/config.js'; import { deriveRuntimePlan, runDockerCompose, writeLocalnetEnv } from '../src/compose.js'; +import { checkSpliceContract } from '../src/splice-contract.js'; +import scaffoldedConfig from '../templates/canton-barebones.config.json' with { type: 'json' }; const config = loadConfig(); const runtimeEnvPath = writeLocalnetEnv(config); @@ -13,8 +15,10 @@ const localnetOverride = fs.readFileSync(config.localnetOverridePath, 'utf8'); // It requires Docker and a Splice checkout, so it runs under "test:e2e", not the // unit test suite. assert.equal(config.imageTag.length > 0, true); -assert.equal(config.splice.repo, 'canton-network/splice'); -assert.equal(config.splice.tag, '0.6.11'); +// "test:e2e" runs `init` first, so the loaded config is the scaffolded template: +// the pin reaching the project intact is what these two assert. +assert.equal(config.splice.repo, scaffoldedConfig.splice.repo); +assert.equal(config.splice.tag, scaffoldedConfig.splice.tag); assert.equal(config.persistence.mode, 'persistent'); assert.deepEqual(config.validators, { appProvider: { enabled: false, ui: false }, @@ -42,6 +46,10 @@ assert.match(localnetOverride, /max-size: "25m"/); assert.match(localnetOverride, /max-file: "3"/); assert.match(localnetOverride, /LOG_LEVEL_STDOUT: "\$\{LOG_LEVEL:-INFO\}"/); +// Against the real downloaded checkout, not a fixture: the pinned release must +// still define every service, profile and mount target the overrides name. +assert.deepEqual(checkSpliceContract(config), []); + runDockerCompose(config, ['config', '--quiet'], { stdio: 'pipe' }); console.log('Smoke test OK'); diff --git a/scripts/splice-contract.test.js b/scripts/splice-contract.test.js new file mode 100644 index 0000000..ae6d138 --- /dev/null +++ b/scripts/splice-contract.test.js @@ -0,0 +1,132 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { findContractMismatches } from '../src/splice-contract.js'; + +// A fake Splice compose.yaml, cut down to only the parts the check reads. It +// copies the real shape at the pinned release: +// - `nginx` and the three SV web UI services, because those are the services +// templates/runtime-overrides.yaml addresses by name. +// - nginx's route-template mounts written the way Splice writes them, with the +// validator's *_PROFILE value spliced into the middle of the filename, so +// `app-user.c${APP_USER_PROFILE}f.template` spells "conf" only while that +// validator is on. Our override shadows that "on" spelling. +// - one profile per service, covering the names the wrapper can select. +function spliceModel() { + return { + services: { + nginx: { + volumes: [ + '${LOCALNET_DIR}/conf/nginx/app-provider.conf:/etc/nginx/templates/app-provider.c${APP_PROVIDER_PROFILE}f.template', + '${LOCALNET_DIR}/conf/nginx/app-user.conf:/etc/nginx/templates/app-user.c${APP_USER_PROFILE}f.template', + ], + }, + 'scan-web-ui': { profiles: ['sv'] }, + 'sv-web-ui': { profiles: ['sv'] }, + 'wallet-web-ui-sv': { profiles: ['sv'] }, + 'wallet-web-ui-app-user': { profiles: ['app-user'] }, + }, + }; +} + +// A fake templates/runtime-overrides.yaml: the services it pins to 0 replicas, +// and the two nginx mount targets it shadows. Its targets carry no variable, +// which is the point of the comparison: the override only replaces Splice's +// mount while Splice's own target resolves to this same string. +function overrideModel() { + return { + services: { + nginx: { + volumes: [ + '${APP_PROVIDER_NGINX_ROUTES}:/etc/nginx/templates/app-provider.conf.template', + '${APP_USER_NGINX_ROUTES}:/etc/nginx/templates/app-user.conf.template', + ], + }, + 'scan-web-ui': { deploy: { replicas: '${SCAN_WEB_UI_REPLICAS}' } }, + 'sv-web-ui': { deploy: { replicas: '${SV_WEB_UI_REPLICAS}' } }, + 'wallet-web-ui-sv': { deploy: { replicas: '${WALLET_WEB_UI_SV_REPLICAS}' } }, + }, + }; +} + +// The profiles the wrapper passes to `--profile`, a subset of what the model +// above declares, so the compatible case has nothing to report. +const profiles = ['sv', 'app-user']; + +// Runs the check on the compatible pair above, with `overrides` replacing one +// input so each case states only what it breaks. +function check(overrides = {}) { + return findContractMismatches({ + spliceModel: spliceModel(), + overrideModel: overrideModel(), + profiles, + ...overrides, + }); +} + +// Scenario: what the wrapper names in Splice's compose model must still be there. +// Compose merges by name and silently accepts a name that matches nothing, so +// each case below is a real breakage that would otherwise surface as a stack +// that starts and misbehaves rather than as an error. +describe('findContractMismatches', () => { + // The pinned release defines every service, profile and mount target the + // wrapper addresses, so a compatible Splice must report nothing at all. + it('reports nothing when the pinned Splice still defines everything', () => { + assert.deepEqual(check(), []); + }); + + // Splice renaming `scan-web-ui` is the silent failure this check exists for: + // the override's 0-replica pin would apply to a service Compose invents from + // the override alone, so the real scan UI keeps starting even when the user + // turned it off in their config. + it('reports a service the override targets but Splice renamed', () => { + const model = spliceModel(); + model.services['scan-ui'] = model.services['scan-web-ui']; + delete model.services['scan-web-ui']; + const mismatches = check({ spliceModel: model }); + assert.equal(mismatches.length, 1); + assert.match(mismatches[0], /service "scan-web-ui"/); + }); + + // A profile the wrapper still passes to `--profile` but Splice dropped: the + // selection would bring up nothing instead of that validator's UI bundle. + it('reports a profile the wrapper selects but Splice dropped', () => { + const model = spliceModel(); + delete model.services['wallet-web-ui-app-user'].profiles; + const mismatches = check({ spliceModel: model }); + assert.equal(mismatches.length, 1); + assert.match(mismatches[0], /profile "app-user"/); + }); + + // Splice moving a validator's route template out from under our mount. Compose + // deduplicates volumes by target, so a target that no longer matches adds a + // second mount instead of replacing Splice's: nginx would keep the real routes + // of a headless validator and die on its missing UI containers at startup. + it('reports an nginx mount target Splice no longer uses', () => { + const model = spliceModel(); + model.services.nginx.volumes = [ + '${LOCALNET_DIR}/conf/nginx/app-user.conf:/etc/nginx/routes/app-user.c${APP_USER_PROFILE}f.template', + ]; + const mismatches = check({ spliceModel: model }); + assert.equal(mismatches.length, 2); + for (const mismatch of mismatches) { + assert.match(mismatch, /nginx mount target/); + } + }); + + // The "off" spelling must not count as a match. Splice's target only reads + // ".conf.template" while the validator is on; with APP_USER_PROFILE=off it + // reads ".coff.template", which nginx renders but never includes. If the check + // compared the raw string it would accept a Splice that no longer offers the + // target we actually shadow. + it('matches Splice targets on their profile-on spelling only', () => { + const model = spliceModel(); + model.services.nginx.volumes = [ + '${LOCALNET_DIR}/conf/nginx/app-provider.conf:/etc/nginx/templates/app-provider.c${APP_PROVIDER_PROFILE}f.template', + '${LOCALNET_DIR}/conf/nginx/app-user.conf:/etc/nginx/templates/app-user.coff.template', + ]; + const mismatches = check({ spliceModel: model }); + assert.equal(mismatches.length, 1); + assert.match(mismatches[0], /app-user\.conf\.template/); + }); +}); diff --git a/src/splice-contract.js b/src/splice-contract.js new file mode 100644 index 0000000..b4eff20 --- /dev/null +++ b/src/splice-contract.js @@ -0,0 +1,80 @@ +// Checks that the pinned Splice release still defines everything this wrapper +// addresses by name in templates/runtime-overrides.yaml. +// +// Docker Compose merges by name and never complains about a name that matches +// nothing, so a Splice release that renames a service does not fail: the +// override lands on nothing and the stack comes up misbehaving. These checks +// turn that silence into an error. +// +// The names are read from the files that emit them rather than restated here, so +// a check cannot drift from what we hand to Docker. Nothing else about Splice is +// asserted; it is free to move anything we do not name. +import fs from 'node:fs'; +import path from 'node:path'; +import yaml from 'js-yaml'; + +import { allLocalnetProfiles } from './compose.js'; +import { resolveFromPackage } from './paths.js'; + +// The target of a short-form `volumes:` entry ("source:target[:mode]"). +function mountTarget(entry) { + return String(entry).split(':')[1] ?? ''; +} + +// Splice spells a validator's mount target around its *_PROFILE value, so +// app-user.c${APP_USER_PROFILE}f.template reads "conf" only while that validator +// is on, and something nginx ignores when it is off. Our override shadows the +// "on" spelling, so that is the spelling these comparisons use. +function withProfilesOn(target) { + return target.replaceAll(/\$\{[A-Z_]+_PROFILE\}/g, 'on'); +} + +// Reports one message per mismatch, empty when the release is compatible. Takes +// parsed models so it can be exercised without a checkout on disk. +export function findContractMismatches({ spliceModel, overrideModel, profiles }) { + const spliceServices = spliceModel.services ?? {}; + const mismatches = []; + + // Without the real service, the override's replicas pin and nginx alias apply + // to a service Compose invents from the override alone and never starts. + for (const name of Object.keys(overrideModel.services ?? {})) { + if (!(name in spliceServices)) { + mismatches.push(`service "${name}" is overridden by this wrapper but the pinned Splice does not define it`); + } + } + + // A `--profile` selection that matches nothing starts nothing. + const spliceProfiles = new Set(Object.values(spliceServices).flatMap(service => service?.profiles ?? [])); + for (const profile of profiles) { + if (!spliceProfiles.has(profile)) { + mismatches.push(`profile "${profile}" is selectable by this wrapper but the pinned Splice does not define it`); + } + } + + // Compose deduplicates volumes by target, which is how the override replaces a + // validator's nginx routes. A target that no longer matches adds a second mount + // instead, leaving nginx with the routes of a validator whose UIs are not + // running: the startup failure the override exists to prevent. + const spliceTargets = new Set( + (spliceServices.nginx?.volumes ?? []).map(entry => withProfilesOn(mountTarget(entry))) + ); + for (const entry of overrideModel.services?.nginx?.volumes ?? []) { + const target = mountTarget(entry); + if (!spliceTargets.has(target)) { + mismatches.push(`nginx mount target "${target}" is overridden by this wrapper but the pinned Splice does not mount anything there`); + } + } + + return mismatches; +} + +// Reads the pinned checkout and the wrapper's own override, then compares them. +// No variable interpolation is applied: every name compared is a YAML literal. +export function checkSpliceContract(config) { + const parse = filePath => yaml.load(fs.readFileSync(filePath, 'utf8')) ?? {}; + return findContractMismatches({ + spliceModel: parse(path.resolve(config.localnetDir, 'compose.yaml')), + overrideModel: parse(resolveFromPackage('templates/runtime-overrides.yaml')), + profiles: allLocalnetProfiles, + }); +}