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/sv-ui-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bootnodedev/cbn": minor
---

Add per-UI `sv` config flags (`scanUI`, `svUI`, `walletUI`) to toggle the SV-facing web UIs. A static override shipped with the package (`templates/runtime-overrides.yaml`) pins each disabled UI to 0 replicas and aliases its hostname onto nginx — driven purely by env vars — so the stack stays healthy and the SV's API routes keep working either way.
37 changes: 25 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Run `npm run init` once to scaffold `canton-barebones.config.json`, then edit it

A Canton network here has three kinds of pieces:

- **SV** (Super Validator) — the node that runs the _global synchronizer_, the shared backbone all participants connect to. It is required infrastructure, so it always runs fully (backend + its web dashboards). It has nothing to configure.
- **SV** (Super Validator) — the node that runs the _global synchronizer_, the shared backbone all participants connect to. It is required infrastructure, so its backend always runs; only its web dashboards are configurable (the `sv` flags).
- **Validators** — the participant nodes that run your apps. Splice's LocalNet ships two fixed slots, `appProvider` and `appUser`. Each has `enabled` (run its backend) and `ui` (also expose its web UIs).
- **Network tools** — utilities that work across the nodes: the Canton `console`, `multiSync` (adds a second, local synchronizer), and `swaggerUI` (API docs).

Expand All @@ -91,6 +91,8 @@ A Canton network here has three kinds of pieces:
"appUser": { "enabled": true, "ui": false }, // default: backend on, UIs off (headless)
},

"sv": { "scanUI": true, "svUI": true, "walletUI": true }, // the SV's web UIs, toggleable per UI

"networkTools": { "console": false, "multiSync": false, "swaggerUI": false },
}
```
Expand All @@ -99,6 +101,7 @@ Rules:

- `ui` needs the backend, so it can only be `true` when that validator's `enabled` is `true`.
- A validator's UIs come as one bundle (wallet + ANS): it is on-or-off per validator, not per individual UI. `enabled: true, ui: false` runs it **headless** — backend only, reached on its direct API ports.
- The SV backend is not configurable — it is required infrastructure and always runs. Its web UIs are, individually: an `sv` flag off skips that UI container, while the API routes on the SV's nginx port keep working (a disabled UI's URL answers 502).
- Config changes take effect on the next `start`.

### How your config becomes running services
Expand All @@ -110,25 +113,28 @@ Rules:
| always | `--profile sv` runs the SV fully and, with it, the shared postgres/canton/splice/nginx |
| `validators.*.enabled` | switches that validator's backend on/off via an env var |
| `validators.*.ui` | on → also starts that validator's UIs; off (but enabled) → nginx is told to skip that validator so it runs headless |
| an `sv` UI flag off | env vars pin that UI to 0 replicas and alias its hostname onto nginx (static `templates/runtime-overrides.yaml`) |
| a `networkTools` flag on | starts that tool via its profile |

So the default config launches the SV plus a headless `appUser`, and each flag you flip adds more. (For _why_ a headless validator needs special handling, see [Design notes](#design-notes).)

## UIs and endpoints

Assuming every flag is on, this is everything the stack exposes. Each participant gets its own nginx port (`sv` 4000, `app-provider` 3000, `app-user` 2000), and its web UIs and ledger APIs are reached through hostnames on that port. A validator's routes only appear when its `ui: true`; the SV is always on. All `*.localhost` names resolve to `127.0.0.1` automatically.
Assuming every flag is on, this is everything the stack exposes. Each participant gets its own nginx port (`sv` 4000, `app-provider` 3000, `app-user` 2000), and its web UIs and ledger APIs are reached through hostnames on that port. A validator's routes only appear when its `ui: true`; the SV's APIs are always on and its web UIs follow the `sv` flags. All `*.localhost` names resolve to `127.0.0.1` automatically.

> In **headless** mode (`enabled: true, ui: false`) a validator's nginx routes below are not exposed. Reach its ledger API on the direct participant ports instead: JSON on `<prefix>975`, gRPC on `<prefix>901`, where the prefix is `2`/`3`/`4` — e.g. `localhost:2975` (JSON) and `localhost:2901` (gRPC) for app-user.

### SV (port 4000, always on)
### SV (port 4000, backend always on)

The three web UIs each have an `sv` config flag; turning one off makes its URL answer 502 while the API rows keep working.

| Surface | URL |
| ----------------------- | ----------------------------------------- |
| SV operations dashboard | http://sv.localhost:4000 |
| Scan (network explorer) | http://scan.localhost:4000 |
| Wallet | http://wallet.localhost:4000 |
| JSON Ledger API | http://canton.localhost:4000/v2 |
| OpenAPI spec | http://canton.localhost:4000/docs/openapi |
| Surface | URL | Requires |
| ----------------------- | ----------------------------------------- | ------------- |
| SV operations dashboard | http://sv.localhost:4000 | `sv.svUI` |
| Scan (network explorer) | http://scan.localhost:4000 | `sv.scanUI` |
| Wallet | http://wallet.localhost:4000 | `sv.walletUI` |
| JSON Ledger API | http://canton.localhost:4000/v2 | always |
| OpenAPI spec | http://canton.localhost:4000/docs/openapi | always |

### app-user (port 2000, requires `validators.appUser.ui: true`)

Expand Down Expand Up @@ -176,11 +182,11 @@ The binary is `canton-barebones <command>`; the `npm run <command>` scripts wrap

**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, and participant env:
**`--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:

```bash
canton-barebones validate --json
# { "ok": true, "plan": { "upProfiles": ["sv","app-user"], "headlessValidators": [], "nodeEnv": {…} }, … }
# { "ok": true, "plan": { "upProfiles": ["sv","app-user"], "headlessValidators": [], "disabledSvUIs": [], "nodeEnv": {…} }, … }

canton-barebones status --json # one JSON object per service, straight from docker compose
```
Expand Down Expand Up @@ -281,3 +287,10 @@ nginx renders an empty app-user.conf → no routes for it → starts fine.
```

The validator's backend still runs (it is driven by an env var, not nginx) and stays reachable on its direct API ports; only its web routing is dropped. See `templates/splice-localnet-overrides.yaml` and `src/compose.js` for the full detail.

### How a disabled SV web UI works (replicas + alias)

Turning off an SV web UI (`sv.scanUI` / `sv.svUI` / `sv.walletUI`) cannot reuse the empty-template trick above: Splice's `sv.conf` mixes the UI routes with **API routes** (scan API, SV admin API, canton JSON API) that proxy to the always-running `splice`/`canton` containers and must stay up, and the flags are per-UI rather than all-or-nothing. Instead, a **static** override shipped inside the package (`templates/runtime-overrides.yaml`, always applied) uses two other compose levers, driven purely by env vars that the wrapper writes to `.generated/localnet.env`:

1. `deploy.replicas: ${…_REPLICAS}` — `0` for a disabled UI, so compose never starts its container. (An override entry rather than `docker compose --scale`, which errors for services whose profile is not selected.)
2. nginx still resolves that UI's hostname at startup (`proxy_pass http://scan-web-ui:8080/` …) and would crash with "host not found in upstream" once no container owns the name. So nginx carries a **network alias** per UI whose _value_ comes from `${…_NGINX_ALIAS}`: for a disabled UI it is the real hostname — the name resolves onto the nginx container itself, nginx boots, and since nothing there listens on the UI port, browsing the disabled UI answers **502** while every API route on port 4000 keeps working. For an enabled UI it is an inert `<name>-unused` (static YAML has no conditionals, so the list entry always exists and only its value changes).
59 changes: 52 additions & 7 deletions scripts/config-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ 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), and all network tools off. The SV is not in the config — it is
// required infrastructure that always runs fully. This is the baseline; every
// negative case below clones it and breaks a single rule, so a failure points to
// one validation concern.
// UIs off), every SV web UI on (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' },
Expand All @@ -38,6 +38,7 @@ const validConfig = {
appProvider: { enabled: false, ui: false },
appUser: { enabled: true, ui: false },
},
sv: { scanUI: true, svUI: true, walletUI: true },
networkTools: { console: false, multiSync: false, swaggerUI: false },
};

Expand Down Expand Up @@ -89,11 +90,12 @@ describe('version gating', () => {
// Scenario: strictness. Unknown fields must fail loudly instead of being ignored,
// so typos and stale keys surface immediately.
describe('unknown fields', () => {
// A stale top-level key (e.g. the removed `sv`) must be rejected.
// A stale top-level key (e.g. a leftover from an older config shape) must be
// rejected.
it('rejects an unknown top-level field', () => {
const raw = clone(validConfig);
raw.sv = { modules: {} };
assertRejects(raw, /sv/);
raw.globalModules = {};
assertRejects(raw, /globalModules/);
});

// Strictness must extend into nested objects: the removed per-validator
Expand Down Expand Up @@ -152,6 +154,49 @@ describe('required and typed fields', () => {
});
});

// Scenario: the SV web UI flags. The SV backend always runs, so the `sv` section
// only carries its three per-UI flags; the section is required and each flag must
// be an explicit boolean.
describe('sv web UI flags', () => {
// Any combination of the three flags is valid — here scan on, the rest off —
// because each UI is independent (unlike a validator's all-or-nothing bundle).
it('accepts a partial set of SV UIs turned off', () => {
const raw = clone(validConfig);
raw.sv = { scanUI: true, svUI: false, walletUI: false };
assert.doesNotThrow(() => parseConfig(raw));
});

// `sv` is a required section: the flags default to nothing, so a config
// without them must fail rather than silently picking a behavior.
it('rejects a missing sv section', () => {
const raw = clone(validConfig);
delete raw.sv;
assertRejects(raw, /sv/);
});

// All three flags must be present — a missing one is a stale or typoed config.
it('rejects a missing sv UI flag', () => {
const raw = clone(validConfig);
delete raw.sv.walletUI;
assertRejects(raw, /walletUI/);
});

// Strictness inside the section: an unknown flag (e.g. an ANS UI, which only
// validators have) must be rejected.
it('rejects an unknown sv UI flag', () => {
const raw = clone(validConfig);
raw.sv.ansUI = true;
assertRejects(raw, /ansUI/);
});

// Flags must be explicit booleans; a string "false" is a common mistake.
it('rejects a non-boolean sv UI flag', () => {
const raw = clone(validConfig);
raw.sv.scanUI = 'false';
assertRejects(raw, /scanUI/);
});
});

// 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
106 changes: 106 additions & 0 deletions scripts/runtime-plan.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, after } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { deriveRuntimePlan, writeLocalnetEnv } from '../src/compose.js';

// A minimal config slice covering only what deriveRuntimePlan and
// writeLocalnetEnv read: the validator flags, the SV UI flags, the network tool
// flags, the identifiers echoed into the env file, and a directory to generate
// into. The baseline mirrors the scaffolded default — app-provider off, app-user
// headless, all SV UIs on, tools off — and each case below flips one lever.
function baseConfig(generatedDir) {
return {
imageTag: '0.6.11',
composeProjectName: 'canton-barebones',
dockerNetwork: 'cantonBarebones',
localnetDir: '/tmp/localnet',
localnetEnvDir: '/tmp/localnet/env',
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 },
generatedDir,
};
}

// A throwaway directory standing in for the project's .generated/, so the tests
// can inspect the env file the writer produces without touching the repo.
const generatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cbn-runtime-plan-'));
after(() => fs.rmSync(generatedDir, { recursive: true, force: true }));

// Parses a generated env file back into a key→value map so assertions can target
// individual variables instead of matching on raw file contents.
function readEnvFile(envPath) {
const entries = fs
.readFileSync(envPath, 'utf8')
.split('\n')
.filter(line => line.includes('='))
.map(line => line.split(/=(.*)/s).slice(0, 2));
return Object.fromEntries(entries);
}

// Scenario: mapping the SV UI flags onto the plan. Each disabled flag must
// surface as its Docker Compose service name — that name is what the static
// runtime override targets (both the replicas pin and the nginx alias), and it
// is echoed in `validate --json` for the user.
describe('deriveRuntimePlan sv UI flags', () => {
// All flags on (the default) → nothing disabled, and the profile set stays the
// usual sv-only baseline. Guards against the sv section accidentally growing
// profile side effects: SV UIs ride the always-on `sv` profile.
it('reports no disabled SV UIs when every flag is on', () => {
const plan = deriveRuntimePlan(baseConfig(generatedDir));
assert.deepEqual(plan.disabledSvUIs, []);
assert.deepEqual(plan.upProfiles, ['sv']);
});

// Turning off scanUI and walletUI while keeping svUI must list exactly the two
// matching service names — proving the flag→service mapping and that flags are
// independent (unlike a validator's all-or-nothing ui bundle).
it('maps each disabled flag to its compose service name', () => {
const config = baseConfig(generatedDir);
config.sv = { scanUI: false, svUI: true, walletUI: false };
const plan = deriveRuntimePlan(config);
assert.deepEqual(plan.disabledSvUIs, ['scan-web-ui', 'wallet-web-ui-sv']);
});
});

// Scenario: the SV UI env vars. templates/runtime-overrides.yaml is static and
// consumes one replicas + alias pair per UI, so these vars ARE the runtime
// contract: replicas 0/1 decides whether the container starts, and the alias
// keeps a disabled UI's hostname resolvable (nginx dies at startup on an
// unresolvable upstream — Splice's sv.conf proxies to these hostnames
// unconditionally). An enabled UI gets an inert "-unused" alias because the
// static YAML list entry always exists and only its value can change.
describe('writeLocalnetEnv sv UI vars', () => {
// All UIs on (the default): every service keeps 1 replica and nginx only
// holds inert aliases, leaving the real hostnames to the UI containers.
it('writes 1 replica and an inert alias for enabled UIs', () => {
const envPath = writeLocalnetEnv(baseConfig(generatedDir));
const env = readEnvFile(envPath);
for (const prefix of ['SCAN_WEB_UI', 'SV_WEB_UI', 'WALLET_WEB_UI_SV']) {
assert.equal(env[`${prefix}_REPLICAS`], '1');
}
assert.equal(env.SCAN_WEB_UI_NGINX_ALIAS, 'scan-web-ui-unused');
assert.equal(env.SV_WEB_UI_NGINX_ALIAS, 'sv-web-ui-unused');
assert.equal(env.WALLET_WEB_UI_SV_NGINX_ALIAS, 'wallet-web-ui-sv-unused');
});

// scanUI off: its service must drop to 0 replicas and nginx must take over the
// real `scan-web-ui` hostname, while the other two UIs stay untouched.
it('writes 0 replicas and the real hostname alias for a disabled UI', () => {
const config = baseConfig(generatedDir);
config.sv = { scanUI: false, svUI: true, walletUI: true };
const env = readEnvFile(writeLocalnetEnv(config));
assert.equal(env.SCAN_WEB_UI_REPLICAS, '0');
assert.equal(env.SCAN_WEB_UI_NGINX_ALIAS, 'scan-web-ui');
assert.equal(env.SV_WEB_UI_REPLICAS, '1');
assert.equal(env.SV_WEB_UI_NGINX_ALIAS, 'sv-web-ui-unused');
assert.equal(env.WALLET_WEB_UI_SV_REPLICAS, '1');
assert.equal(env.WALLET_WEB_UI_SV_NGINX_ALIAS, 'wallet-web-ui-sv-unused');
});
});
3 changes: 3 additions & 0 deletions scripts/smoke.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ assert.deepEqual(config.validators, {
appProvider: { enabled: false, ui: false },
appUser: { enabled: true, ui: false },
});
assert.deepEqual(config.sv, { scanUI: true, svUI: true, walletUI: true });
assert.deepEqual(config.networkTools, { console: false, multiSync: false, swaggerUI: false });

// With the scaffolded default (app-provider off, app-user enabled headless, tools
Expand All @@ -30,6 +31,8 @@ assert.deepEqual(config.networkTools, { console: false, multiSync: false, swagge
const plan = deriveRuntimePlan(config);
assert.deepEqual(plan.upProfiles, ['sv']);
assert.deepEqual(plan.headlessValidators, ['appUser']);
// All SV web UIs are on by default, so nothing is pinned to 0 replicas.
assert.deepEqual(plan.disabledSvUIs, []);
assert.equal(runtimeEnvPath.endsWith('.generated/localnet.env'), true);
assert.equal(config.localnetOverridePath.endsWith('splice-localnet-overrides.yaml'), true);
assert.match(localnetOverride, /max-size: "25m"/);
Expand Down
Loading
Loading