row 163 (#1660): stack-release.sh rehearse, the D2 rehearsal on a scratch root from a backup, and bus start --no-launch (Sage; Filbert approve 29447; Sage gate passed)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# Row 163 (D2): `stack-release.sh rehearse` and `bus start --no-launch`
|
||||
|
||||
Gitea #1660. Brief: docs/plans/2026-10-18_stack-release.md, "Stack rehearsal on a scratch root
|
||||
(D2)", blob b1500d96ad27. Reviewer: Filbert. Rulings: mos-0961, mos-0962, mos-0963.
|
||||
Base: 4f02486d5 (origin/refactor). D2 lands after the Part B window (P19 385), because Part B's
|
||||
window file names the stack-release.sh blob (mos-0958, mos-0959).
|
||||
|
||||
## What it does
|
||||
|
||||
`rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]` builds
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy 12>/` (0700) from a backup, runs a guard on the scratch
|
||||
config with the target copy's code, runs the migration checks when the schema differs, starts the
|
||||
target's `bus start BUSINESS --no-launch` and Console on the scratch root, gates them with D1's
|
||||
checks minus the PM's, checks that no session started, stops both, and compares the backup and the
|
||||
live config and business files with sums taken first. One PASS or FAIL line per check, then
|
||||
REHEARSAL_PASS (0) or REHEARSAL_FAIL (1). Usage exits 4. docs/TOOLS.md has the operator text.
|
||||
|
||||
`bus start --no-launch` (brief item 4): `start()` refuses `launches-disabled`, so every launch path
|
||||
spawns nothing; a session's launch request is refused and logged; `listen()` starts no thread
|
||||
ticker; `recover()` signals and ends nothing and logs what it left; the Registry is read-only, so
|
||||
sessions.json stays as an earlier host left it and `add()` refuses. The host-up line ends
|
||||
`, launches disabled`. `--pm` with it is a usage error. No unit passes it.
|
||||
|
||||
## Rulings carried
|
||||
|
||||
- mos-0961: `data-root-live` refuses a scratch dataRoot that resolves to the live data root;
|
||||
`sessions-live` refuses when a sessions.json in the scratch data root names a running pid. The run
|
||||
never deletes the file or retries. recover() under --no-launch is covered by the launcher test
|
||||
"bus start --no-launch leaves an earlier host's live session alone: no signal, no end,
|
||||
sessions.json as it was".
|
||||
- mos-0962: every tracker.* variable is stripped, business and role level; `business.diff` keeps the
|
||||
change; `tracker-configured` fails if bootConfig still sets trackers; notify.json is asserted to be
|
||||
exactly {"notifyVersion":1,"binding":null} at 0600.
|
||||
- mos-0963: every role credential reference and @sync's is moved to its own 0600 file under
|
||||
`<rehearsal>/placeholders/` holding `rehearsal-placeholder-token`; `credential-ref-live` fails for
|
||||
an env reference or a file whose real directory is not the placeholders directory. Test R9 leaves
|
||||
one role ref on a file outside placeholders and gets the FAIL; mutant K1 drops the check and R9
|
||||
fails.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
The rehearsal reaches nothing off the host:
|
||||
|
||||
- reads: the live config.json, the live business file, the pointer, the backup, the copies
|
||||
directory, the target copy's roles/, git in the canonical checkout (`copy_ok`'s merge-base);
|
||||
- writes: only `<dataRoot>/rehearsals/<stamp>/`;
|
||||
- processes: the target's bus host (`--no-launch`) and Console (`serve --port N --business B`, no
|
||||
`--send`), both with `env -i HOME PATH MOSAIC_CONFIG=<scratch config>`;
|
||||
- network: 127.0.0.1:N for /healthz, and the host's sockets under the scratch data root.
|
||||
|
||||
No model call: launches are disabled and no session starts (`no-session`). No Vikunja: tracker.*
|
||||
is stripped and `tracker-configured` checks bootConfig. No Discord: notify binding null. No Gitea:
|
||||
every credential ref is a placeholder file, and the git socket serves only bound sessions, of
|
||||
which there are none.
|
||||
|
||||
`projects.stack.root` stays at the live checkout, /mnt/storage/src/mosaic-stack. The business
|
||||
file's projects are copied unchanged. Under --no-launch nothing makes a checkout from it, because
|
||||
checkouts are made only in `start()`, which refuses.
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
1. Item 1 says to copy "the business file, roles and organization file from the live config
|
||||
directory". Roles come from the target copy's `roles/`, as the live bus reads them (rolesDir is
|
||||
`<REPO>/roles` unless MOSAIC_ROLES_DIR is set, and `env -i` leaves it unset). The live config
|
||||
directory has no organization file; bootConfig's organization entry is the config dir itself,
|
||||
which here is the scratch config dir.
|
||||
2. The business file is rewritten, not copied (mos-0962, mos-0963), and `business.diff` records
|
||||
the change.
|
||||
3. The `tracker` key stays and `tracker.sync.credentials` becomes a placeholder: the business
|
||||
schema requires both, so stripping the key (mos-0962's wording) makes the loader refuse.
|
||||
bootConfig sets trackers only with tracker.baseUrl and a project's tracker.project, and both are
|
||||
stripped; `tracker-configured` checks the result. Authority: mos-0964 (2026-10-20T14:35:30Z),
|
||||
which accepts this and replaces the "@sync still present" clause of mos-0963 item 2:
|
||||
business.mjs:197 calls checkTracker on every load, and checkTracker requires tracker and
|
||||
tracker.sync (:57, :59) and checks sync.credentials (:63). @sync stays with its ref on a
|
||||
placeholder, and `credential-ref-live` covers it with the role refs. Evidence: R10 (a tracker
|
||||
still set fails `tracker-configured`), K2 (the check dropped, R10 fails), K10 (the tracker.*
|
||||
strip dropped, R1 fails on `tracker-configured`), and K9 (the @sync swap dropped, R1 fails on `credential-ref-live`).
|
||||
4. mosaic-config already refuses a scratch config that names the live data root lexically, because
|
||||
the rehearsal directory sits inside the live data root and a dataRoot may not contain the config
|
||||
dir. The guard's `data-root-live` catches the form that reaches the live root through a link
|
||||
(R11).
|
||||
5. Files owned: the brief lists stack-release.sh, its test, TOOLS.md and the packet. Item 4's flag
|
||||
adds packages/cli/src/cli.mjs, packages/cli/src/launcher.mjs, packages/seat/src/session.mjs and
|
||||
their tests (host.test.mjs, launcher.test.mjs, session.test.mjs).
|
||||
6. The brief's "free loopback port" is an operator-given `--port`, refused when reserved (7332,
|
||||
7333, the pointer's --port and --tester-port) or in use, never replaced by another port.
|
||||
7. Brief item 3's "in the foreground": the host runs as a child of the script for the length of
|
||||
the gate, then gets SIGTERM; it is not a unit and not detached.
|
||||
|
||||
## Tests
|
||||
|
||||
packages/cli/tests/stack-release.test.mjs, rehearse section (stand-in host, Console, migrate and
|
||||
Store in four fixture copies; real scripts/mosaic, mosaic-config, cli config and the business
|
||||
loader):
|
||||
|
||||
- R1 passes on the same schema: files, modes, placeholders, business.diff, the host's meta row in
|
||||
the scratch store and not in the backup, args logs, steps.log equal to the output, live files
|
||||
byte-identical.
|
||||
- R2 migration path 3g to 3h; R3 migrate fails.
|
||||
- R4 host fails, status fails, status names another pid, host launches, host keeps writer.lock.
|
||||
- R5 SIGTERM ignored: SIGKILL and FAIL host-stopped. R6 healthz. R7 copy-match.
|
||||
- R8 port in use, and 7332, 7333, the pointer's --port and --tester-port reserved.
|
||||
- R9 credential-ref-live (mos-0963). R10 tracker-configured (mos-0962). R11 data-root-live through
|
||||
a link and sessions-live on a running pid (mos-0961). R12 notify-off.
|
||||
- R13 old copy, backup missing, backup sums, no pointer, usage.
|
||||
|
||||
packages/cli/tests/launcher.test.mjs: recover() under --no-launch leaves an earlier host's live
|
||||
session alone; every launch is refused and logged; no thread tick (and the same host with
|
||||
launches runs it). packages/cli/tests/host.test.mjs: the host-up line, no sessions.json, --pm
|
||||
with --no-launch is usage, and no unit renders --no-launch. packages/seat/tests/session.test.mjs:
|
||||
a read-only Registry never writes and refuses add().
|
||||
|
||||
## Mutants
|
||||
|
||||
33, one per behaviour, each killed (mutants.py, mutants-run.log and mutants-rerun.log beside this file). The first run left K11
|
||||
(bus-status pid), K21 (scratch store linked to the backup), K27 (ticker under --no-launch) and
|
||||
K31 (cli drops launches); each got a test and the rerun killed all four. K21 now dies on
|
||||
FAIL backup-unchanged, the check it exists for.
|
||||
|
||||
## Round 2 (Filbert r1, comment 29445: F1-F3 changes, N1)
|
||||
|
||||
Filbert's six mutants M1-M6 survived the R tests. Each named a check that worked but that no test
|
||||
drove to FAIL. Round 2 adds those tests. stack-release.sh is unchanged (blob 0053f35d1360).
|
||||
|
||||
- F1 (M1, M2): R14. A new fixture fault, host-touches, has the host append to a file it lists.
|
||||
Three cases (the live config, the live business file, the backup's bus.sqlite) each give exactly
|
||||
one FAIL, live-config-unchanged or backup-unchanged, after the gate passed and the host stopped.
|
||||
- F2 (M3): R9 gains a pm gitea reference left on env REHEARSAL_GITEA_TOKEN, which fails
|
||||
credential-ref-live with "pm gitea is env REHEARSAL_GITEA_TOKEN" before the host starts.
|
||||
- F2 (M4): R11 gains a scratch config whose dataRoot is another existing directory ("resolves to
|
||||
X, not R/data") and one whose dataRoot is not there ("X does not exist"). Both fail
|
||||
data-root-scratch before the host starts.
|
||||
- F2 (M5): R4 gains host-launches-dir, a host that makes data/launches, which fails no-session
|
||||
with "found launches under".
|
||||
- F3 (M6): R15 sends TERM, INT and HUP to a rehearsal whose gate is still waiting on /healthz.
|
||||
Each gives FAIL signal, then console-stopped, host-stopped, host-released-store,
|
||||
backup-unchanged and live-config-unchanged all PASS, REHEARSAL_FAIL, exit 1, and the host's pid
|
||||
is gone. rehearse() gained an e.during hook that gets the child process.
|
||||
- N1: Registry.remove() wrote sessions.json under readOnly. #write() now returns at once when the
|
||||
registry is read-only, so the constructor, add() and remove() share one rule. The session test
|
||||
calls remove() on a read-only registry and checks that the file is unchanged.
|
||||
|
||||
Mutants K34-K41, one per finding and N1, are all killed (r2/mutants-run.log beside this file):
|
||||
K34 backup-unchanged always passes, K35 live-config-unchanged always passes, K36 env ref
|
||||
accepted, K37 data-root-scratch mismatch dropped, K38 missing root dropped, K39 launches dir
|
||||
ignored, K40 no INT/TERM/HUP trap, K41 read-only Registry writes.
|
||||
@@ -528,6 +528,68 @@ on PATH; the checkout's `scripts/mosaic` exits 3 with `human-required` or
|
||||
`tester-required`, and queue writes stay on it ([packages/cli/README.md, One
|
||||
pinned copy](../packages/cli/README.md#one-pinned-copy-row-85-1579)).
|
||||
|
||||
#### Rehearsal on a scratch root (D2, row 163)
|
||||
|
||||
```
|
||||
scripts/stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
```
|
||||
|
||||
`rehearse` starts the target copy's bus host and Console on a copy of a
|
||||
backup and runs `activate`'s gate checks on them, all but the PM's, while the
|
||||
live stack keeps running. It reads the live config and a backup of the live
|
||||
store, so Jason or Mos runs it, as a window's pre-step. No development seat
|
||||
runs it against live data. It stops and starts no unit and writes no pointer
|
||||
or log line.
|
||||
|
||||
`--backup` takes an activation id under `<dataRoot>/backups/stack/` or an
|
||||
absolute path. `--port` is the scratch Console's loopback port, 1024 to
|
||||
65535. The run refuses `port-reserved` for 7332, 7333 and the pointer's
|
||||
`--port` and `--tester-port`, and `port-in-use` for a port something holds;
|
||||
it never picks another port. `--timeout` (default 60 seconds) bounds the
|
||||
gate and each stop.
|
||||
|
||||
Before it makes anything, it checks the pointer, the copy (present,
|
||||
reachable from refactor, a readable schema, a bus host with `--no-launch`),
|
||||
the port and the backup's `SHA256SUMS`. Then it makes
|
||||
`<dataRoot>/rehearsals/<UTC second>-<copy's first 12>/`, mode 0700:
|
||||
|
||||
- `data/bus/` holds a copy of the backup's store files, and
|
||||
`data/notify/BUSINESS/notify.json` is `{"notifyVersion":1,"binding":null}`.
|
||||
- `config/config.json` is the live config with `dataRoot` set to `data/`.
|
||||
- `config/businesses/BUSINESS.json` is the live business file with every
|
||||
`tracker.*` variable removed and every credential reference, the roles'
|
||||
and `tracker.sync`'s, pointed at its own 0600 file under `placeholders/`
|
||||
holding `rehearsal-placeholder-token`. `business.diff` shows the changes.
|
||||
Roles come from the target copy's `roles/`, as the live bus reads them.
|
||||
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
|
||||
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
|
||||
|
||||
A guard then loads the scratch config with the target's code and refuses
|
||||
with a named check: `data-root-live` when the scratch data root resolves to
|
||||
the live one, `sessions-live` when a `sessions.json` there names a running
|
||||
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
|
||||
`tracker-configured` when `bootConfig` still sets a tracker, and
|
||||
`credential-ref-live` for an env reference or a file outside `placeholders/`.
|
||||
When the backup's schema differs from the target's, the run checks that the
|
||||
target refuses the store, migrates it, opens it, migrates it again as a
|
||||
no-op, and that the pointer's copy refuses the migrated store.
|
||||
|
||||
It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
|
||||
`serve --port N --business BUSINESS` from the target copy, without `--send`.
|
||||
The gate needs the host's `launches disabled` line, a live `bus status` host
|
||||
with this run's pid and a boot from this start, the Console's address line,
|
||||
`/healthz` 200, and both command lines on the target copy. Then it checks
|
||||
that the host wrote no `sessions.json` and no `launches`, stops the Console
|
||||
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
|
||||
the host released `writer.lock`, and compares the backup and the live config
|
||||
and business files with sums it took before. The last line is
|
||||
`REHEARSAL_PASS` (exit 0) or `REHEARSAL_FAIL` (exit 1), then the directory.
|
||||
Usage exits 4. Nothing prunes rehearsal directories.
|
||||
|
||||
`bus start --no-launch` refuses every launch with `launches-disabled`,
|
||||
starts no thread ticker, and leaves an earlier host's `sessions.json` and
|
||||
its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
||||
|
||||
## Console service (`scripts/webui-service.sh`)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -57,7 +57,7 @@ export const USAGE = `usage:
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
mosaic bus start <business> [--pm]
|
||||
mosaic bus start <business> [--pm | --no-launch]
|
||||
mosaic bus stop
|
||||
mosaic bus status [--json]`;
|
||||
|
||||
@@ -482,8 +482,11 @@ async function launches(rest, io, deps) {
|
||||
async function bus(rest, io, deps) {
|
||||
const [sub, ...args] = rest;
|
||||
if (sub === "start") {
|
||||
const parsed = parse(args, { flags: ["--pm"] });
|
||||
const parsed = parse(args, { flags: ["--pm", "--no-launch"] });
|
||||
if (parsed.positional.length !== 1) throw usage("bus start takes one business");
|
||||
// Row 163: --no-launch is the D2 rehearsal's host. No unit passes it.
|
||||
const launches = !parsed.flags.has("--no-launch");
|
||||
if (!launches && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
|
||||
const businessId = parsed.positional[0];
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
// Fail closed, and say so (vikunja:19/302): a refused load is recorded where the Console and
|
||||
@@ -500,9 +503,9 @@ async function bus(rest, io, deps) {
|
||||
const binding = readNotifyConfig(system.dataRoot, businessId);
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null });
|
||||
try {
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), ...(deps.launcher ?? {}) });
|
||||
const launcher = createLauncher({ host, system, env: io.env, tracker: Boolean(boot.trackers?.[businessId]), launches, ...(deps.launcher ?? {}) });
|
||||
await launcher.listen();
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}\n`);
|
||||
io.stdout.write(`bus host up: business ${businessId}, socket ${host.path}, notifier ${binding ?? "off"}${launches ? "" : ", launches disabled"}\n`);
|
||||
if (parsed.flags.has("--pm")) {
|
||||
const pm = await launcher.launchPm();
|
||||
io.stdout.write(`launched ${pm.instance}: run ${pm.run}, ${pm.harness} ${pm.model}\n`);
|
||||
|
||||
@@ -153,7 +153,11 @@ function call(name, what, fn) {
|
||||
}
|
||||
|
||||
// host: startHost()'s handle. tracker: true when the broker has task verbs for
|
||||
// the business. makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// the business. launches: false for `bus start --no-launch` (row 163, the D2
|
||||
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
|
||||
// session's launch request is refused, recover() kills and ends nothing, and
|
||||
// sessions.json stays as it was.
|
||||
// makeLoader (the plug-in loader's factory, which tests point at a
|
||||
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
|
||||
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
|
||||
// the tests; the command line never sets them.
|
||||
@@ -162,6 +166,7 @@ export function createLauncher({
|
||||
system,
|
||||
env = process.env,
|
||||
tracker = false,
|
||||
launches = true,
|
||||
makeLoader = createLoader,
|
||||
bwrap = BWRAP,
|
||||
readOnly = [],
|
||||
@@ -187,7 +192,7 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
|
||||
}
|
||||
const registry = new Registry(dataRoot);
|
||||
const registry = new Registry(dataRoot, { readOnly: !launches });
|
||||
const children = new Map();
|
||||
let chain = Promise.resolve();
|
||||
let closed = false;
|
||||
@@ -323,6 +328,8 @@ export function createLauncher({
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
|
||||
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
|
||||
// A thread's store is checked first, so a bad id or an unsafe directory
|
||||
// launches nothing.
|
||||
let store = null;
|
||||
@@ -627,6 +634,11 @@ export function createLauncher({
|
||||
await host.op({ op: "refuse", cap, code: "not-a-role" }).catch(() => {});
|
||||
return { ok: false, error: "not-a-role" };
|
||||
}
|
||||
if (!launches) {
|
||||
await host.op({ op: "refuse", cap, code: "launches-disabled" }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code: "launches-disabled" });
|
||||
return { ok: false, error: "launches-disabled" };
|
||||
}
|
||||
let checked;
|
||||
try {
|
||||
if (typeof instance !== "string") throw new Refusal("invalid-request");
|
||||
@@ -675,6 +687,13 @@ export function createLauncher({
|
||||
// This broker never bound those runs, so each is bound again first with
|
||||
// its identical launch record, which the broker accepts.
|
||||
async function recover() {
|
||||
// Row 163 (mos-0961): a --no-launch host never signals or ends an earlier host's session. Its
|
||||
// data root may hold a sessions.json that names live processes, so it only says what it left.
|
||||
if (!launches) {
|
||||
for (const s of leftover) log(`run ${s.run} (${s.instance}) from an earlier host: left as it is, this host was started with --no-launch`);
|
||||
leftover = [];
|
||||
return;
|
||||
}
|
||||
for (const s of leftover) {
|
||||
if (startTimeOf(s.pid) === s.startTime && (cmdlineOf(s.pid) ?? [])[0] === bwrap) {
|
||||
try {
|
||||
@@ -744,7 +763,7 @@ export function createLauncher({
|
||||
});
|
||||
chmodSync(path, 0o600);
|
||||
await gitSocket.listen();
|
||||
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
|
||||
}
|
||||
|
||||
// SIGTERM every runner, wait, then end what's left. Runs before the host
|
||||
|
||||
@@ -811,6 +811,29 @@ test("bus start runs until bus stop; status reports it while it runs", async (t)
|
||||
assert.match(again.stdout, /no host runs/);
|
||||
});
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal's host.
|
||||
test("bus start --no-launch says launches are disabled in its host-up line and stops cleanly; with --pm it is a usage error", async (t) => {
|
||||
const f = fixture(tmp(t));
|
||||
notifyConfig(f.dataRoot, "acme", null);
|
||||
const both = spawnSync(process.execPath, [CLI, "bus", "start", "acme", "--pm", "--no-launch"], { env: f.env, encoding: "utf8", timeout: 40000 });
|
||||
assert.equal(both.status, 4, both.stderr);
|
||||
assert.match(both.stderr, /bus start takes --pm or --no-launch, not both/);
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
|
||||
const child = spawn(process.execPath, [CLI, "bus", "start", "acme", "--no-launch"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
t.after(() => child.exitCode === null && child.kill("SIGKILL"));
|
||||
let out = "";
|
||||
child.stdout.on("data", (b) => (out += b));
|
||||
child.stderr.on("data", (b) => (out += b));
|
||||
await until(() => /bus host up: business acme.*\n/.test(out), 30000);
|
||||
assert.match(out, /bus host up: business acme, socket \S+, notifier off, launches disabled\n/);
|
||||
// The launcher got launches false: its Registry is read-only, so no sessions.json appears.
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus-host", "sessions.json")), false);
|
||||
const exit = once(child, "exit");
|
||||
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
|
||||
assert.equal(stop.status, 0, stop.stderr);
|
||||
assert.equal((await exit)[0], 0, out);
|
||||
});
|
||||
|
||||
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
|
||||
const dir = tmp(t);
|
||||
const script = join(REPO, "scripts", "bus-service.sh");
|
||||
@@ -818,6 +841,8 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
|
||||
assert.equal(render.status, 0, render.stderr);
|
||||
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
|
||||
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
|
||||
// Row 163: --no-launch is for the D2 rehearsal's own host only; no unit starts a bus with it.
|
||||
assert.doesNotMatch(render.stdout, /--no-launch/);
|
||||
// One session credential file, optional so Pi sessions start without it.
|
||||
// The README's paragraph names the same file; nothing else does.
|
||||
const files = render.stdout.match(/^EnvironmentFile=.*$/gm);
|
||||
|
||||
@@ -15,11 +15,12 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "../../bus/src/client.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { gitSocketPath, launchLogFile, launchSocketPath, readSessions, Registry, runsDir, SESSION_CREDENTIALS, sessionsFile, workspaceDir } from "../../seat/src/session.mjs";
|
||||
import { BWRAP, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { childrenOf } from "../../seat/src/proc.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { cmdlineOf, startHost, startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { mockAnthropic, REPO } from "../../harness/tests/helpers.mjs";
|
||||
import { fakeHarnesses, fixture, tmp } from "./helpers.mjs";
|
||||
@@ -901,6 +902,56 @@ for (const exited of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 163 (mos-0961): the D2 rehearsal host. A live bwrap process the test owns stands in for an
|
||||
// earlier host's session; its outer bwrap is what recover() would kill, and its child stands in
|
||||
// for the runner that close() would signal.
|
||||
test("bus start --no-launch leaves an earlier host's live session alone: no signal, no end, sessions.json as it was", { skip, timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const { f } = given;
|
||||
const box = spawn(BWRAP, ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--unshare-pid", "--die-with-parent", "sleep", "60"], { stdio: "ignore" });
|
||||
t.after(() => box.kill("SIGKILL"));
|
||||
const pid = box.pid;
|
||||
const startTime = await until(() => startTimeOf(pid));
|
||||
const runnerPid = await until(() => childrenOf(pid)[0]);
|
||||
const runnerStartTime = await until(() => startTimeOf(runnerPid));
|
||||
assert.equal(cmdlineOf(pid)[0], BWRAP, "recover() would take it for a session");
|
||||
new Registry(f.dataRoot).add({ business: "acme", instance: "chief", run: "run-left", harness: "pi", pid, startTime, runnerPid, runnerStartTime });
|
||||
const before = readFileSync(sessionsFile(f.dataRoot), "utf8");
|
||||
|
||||
const s = await setup(t, undefined, given, { launches: false });
|
||||
assert.ok(s.logs.includes("run run-left (chief) from an earlier host: left as it is, this host was started with --no-launch"), s.logs.join("\n"));
|
||||
assert.equal(startTimeOf(pid), startTime, "listen() sent no signal");
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
assert.deepEqual((await s.client.call("trail", { subject: "run-left" })).filter((r) => r.kind === "session.ended"), []);
|
||||
assert.equal(await s.close(), 0);
|
||||
assert.equal(startTimeOf(pid), startTime, "close() sent no signal either");
|
||||
assert.equal(startTimeOf(runnerPid), runnerStartTime);
|
||||
assert.equal(readFileSync(sessionsFile(f.dataRoot), "utf8"), before);
|
||||
});
|
||||
|
||||
test("bus start --no-launch refuses every launch: the PM launch and a session's request, each logged, nothing spawned", { skip, timeout: 30000 }, async (t) => {
|
||||
const s = await setup(t, undefined, undefined, { launches: false });
|
||||
await assert.rejects(s.launcher.launchPm(), (e) => e.exitCode === 3 && /^PM launch refused: launches-disabled/.test(e.message));
|
||||
const sock = launchSocketPath(s.f.dataRoot);
|
||||
assert.deepEqual(await ask(sock, `${JSON.stringify({ cap: s.cto.cap, instance: "coder" })}\n`), { ok: false, error: "launches-disabled" });
|
||||
assert.deepEqual(log(s.f).map((e) => [e.event, e.instance, e.code]), [["refused", "chief", "launches-disabled"], ["refused", "coder", "launches-disabled"]]);
|
||||
assert.deepEqual(readSessions(s.f.dataRoot), []);
|
||||
const runs = runsDir(s.f.dataRoot, "acme");
|
||||
assert.deepEqual(existsSync(runs) ? readdirSync(runs) : [], [], "no run directory");
|
||||
assert.equal(await s.close(), 0);
|
||||
});
|
||||
|
||||
test("bus start --no-launch starts no thread tick; the same host with launches runs it", { skip, timeout: 30000 }, async (t) => {
|
||||
for (const launches of [false, true]) {
|
||||
const asked = [];
|
||||
const wrap = (host) => Object.create(host, { op: { value: async (m) => (["threadsState", "landHolders"].includes(m.op) && asked.push(m.op), host.op(m)) } });
|
||||
const s = await setup(t, undefined, undefined, { launches, threadTickMs: 50, wrap });
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
assert.equal(asked.length > 0, launches, `launches ${launches}: ${asked.join(", ")}`);
|
||||
assert.equal(await s.close(), 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("a malformed sessions.json refuses the host start with exit 3 and stays as it was", { timeout: 30000 }, async (t) => {
|
||||
const given = prepare(t);
|
||||
const file = sessionsFile(given.f.dataRoot);
|
||||
|
||||
@@ -1705,3 +1705,382 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
|
||||
await noLeak(e);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
// migrate-schema.mjs. A rehearsal starts its processes with only HOME, PATH and MOSAIC_CONFIG, so
|
||||
// the stand-ins take their faults from $HOME/faults and record what they did in $HOME.
|
||||
|
||||
// The bus host. bus start writes the store's writer.lock, a meta row in the store, and its state
|
||||
// beside the scratch store, and prints the host-up line; without --no-launch it starts the PM, which here means a line in
|
||||
// $HOME/starts.log and a sessions.json. bus status --json answers from that state.
|
||||
function rHost(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
|
||||
const data=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot,state=path.join(data,'fixture-host.json');
|
||||
const flags=["--pm","--no-launch"];
|
||||
if(a.join(' ')==='bus status --json'){
|
||||
if(fault(`status-${id}`))process.exit(1);
|
||||
let s={host:null,boot:null};try{s=JSON.parse(fs.readFileSync(state,'utf8'));s.host.live=alive(s.host.pid)}catch{}
|
||||
if(fault('status-other-host')&&s.host)s.host.pid=process.ppid;
|
||||
console.log(JSON.stringify(s));process.exit(0);
|
||||
}
|
||||
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
|
||||
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
|
||||
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
|
||||
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
|
||||
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
|
||||
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
|
||||
db.prepare("INSERT OR REPLACE INTO meta(key,value) VALUES('fixture_host_start',?)").run(at);db.close();
|
||||
fs.writeFileSync(state,JSON.stringify({host:{business:b,pid:process.pid,live:true,startedAt:at,notifier:'off'},boot:{ok:true,business:b,at}}));
|
||||
if(fault('host-launches-dir'))fs.mkdirSync(path.join(data,'launches'));
|
||||
// host-touches lists files a careless host would write: the live config, business file or backup.
|
||||
if(fault('host-touches'))for(const f of fs.readFileSync(path.join(home,'faults','host-touches'),'utf8').split('\n').filter(Boolean))fs.appendFileSync(f,'x');
|
||||
if(!off||fault('host-launches')){
|
||||
fs.appendFileSync(path.join(home,'starts.log'),`pm-${b}\n`);
|
||||
fs.mkdirSync(path.join(data,'bus-host'),{recursive:true});
|
||||
fs.writeFileSync(path.join(data,'bus-host','sessions.json'),JSON.stringify({sessionsVersion:1,sessions:[{pid:process.pid}]}));
|
||||
}
|
||||
console.log(`bus host up: business ${b}, socket ${path.join(data,'bus','broker.sock')}, notifier off${off?', launches disabled':''}`);
|
||||
process.on('SIGTERM',()=>{if(fault('host-ignores-term'))return;if(!fault('host-keeps-lock'))fs.rmSync(lock,{force:true});process.exit(0)});
|
||||
setInterval(()=>{},1<<30);
|
||||
}
|
||||
|
||||
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
|
||||
function rConsole(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
|
||||
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
|
||||
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
|
||||
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
|
||||
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
|
||||
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
|
||||
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
|
||||
process.on('SIGTERM',()=>process.exit(0));
|
||||
}
|
||||
|
||||
// migrate-schema.mjs: sets its copy's SCHEMA_VERSION, or exit 1 under migrate-fails.
|
||||
function rMigrate(){
|
||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
||||
const home=process.env.HOME,data=process.argv[2];
|
||||
const v=/^export const SCHEMA_VERSION = '([A-Za-z0-9]+)';$/m.exec(fs.readFileSync(path.join(import.meta.dirname,'store.mjs'),'utf8'))[1];
|
||||
fs.appendFileSync(path.join(home,'migrate.log'),`${data}\n`);
|
||||
if(fs.existsSync(path.join(home,'faults','migrate-fails'))){console.error('fixture migrate: failed');process.exit(1)}
|
||||
const db=new DatabaseSync(path.join(data,'bus','bus.sqlite'));db.prepare("UPDATE meta SET value=? WHERE key='schema_version'").run(v);db.close();
|
||||
console.log(`bus schema ${v} ready`);
|
||||
}
|
||||
|
||||
// The copy's Store: refuses a store at another version with schema-mismatch, as the real one does.
|
||||
const rStore=(v,froms)=>`${storeText(v,froms)}import {DatabaseSync} from 'node:sqlite';
|
||||
import {join} from 'node:path';
|
||||
export class Store {
|
||||
constructor(root) {
|
||||
this.db = new DatabaseSync(join(root, 'bus', 'bus.sqlite'));
|
||||
if (this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get()?.value !== SCHEMA_VERSION) { this.db.close(); throw Error('schema-mismatch'); }
|
||||
}
|
||||
get(sql, ...args) { return this.db.prepare(sql).get(...args); }
|
||||
close() { this.db.close(); }
|
||||
}
|
||||
`;
|
||||
|
||||
// B (3g) and C (3h) have --no-launch. A is a copy from before row 163, whose host has no such flag.
|
||||
// C1's scripts/mosaic runs B's host, as a copy-check failure needs.
|
||||
const RCOPIES=join(base,'rcopies');mkdirSync(RCOPIES);
|
||||
for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],[C1,'3g',['3f']]]){
|
||||
const d=join(RCOPIES,id);
|
||||
for(const p of ['scripts/mosaic','scripts/mosaic-config.mjs','packages/cli/src/config.mjs','packages/cli/src/errors.mjs','packages/bus/src/business.mjs','packages/plugins/src/kinds.mjs','packages/business/src','roles'])cpSync(join(repo,p),join(d,p),{recursive:true});
|
||||
if(id===C1)writeFileSync(join(d,'scripts','mosaic'),`#!/usr/bin/env bash\nexec node "${join(RCOPIES,B,'packages','cli','src','cli.mjs')}" "$@"\n`,{mode:0o755});
|
||||
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
|
||||
execFileSync('chmod',['-R','a-w',d]);
|
||||
}
|
||||
|
||||
const TESTER='7499';
|
||||
const freePort=async()=>{const s=createServer();s.listen(0,'127.0.0.1');await once(s,'listening');const p=String(s.address().port);await new Promise((r)=>s.close(r));return p};
|
||||
const rh=(e,...p)=>join(e.env.HOME,...p);
|
||||
const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync(rh(e,'faults',name),'')};
|
||||
|
||||
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
|
||||
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
|
||||
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
|
||||
async function rmk(t,{copy=B,store='3g'}={}){
|
||||
const e=await mk(t);
|
||||
t.after(()=>{
|
||||
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
|
||||
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
|
||||
});
|
||||
const db=new DatabaseSync(e.store);
|
||||
db.exec('PRAGMA journal_mode=WAL');
|
||||
db.exec('CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
|
||||
db.exec('CREATE TABLE events (id INTEGER PRIMARY KEY, at TEXT NOT NULL)');
|
||||
db.prepare("INSERT INTO meta VALUES ('schema_version', ?)").run(store);
|
||||
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
|
||||
db.close();
|
||||
mkdirSync(e.state,{recursive:true});
|
||||
writeFileSync(e.pointer,JSON.stringify({copy,business:'acme',pm:true,claude:e.claude,console:['--port',e.port,'--business','acme','--tester-port',TESTER],schema:store,activation:'manual',at:'2026-10-20T00:00:00Z'})+'\n');
|
||||
e.act=`20261020T000000Z-${copy.slice(0,12)}`;e.backup=join(e.data,'backups','stack',e.act);
|
||||
mkdirSync(join(e.backup,'bus'),{recursive:true,mode:0o700});
|
||||
cpSync(e.store,join(e.backup,'bus','bus.sqlite'));
|
||||
writeFileSync(join(e.backup,'SHA256SUMS'),`${sha(e.store)} bus.sqlite\n`);
|
||||
for(const [f,text] of [['DATA_ROOT',e.data],['SCHEMA',store],['STAMP','2026-10-20T00:00:00.000Z']])writeFileSync(join(e.backup,f),`${text}\n`);
|
||||
e.biz=join(dirname(e.cfg),'businesses','acme.json');
|
||||
return e;
|
||||
}
|
||||
|
||||
// What a rehearsal may never change: the live store, config, business file, pointer and log, and
|
||||
// the backup, by sha256 of every file.
|
||||
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
|
||||
|
||||
async function rehearse(e,id,...args){
|
||||
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
|
||||
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
|
||||
e.during?.(ch);
|
||||
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
||||
const [code]=await once(ch,'close');clearTimeout(timer);
|
||||
const m=/\nrehearsal directory: (.*)\n$/.exec(out);
|
||||
return {code,out,err,dir:m?m[1]:null,names:out.split('\n').filter((l)=>/^(PASS|FAIL) /.test(l)).map((l)=>l.replace(/^(PASS|FAIL) ([^:]*).*/,'$1 $2'))};
|
||||
}
|
||||
// A script of its own whose text has `from` replaced, for the guard tests. The replacement is a
|
||||
// function so a shell "$$" in `to` stays as written.
|
||||
function ownScript(e,from,to){
|
||||
const own=join(e.root,'repo');cpSync(repo,own,{recursive:true});
|
||||
e.sr=join(own,'scripts','stack-release.sh');
|
||||
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
|
||||
}
|
||||
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
|
||||
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
|
||||
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
|
||||
|
||||
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_PASS\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(r.err,'');
|
||||
assert.equal(untouched(e),before);
|
||||
const R=r.dir;assert.equal(dirname(R),join(e.data,'rehearsals'));assert.match(basename(R),new RegExp(`^\\d{8}T\\d{6}Z-${B.slice(0,12)}$`));
|
||||
for(const d of [dirname(R),R,join(R,'placeholders'),join(R,'config')])assert.equal(statSync(d).mode&0o777,0o700,d);
|
||||
for(const f of ['steps.log','backup.before','live.before','business.diff','host.log','console.log','config/config.json','data/bus/bus.sqlite'])assert.ok(existsSync(join(R,f)),f);
|
||||
// The host wrote the scratch store, a copy: the backup has no such row.
|
||||
const metaOf=(f)=>{const db=new DatabaseSync(f,{readOnly:true});try{return db.prepare("SELECT value FROM meta WHERE key='fixture_host_start'").get()?.value}finally{db.close()}};
|
||||
assert.match(metaOf(join(R,'data','bus','bus.sqlite')),/^\d{4}-/);assert.equal(metaOf(join(e.backup,'bus','bus.sqlite')),undefined);
|
||||
// The scratch config differs from the live one only in dataRoot.
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(R,'config','config.json'),'utf8')),{...JSON.parse(readFileSync(e.cfg,'utf8')),dataRoot:join(R,'data')});
|
||||
// The notifier config: binding null at 0600.
|
||||
const nf=join(R,'data','notify','acme','notify.json');
|
||||
assert.deepEqual(JSON.parse(readFileSync(nf,'utf8')),{notifyVersion:1,binding:null});assert.equal(statSync(nf).mode&0o777,0o600);
|
||||
// The scratch business: no tracker.* variable, and every reference a 0600 placeholder with its date kept.
|
||||
const live=JSON.parse(readFileSync(e.biz,'utf8')),sb=JSON.parse(readFileSync(join(R,'config','businesses','acme.json'),'utf8'));
|
||||
assert.ok(Object.keys(live.vars).some((k)=>k.startsWith('tracker.')));
|
||||
assert.deepEqual(Object.keys(sb.vars).filter((k)=>k.startsWith('tracker.')),[]);
|
||||
const refs=[...Object.entries(sb.roles).flatMap(([n,r])=>Object.entries(r.credentials??{}).map(([s,ref])=>[`${n}-${s}`,ref,live.roles[n].credentials[s]])),...Object.entries(sb.tracker.sync.credentials).map(([s,ref])=>[`@sync-${s}`,ref,live.tracker.sync.credentials[s]])];
|
||||
assert.ok(refs.length>=2);
|
||||
for(const [name,ref,old] of refs){
|
||||
const f=join(R,'placeholders',name);
|
||||
assert.deepEqual(ref,{...old,file:f});assert.equal(readFileSync(f,'utf8'),'rehearsal-placeholder-token');assert.equal(statSync(f).mode&0o777,0o600);
|
||||
}
|
||||
assert.deepEqual(readdirSync(join(R,'placeholders')).sort(),refs.map(([n])=>n).sort());
|
||||
// Everything else in the business is as it was.
|
||||
const strip=(b)=>{const c=structuredClone(b);for(const k of Object.keys(c.vars))if(k.startsWith('tracker.'))delete c.vars[k];for(const r of Object.values(c.roles))delete r.credentials;delete c.tracker.sync.credentials;return c};
|
||||
assert.deepEqual(strip(sb),strip(live));
|
||||
const diff=readFileSync(join(R,'business.diff'),'utf8');assert.match(diff,/^-.*"tracker\.baseUrl"/m);assert.match(diff,/^\+.*placeholders\/pm-/m);
|
||||
// The host ran with launches disabled and started no PM; the Console got no --send.
|
||||
assert.deepEqual(lines(rh(e,'host-args.log')),['bus start acme --no-launch']);
|
||||
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme`]);
|
||||
assert.equal(existsSync(rh(e,'starts.log')),false);
|
||||
assert.equal(existsSync(join(R,'data','bus','writer.lock')),false);
|
||||
assert.deepEqual(lines(join(R,'steps.log')).map((l)=>l.replace(/^\S+ /,'')),r.out.split('\n').filter((l)=>/^(PASS|FAIL) |^REHEARSAL_/.test(l)));
|
||||
});
|
||||
|
||||
test('R2 rehearse runs the migration checks when the schema differs; the live store stays at 3g',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const r=await rehearse(e,C,'--backup',e.backup,'--port',port,'--timeout','10');
|
||||
assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','PASS migrate','PASS new-opens-migrated','PASS migrate-again','PASS old-refuses-migrated','PASS gate','PASS no-session',...POST]);
|
||||
assert.ok(r.out.includes('PASS migrate: 3g to 3h\n'),r.out);
|
||||
assert.equal(untouched(e),before);assert.equal(storeVersion(e.store,false),'3g');
|
||||
assert.equal(storeVersion(join(r.dir,'data','bus','bus.sqlite'),false),'3h');
|
||||
assert.equal(storeVersion(join(r.dir,'refuse','bus','bus.sqlite'),false),'3g');
|
||||
assert.deepEqual(lines(rh(e,'migrate.log')),[join(r.dir,'data'),join(r.dir,'data')]);
|
||||
});
|
||||
|
||||
test('R3 a failing migration gives REHEARSAL_FAIL migrate and starts nothing',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,'migrate-fails');
|
||||
const r=await rehearse(e,C,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS new-refuses-old-store','FAIL migrate','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.ok(r.out.includes('FAIL migrate: migrate-schema.mjs exit 1: fixture migrate: failed\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R4 a host that does not start, does not answer status, reports another pid, launches anyway, makes a launches directory or keeps its lock each fails by name',async(t)=>{
|
||||
for(const [f,name,why] of [[`host-fails-${B}`,'host-up','exited'],[`status-${B}`,'bus-status','bus status failed'],['status-other-host','bus-status','the host this run started'],['host-launches','no-session','bus-host/sessions.json'],['host-launches-dir','no-session','found launches under'],['host-keeps-lock','host-released-store','writer.lock is still there']]){
|
||||
const e=await rmk(t),before=untouched(e);rfault(e,f);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.ok(r.names.includes(`FAIL ${name}`),f+r.out);assert.equal(r.names.filter((n)=>n.startsWith('FAIL')).length,1,f+r.out);
|
||||
assert.ok(r.out.split('\n').some((l)=>l.startsWith(`FAIL ${name}: `)&&l.includes(why)),f+r.out);
|
||||
assert.ok(r.names.includes('PASS console-stopped')&&r.names.includes('PASS backup-unchanged'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
assert.equal(untouched(e),before);
|
||||
}
|
||||
});
|
||||
|
||||
test('R5 a host that ignores SIGTERM gets SIGKILL and FAIL host-stopped',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,'host-ignores-term');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','2');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(/^FAIL host-stopped: pid \d+ was still running 2s after SIGTERM; sent SIGKILL$/m.test(r.out),r.out);
|
||||
});
|
||||
|
||||
test('R6 a Console that does not answer /healthz gives REHEARSAL_FAIL healthz',async(t)=>{
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL healthz: no answer within 3s\n'),r.out);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),['FAIL healthz']);
|
||||
});
|
||||
|
||||
test('R7 a host on another copy fails copy-match',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const r=await rehearse(e,C1,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL copy-match: the host's command line is not ${join(RCOPIES,C1)}'s cli.mjs bus start acme --no-launch\n`),r.out);
|
||||
});
|
||||
|
||||
test('R8 a scratch port in use or reserved refuses, and the rehearsal takes no other port',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e);
|
||||
const held=createServer();held.listen(0,'127.0.0.1');await once(held,'listening');t.after(()=>held.close());
|
||||
const p=String(held.address().port);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',p);
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.equal(r.out,`PASS copy: ${B}, schema 3g\nFAIL port-in-use: 127.0.0.1:${p} is in use; the rehearsal takes no other port\nREHEARSAL_FAIL\n`);
|
||||
for(const q of ['7332','7333',e.port,TESTER]){
|
||||
const s=await rehearse(e,B,'--backup',e.act,'--port',q);
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.equal(s.out,`PASS copy: ${B}, schema 3g\nFAIL port-reserved: --port ${q} is a live Console port (7332, 7333 and the pointer's: ${e.port} ${TESTER})\nREHEARSAL_FAIL\n`);
|
||||
}
|
||||
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R9 mos-0963: a role reference left outside placeholders/, or left on an env variable, fails credential-ref-live before the host starts',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
// The live pm gitea reference names a file that is there, as a live token would be.
|
||||
const tok=join(e.root,'live-gitea.token');writeFileSync(tok,'live-token-value',{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.roles.pm.credentials.gitea.file=tok;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
const before=untouched(e);
|
||||
ownScript(e,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL credential-ref-live: pm gitea is ${tok}, not a file in ${join(r.dir,'placeholders')}\n`),r.out);
|
||||
assert.deepEqual(r.names.slice(-3),['FAIL credential-ref-live','PASS backup-unchanged','PASS live-config-unchanged']);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
|
||||
assert.equal(untouched(e),before);
|
||||
// An env reference names no file at all; the guard says which variable it is.
|
||||
const f=await rmk(t);
|
||||
const fb=JSON.parse(readFileSync(f.biz,'utf8'));const {file,...rest}=fb.roles.pm.credentials.gitea;fb.roles.pm.credentials.gitea={...rest,env:'REHEARSAL_GITEA_TOKEN'};writeFileSync(f.biz,JSON.stringify(fb,null,2)+'\n',{mode:0o600});
|
||||
ownScript(f,'for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}','for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);if(name!=="pm")swap(name,r?.credentials)}');
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(s.out.includes('FAIL credential-ref-live: pm gitea is env REHEARSAL_GITEA_TOKEN\n'),s.out);
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R10 mos-0962: a scratch business that still sets a tracker fails tracker-configured',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
const proj=join(e.root,'project');mkdirSync(join(proj,'.mosaic'),{recursive:true});
|
||||
writeFileSync(join(proj,'.mosaic','project.json'),JSON.stringify({projectVersion:1,id:'stack',vars:{'tracker.project':7}}),{mode:0o600});
|
||||
const b=JSON.parse(readFileSync(e.biz,'utf8'));b.projects.stack.root=proj;writeFileSync(e.biz,JSON.stringify(b,null,2)+'\n',{mode:0o600});
|
||||
ownScript(e,'if(k.startsWith("tracker."))delete vars[k]','if(k.startsWith("tracker-kept."))delete vars[k]');
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL tracker-configured: bootConfig sets trackers for acme\n'),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R11 mos-0961: a scratch config on the live data root or on any root but the scratch one, or a sessions.json naming a running pid, refuses by name',async(t)=>{
|
||||
// mosaic-config already refuses the live path itself, which contains the scratch config directory;
|
||||
// the guard catches a path that reaches the live data root through a link.
|
||||
const e=await rmk(t),link=join(e.root,'live-link');symlinkSync(e.data,link);
|
||||
ownScript(e,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(link)};`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes(`FAIL data-root-live: the scratch config names dataRoot ${link}, which resolves to the live data root ${e.data}\n`),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
// A root that is neither live nor this rehearsal's, and one that is not there.
|
||||
for(const [dir,made,msg] of [['other-root',true,(d,R)=>`the scratch config resolves to ${d}, not ${join(R,'data')}`],['no-root',false,(d)=>`${d} does not exist`]]){
|
||||
const g=await rmk(t),d=join(g.root,dir);if(made)mkdirSync(d);
|
||||
ownScript(g,'c.dataRoot=data;',`c.dataRoot=${JSON.stringify(d)};`);
|
||||
const q=await rehearse(g,B,'--backup',g.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(q.code,1,dir+q.out+q.err);
|
||||
assert.ok(q.out.includes(`FAIL data-root-scratch: ${msg(d,q.dir)}\n`),dir+q.out);
|
||||
assert.equal(existsSync(rh(g,'host-args.log')),false);
|
||||
}
|
||||
const f=await rmk(t);
|
||||
ownScript(f,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json"`,`printf '{"notifyVersion":1,"binding":null}\\n' > "$R/data/notify/$BUSINESS/notify.json" && mkdir "$R/data/bus-host" && printf '{"sessionsVersion":1,"sessions":[{"pid":%s}]}\\n' "$$" > "$R/data/bus-host/sessions.json"`);
|
||||
const s=await rehearse(f,B,'--backup',f.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(s.code,1,s.out+s.err);
|
||||
assert.ok(/^FAIL sessions-live: \S+\/bus-host\/sessions\.json names running pid\(s\) \d+$/m.test(s.out),s.out);
|
||||
// The file is left as it was: the rehearsal never deletes it and retries.
|
||||
assert.ok(existsSync(join(s.dir,'data','bus-host','sessions.json')));
|
||||
assert.equal(existsSync(rh(f,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R12 mos-0962: a notifier config with a binding fails notify-off',async(t)=>{
|
||||
const e=await rmk(t);
|
||||
ownScript(e,`printf '{"notifyVersion":1,"binding":null}\\n'`,`printf '{"notifyVersion":1,"binding":"discord"}\\n'`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
|
||||
assert.equal(r.code,1,r.out+r.err);
|
||||
assert.ok(r.out.includes('FAIL notify-off: '),r.out);
|
||||
assert.equal(existsSync(rh(e,'host-args.log')),false);
|
||||
});
|
||||
|
||||
test('R13 the pre-checks: an old copy, a backup that fails its sums, a missing backup or pointer, and usage',async(t)=>{
|
||||
const e=await rmk(t),before=untouched(e),port=await freePort();
|
||||
const old=await rehearse(e,A,'--backup',e.act,'--port',port);
|
||||
assert.equal(old.code,1);assert.equal(old.out,`FAIL copy-without-no-launch: ${A}'s bus host has no --no-launch flag\nREHEARSAL_FAIL\n`);
|
||||
const none=await rehearse(e,B,'--backup','20261020T000000Z-000000000000','--port',port);
|
||||
assert.equal(none.code,1);assert.ok(none.out.includes(`FAIL backup-missing: ${join(e.data,'backups','stack','20261020T000000Z-000000000000')} has no bus/bus.sqlite and SHA256SUMS\n`),none.out);
|
||||
const f=await rmk(t);appendFileSync(join(f.backup,'bus','bus.sqlite'),'x');
|
||||
const bad=await rehearse(f,B,'--backup',f.act,'--port',port);
|
||||
assert.equal(bad.code,1);assert.ok(bad.out.includes(`FAIL backup-sums: ${f.backup}/bus does not match its SHA256SUMS\n`),bad.out);
|
||||
const g=await rmk(t);rmSync(g.pointer);
|
||||
const np=await rehearse(g,B,'--backup',g.act,'--port',port);
|
||||
assert.equal(np.code,1);assert.match(np.out,/^FAIL no-pointer: /);
|
||||
for(const args of [['--port',port],['--backup',e.act],['--backup',e.act,'--port','80'],['--backup',e.act,'--port','70000'],['--backup','relative/dir','--port',port],['--backup',e.act,'--port',port,'--pm'],['--backup',e.act,'--port',port,'--plan']]){
|
||||
const u=await rehearse(e,B,...args);assert.equal(u.code,4,args.join(' ')+u.out+u.err);assert.match(u.err,/^usage: /);
|
||||
}
|
||||
assert.deepEqual([...rDirs(e),...rDirs(f),...rDirs(g)],[]);
|
||||
assert.equal(untouched(e),before);
|
||||
});
|
||||
|
||||
test('R14 a host that writes the live config, the live business file or the backup fails the end check by name',async(t)=>{
|
||||
for(const [pick,name] of [[(e)=>e.cfg,'live-config-unchanged'],[(e)=>e.biz,'live-config-unchanged'],[(e)=>join(e.backup,'bus','bus.sqlite'),'backup-unchanged']]){
|
||||
const e=await rmk(t),f=pick(e);rfault(e,'host-touches');writeFileSync(rh(e,'faults','host-touches'),`${f}\n`);
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
|
||||
assert.equal(r.code,1,f+r.out+r.err);
|
||||
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${name}`],f+r.out);
|
||||
assert.ok(r.names.includes('PASS gate')&&r.names.includes('PASS host-stopped'),f+r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
}
|
||||
});
|
||||
|
||||
test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops the host and Console and runs the end checks',async(t)=>{
|
||||
for(const sig of ['SIGTERM','SIGINT','SIGHUP']){
|
||||
// The Console never answers /healthz, so the gate is still waiting when the signal comes.
|
||||
const e=await rmk(t);rfault(e,`healthz-${B}`);
|
||||
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill(sig)};
|
||||
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
|
||||
assert.equal(r.code,1,sig+r.out+r.err);
|
||||
assert.deepEqual(r.names,[...PRE,'PASS migration','FAIL signal','PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'],sig+r.out);
|
||||
assert.ok(r.out.includes('FAIL signal: the rehearsal was interrupted\n'),r.out);
|
||||
assert.ok(r.out.endsWith(`REHEARSAL_FAIL\nrehearsal directory: ${r.dir}\n`),r.out);
|
||||
const pid=JSON.parse(readFileSync(join(r.dir,'data','fixture-host.json'),'utf8')).host.pid;
|
||||
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -186,17 +186,22 @@ export function endReason(code, signal) {
|
||||
|
||||
// The host's view of its running sessions, kept in memory and mirrored to
|
||||
// sessions.json for `mosaic stop` and `mosaic launches list`.
|
||||
// readOnly (row 163, a --no-launch host): sessions.json stays as an earlier host left it, so a
|
||||
// later host still finds and ends those runs. add() refuses, and nothing writes the file.
|
||||
export class Registry {
|
||||
#dataRoot;
|
||||
#sessions = [];
|
||||
constructor(dataRoot) {
|
||||
#readOnly;
|
||||
constructor(dataRoot, { readOnly = false } = {}) {
|
||||
this.#dataRoot = dataRoot;
|
||||
this.#readOnly = readOnly;
|
||||
this.#write();
|
||||
}
|
||||
get sessions() {
|
||||
return this.#sessions.map((s) => ({ ...s }));
|
||||
}
|
||||
add(entry) {
|
||||
if (this.#readOnly) throw new SeatError("this host keeps no sessions (--no-launch)", 2);
|
||||
this.#sessions.push({ ...entry });
|
||||
this.#write();
|
||||
}
|
||||
@@ -213,6 +218,7 @@ export class Registry {
|
||||
return this.#sessions.find((s) => s.business === business && s.instance === instance && (s.thread ?? null) === thread) ?? null;
|
||||
}
|
||||
#write() {
|
||||
if (this.#readOnly) return;
|
||||
const file = sessionsFile(this.#dataRoot);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
|
||||
@@ -133,6 +133,23 @@ test("Registry mirrors to sessions.json; readSessions marks live entries; bad fi
|
||||
assert.throws(() => readSessions(root), (e) => e instanceof SeatError && /malformed/.test(e.message));
|
||||
});
|
||||
|
||||
// Row 163: a --no-launch host's registry leaves an earlier host's sessions.json as it was.
|
||||
test("a read-only Registry never writes sessions.json, refuses add() and leaves the file on remove()", (t) => {
|
||||
const root = scratch(t);
|
||||
new Registry(root).add({ business: "acme", instance: "chief", run: "r1", runnerPid: process.pid, runnerStartTime: startTimeOf(process.pid) });
|
||||
const before = readFileSync(sessionsFile(root), "utf8");
|
||||
const r = new Registry(root, { readOnly: true });
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
assert.deepEqual(r.sessions, []);
|
||||
assert.throws(() => r.add({ business: "acme", instance: "coder", run: "r2" }), (e) => e instanceof SeatError && /keeps no sessions/.test(e.message));
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
r.remove("acme", "r1");
|
||||
assert.equal(readFileSync(sessionsFile(root), "utf8"), before);
|
||||
const empty = scratch(t);
|
||||
new Registry(empty, { readOnly: true });
|
||||
assert.equal(existsSync(sessionsFile(empty)), false);
|
||||
});
|
||||
|
||||
// Row 121 (#1615): a thread instance runs once per thread, so the registry
|
||||
// keys a thread run by its thread too.
|
||||
test("REQ-THREAD-1 Registry.running keys a thread run by business, instance and thread", (t) => {
|
||||
|
||||
+304
-2
@@ -15,6 +15,8 @@
|
||||
# 4. this script never prunes backups, rollback sets, post-attempt directories or copies.
|
||||
# Decisions 94 and 102 still decide who starts the Console; webui-service.sh keeps having no start
|
||||
# or stop verb. status and --plan only read, and every seat may run them.
|
||||
# rehearse (D2, row 163) stops and starts no unit and writes only its own rehearsal directory, but
|
||||
# it reads the live config and a backup of the live store, so Jason or Mos runs it.
|
||||
#
|
||||
# Most functions below run as `f || handler`, and bash ignores set -e inside such a call, so
|
||||
# every step that can fail carries its own `|| return 1`.
|
||||
@@ -28,7 +30,7 @@ BIN="$HOME/.npm-global/bin"
|
||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
CONSOLE_UNIT=mosaic-webui.service
|
||||
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 R_BACKUP="" R_PORT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
@@ -38,6 +40,7 @@ usage: stack-release.sh adopt BUSINESS
|
||||
stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||
stack-release.sh status BUSINESS
|
||||
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
||||
common options: --copies DIR, --bin DIR
|
||||
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||
every table in COUNTED.
|
||||
@@ -84,11 +87,14 @@ parse_opts() {
|
||||
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
|
||||
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
--port) [ "$OP" = rehearse ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$2))" -ge 1024 ] && [ "$((10#$2))" -le 65535 ] || usage; R_PORT="$((10#$2))"; shift 2 ;;
|
||||
--events) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^(0|[1-9][0-9]*)$ ]] || usage; EVENTS="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$EVENTS" ] || [ -n "$RESTORE" ] || usage
|
||||
[ "$OP" != rehearse ] || { [ -n "$R_BACKUP" ] && [ -n "$R_PORT" ]; } || usage
|
||||
# A bad tester port is a usage error; --plan lists it as tester-port-invalid instead (row 180).
|
||||
[ -z "$TESTER_PORT" ] || [ "$PLAN" = 1 ] || valid_tester_port "$TESTER_PORT" 7332 || usage
|
||||
# --pm-after-harness replaces --pm (row 169 item 1), so the two never go together.
|
||||
@@ -1155,13 +1161,307 @@ do_status() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- The rehearsal (D2, row 163) ---
|
||||
|
||||
# rehearse runs the new copy's bus host (--no-launch) and Console on a scratch root built from a
|
||||
# backup, under <dataRoot>/rehearsals/<stamp>/, and gates them as activate does. It never writes the
|
||||
# live store, the live config files, the backup, the pointer, the log or a unit. Nothing prunes
|
||||
# the rehearsal directory. Design, rulings (mos-0961, mos-0962, mos-0963) and the deviations from
|
||||
# the brief: agents/sage/work/queue-163/BUILD.md.
|
||||
|
||||
# One PASS or FAIL line per check, on stdout and in steps.log. Lines from before the directory
|
||||
# exists wait in R_EARLY and go in first.
|
||||
r_line() {
|
||||
echo "$*"
|
||||
if [ -z "$STEPS" ]; then R_EARLY+=("$(now) $*"); else echo "$(now) $*" >> "$STEPS" || true; fi
|
||||
}
|
||||
r_pass() { r_line "PASS $1${2:+: $2}"; }
|
||||
r_fail() { r_line "FAIL $1${2:+: $2}"; R_RC=1; }
|
||||
r_abort() { r_fail "$@"; r_end; }
|
||||
|
||||
r_alive() { local s; s="$(cat "/proc/$1/stat" 2>/dev/null)" || return 1; s="${s##*) }"; [ "${s:0:1}" != Z ]; }
|
||||
r_gone() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
while r_alive "$1"; do [ "$SECONDS" -lt "$deadline" ] || return 1; sleep 1; done
|
||||
}
|
||||
|
||||
# Stops the Console, then the host, with SIGTERM within --timeout; one still there gets SIGKILL
|
||||
# and a FAIL line. The host has to leave the store's writer.lock behind it.
|
||||
r_stop() {
|
||||
local name pid
|
||||
for name in console host; do
|
||||
if [ "$name" = console ]; then pid="$R_CONSOLE_PID"; R_CONSOLE_PID=""; else pid="$R_HOST_PID"; R_HOST_PID=""; fi
|
||||
[ -n "$pid" ] || continue
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
if r_gone "$pid"; then r_pass "$name-stopped" "pid $pid"
|
||||
else kill -KILL "$pid" 2>/dev/null || true; r_fail "$name-stopped" "pid $pid was still running ${TIMEOUT}s after SIGTERM; sent SIGKILL"
|
||||
fi
|
||||
wait "$pid" 2>/dev/null || true
|
||||
done
|
||||
[ -n "$R_STARTED" ] || return 0
|
||||
R_STARTED=""
|
||||
if [ -e "$R/data/bus/writer.lock" ]; then r_fail host-released-store "$R/data/bus/writer.lock is still there"; else r_pass host-released-store; fi
|
||||
}
|
||||
|
||||
r_tree_sums() { (cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha256sum); }
|
||||
|
||||
# The last checks, then the last line. The backup and the live config files are compared with the
|
||||
# sums taken before the rehearsal directory was made.
|
||||
r_end() {
|
||||
trap - INT TERM HUP
|
||||
r_stop
|
||||
if [ -n "$R" ]; then
|
||||
if [ "$(r_tree_sums "$R_BK")" = "$(cat "$R/backup.before")" ]; then r_pass backup-unchanged; else r_fail backup-unchanged "$R_BK differs from $R/backup.before"; fi
|
||||
if [ "$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" = "$(cat "$R/live.before")" ]; then r_pass live-config-unchanged; else r_fail live-config-unchanged "$LIVE_CFG or $LIVE_BIZ differs from $R/live.before"; fi
|
||||
fi
|
||||
if [ "$R_RC" = 0 ]; then r_line REHEARSAL_PASS; else r_line REHEARSAL_FAIL; fi
|
||||
[ -z "$R" ] || echo "rehearsal directory: $R"
|
||||
exit "$R_RC"
|
||||
}
|
||||
|
||||
r_port_free() {
|
||||
node -e 'const s=require("node:net").createServer();s.once("error",()=>process.exit(1));s.listen(Number(process.argv[1]),"127.0.0.1",()=>s.close(()=>process.exit(0)));' -- "$1"
|
||||
}
|
||||
|
||||
# The scratch config: the live one with dataRoot set to the scratch data root.
|
||||
R_CONFIG_JS='
|
||||
const fs=require("node:fs");const [file,data]=process.argv.slice(1);
|
||||
const c=JSON.parse(fs.readFileSync(file,"utf8"));c.dataRoot=data;process.stdout.write(JSON.stringify(c,null,2)+"\n");'
|
||||
|
||||
# The scratch business file (mos-0962, mos-0963): the live file without its tracker.* variables,
|
||||
# business and role, and with every credential reference, the roles and tracker.sync, moved to its
|
||||
# own 0600 placeholder file. The service and its rotateBy or expires date stay. The tracker key
|
||||
# stays because the loader requires it. Mode `normalize` prints the live file as this writes it,
|
||||
# for the diff.
|
||||
R_BUSINESS_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const [mode,live,out,ph]=process.argv.slice(1);
|
||||
const b=JSON.parse(fs.readFileSync(live,"utf8"));
|
||||
if(mode==="normalize"){process.stdout.write(JSON.stringify(b,null,2)+"\n");process.exit(0)}
|
||||
const DATE={gitea:"rotateBy",vikunja:"expires"};
|
||||
const strip=(vars)=>{if(vars&&typeof vars==="object")for(const k of Object.keys(vars))if(k.startsWith("tracker."))delete vars[k]};
|
||||
const swap=(name,refs)=>{
|
||||
if(!refs||typeof refs!=="object")return;
|
||||
for(const s of Object.keys(refs)){
|
||||
const file=path.join(ph,`${name}-${s}`),old=refs[s],ref={file};
|
||||
fs.writeFileSync(file,"rehearsal-placeholder-token",{mode:0o600,flag:"wx"});
|
||||
if(old&&typeof old==="object"&&Object.hasOwn(DATE,s)&&old[DATE[s]]!==undefined)ref[DATE[s]]=old[DATE[s]];
|
||||
refs[s]=ref;
|
||||
}
|
||||
};
|
||||
strip(b.vars);
|
||||
for(const [name,r] of Object.entries(b.roles??{})){strip(r?.vars);swap(name,r?.credentials)}
|
||||
swap("@sync",b.tracker?.sync?.credentials);
|
||||
fs.writeFileSync(out,JSON.stringify(b,null,2)+"\n",{mode:0o600,flag:"wx"});'
|
||||
|
||||
# The guard, before the host starts. It reads the scratch config through the new copy's own
|
||||
# loadSystem and bootConfig, as the host will, and prints one line per check:
|
||||
# data-root-live the data root is not the live one (mos-0961)
|
||||
# sessions-live no sessions.json there names a running process (mos-0961)
|
||||
# data-root-scratch the data root is the scratch one
|
||||
# notify-off the notifier config is {"notifyVersion":1,"binding":null}, 0600 (mos-0962)
|
||||
# tracker-configured bootConfig sets no trackers (mos-0962)
|
||||
# credential-ref-live every credential reference is a file under placeholders/ (mos-0963)
|
||||
# Exit 0 when all pass, 1 on the first FAIL.
|
||||
R_GUARD_JS='
|
||||
const fs=require("node:fs"),path=require("node:path");const {pathToFileURL}=require("node:url");
|
||||
const [copy,id,live,scratch,ph]=process.argv.slice(1);
|
||||
const real=(p)=>{try{return fs.realpathSync(p)}catch{return null}};
|
||||
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,"utf8");return s[s.lastIndexOf(")")+2]!=="Z"}catch{return false}};
|
||||
const pass=(n,w)=>console.log(`PASS ${n}${w?": "+w:""}`);
|
||||
const fail=(n,w)=>{console.log(`FAIL ${n}: ${w}`);process.exit(1)};
|
||||
(async()=>{
|
||||
let c,system,config;
|
||||
try{c=await import(pathToFileURL(`${copy}/packages/cli/src/config.mjs`).href);system=c.loadSystem({env:process.env})}
|
||||
catch(e){fail("scratch-config",String(e?.message??e).split("\n")[0])}
|
||||
const root=real(system.dataRoot);
|
||||
if(root===null)fail("data-root-scratch",`${system.dataRoot} does not exist`);
|
||||
if(root===real(live))fail("data-root-live",`the scratch config names dataRoot ${system.dataRoot}, which resolves to the live data root ${live}`);
|
||||
pass("data-root-live",`${system.dataRoot} is not the live data root`);
|
||||
const sf=path.join(root,"bus-host","sessions.json");
|
||||
if(fs.existsSync(sf)){
|
||||
let doc;try{doc=JSON.parse(fs.readFileSync(sf,"utf8"))}catch{fail("sessions-live",`${sf} is unreadable`)}
|
||||
if(!Array.isArray(doc?.sessions))fail("sessions-live",`${sf} is malformed`);
|
||||
const running=doc.sessions.flatMap((s)=>[s?.pid,s?.runnerPid]).filter((p)=>Number.isSafeInteger(p)&&p>0&&alive(p));
|
||||
if(running.length)fail("sessions-live",`${sf} names running pid(s) ${running.join(", ")}`);
|
||||
}
|
||||
pass("sessions-live","no running session named in the data root");
|
||||
if(root!==real(scratch))fail("data-root-scratch",`the scratch config resolves to ${system.dataRoot}, not ${scratch}`);
|
||||
pass("data-root-scratch");
|
||||
const nf=path.join(root,"notify",id,"notify.json");
|
||||
let st,doc;try{st=fs.lstatSync(nf);doc=JSON.parse(fs.readFileSync(nf,"utf8"))}catch{fail("notify-off",`${nf} is missing or not JSON`)}
|
||||
if(!st.isFile()||(st.mode&0o777)!==0o600||JSON.stringify(doc)!==JSON.stringify({notifyVersion:1,binding:null}))fail("notify-off",`${nf} is not {"notifyVersion":1,"binding":null} at mode 0600`);
|
||||
pass("notify-off","binding null, so no Discord bridge");
|
||||
try{config=c.bootConfig({system,businessId:id,env:process.env})}catch(e){fail("boot-config",String(e?.message??e).split("\n")[0])}
|
||||
if(config.trackers!==undefined)fail("tracker-configured",`bootConfig sets trackers for ${Object.keys(config.trackers).join(", ")}`);
|
||||
pass("tracker-configured","bootConfig sets no trackers");
|
||||
const b=config.businesses?.[id],phr=real(ph),refs=[];
|
||||
for(const [role,r] of Object.entries(b?.roles??{}))for(const [s,ref] of Object.entries(r?.credentials??{}))refs.push([`${role} ${s}`,ref]);
|
||||
for(const [s,ref] of Object.entries(b?.tracker?.sync?.credentials??{}))refs.push([`@sync ${s}`,ref]);
|
||||
for(const [what,ref] of refs){
|
||||
if(ref?.env!==undefined)fail("credential-ref-live",`${what} is env ${ref.env}`);
|
||||
const f=typeof ref?.file==="string"?real(ref.file):null;
|
||||
if(f===null||phr===null||path.dirname(f)!==phr)fail("credential-ref-live",`${what} is ${ref?.file}, not a file in ${ph}`);
|
||||
}
|
||||
pass("credential-ref-live",`${refs.length} reference(s), each a placeholder`);
|
||||
})().catch((e)=>fail("guard",String(e?.message??e).split("\n")[0]));'
|
||||
|
||||
# Opens a data root's store with a copy's Store, as the bus does at start, with no migration
|
||||
# (de352dfaf's verify-open.mjs). Prints OPEN_OK <version> or REFUSED <message>.
|
||||
R_OPEN_JS='
|
||||
const {pathToFileURL}=require("node:url");const [copy,root]=process.argv.slice(1);
|
||||
(async()=>{
|
||||
const {Store}=await import(pathToFileURL(`${copy}/packages/bus/src/store.mjs`).href);
|
||||
let s;
|
||||
try{s=new Store(root);process.stdout.write(`OPEN_OK ${s.get("SELECT value FROM meta WHERE key=?","schema_version").value}\n`)}
|
||||
catch(e){process.stdout.write(`REFUSED ${e.message}\n`)}
|
||||
finally{s?.close()}
|
||||
})();'
|
||||
r_open() { node -e "$R_OPEN_JS" -- "$1" "$2" 2>&1 || true; }
|
||||
|
||||
# Every process this rehearsal starts gets only HOME, PATH and the scratch MOSAIC_CONFIG.
|
||||
r_env() { env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$@"; }
|
||||
|
||||
# The de352dfaf checks, when the backup's schema is not the new copy's.
|
||||
r_migration() {
|
||||
local copy="$COPIES/$TO" old="$COPIES/$P_COPY" v out rc
|
||||
v="$(node -e "$STORE_JS" -- version "$R/data/bus/bus.sqlite" 2>&1)" || r_abort store-unreadable "$v"
|
||||
if [ "$v" = "$T_SCHEMA" ]; then r_pass migration "not needed: the backup is already $v"; return 0; fi
|
||||
in_chain "$v" || r_abort store-newer "the backup is $v, which $TO's migration chain does not start from"
|
||||
(umask 077; mkdir "$R/refuse" "$R/refuse/bus" && cp "$R_BK"/bus/* "$R/refuse/bus/") || r_abort scratch-build "cannot copy the backup to $R/refuse"
|
||||
out="$(r_open "$copy" "$R/refuse")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort new-refuses-old-store "$TO opened the $v store: $out"
|
||||
r_pass new-refuses-old-store "$TO refuses the $v store at a normal open"
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate "migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate "$v to $T_SCHEMA"
|
||||
out="$(r_open "$copy" "$R/data")"
|
||||
[ "$out" = "OPEN_OK $T_SCHEMA" ] || r_abort new-opens-migrated "$out"
|
||||
r_pass new-opens-migrated
|
||||
rc=0; out="$(r_env node "$copy/packages/bus/src/migrate-schema.mjs" "$R/data" 2>&1)" || rc=$?
|
||||
printf '%s\n' "$out" >> "$R/migrate.log"
|
||||
[ "$rc" = 0 ] && [ "$(tail -n 1 <<< "$out")" = "bus schema $T_SCHEMA ready" ] || r_abort migrate-again "second migrate-schema.mjs exit $rc: $(tail -n 1 <<< "$out")"
|
||||
r_pass migrate-again "a second run is a no-op"
|
||||
out="$(r_open "$old" "$R/data")"
|
||||
[ "$out" = "REFUSED schema-mismatch" ] || r_abort old-refuses-migrated "the pointer's copy $P_COPY opened the $T_SCHEMA store: $out"
|
||||
r_pass old-refuses-migrated "$P_COPY refuses the $T_SCHEMA store"
|
||||
}
|
||||
|
||||
# One pass of D1's gate checks but the PM's, against the processes this run started. Sets R_GATE
|
||||
# to the first failing check and R_WHY to what it saw.
|
||||
r_gate_once() {
|
||||
local copy="$COPIES/$TO" out
|
||||
R_GATE=host-up R_WHY=""
|
||||
r_alive "$R_HOST_PID" || { R_WHY="the host (pid $R_HOST_PID) exited; see $R/host.log"; return 1; }
|
||||
grep -q "^bus host up: business $BUSINESS, .*, launches disabled\$" "$R/host.log" || { R_WHY="no \"bus host up ... launches disabled\" line in $R/host.log"; return 1; }
|
||||
R_GATE=bus-status
|
||||
out="$(r_env "$copy/scripts/mosaic" bus status --json 2>/dev/null)" || { R_WHY="bus status failed"; return 1; }
|
||||
R_WHY="$(node -e '
|
||||
const [b,main,since]=process.argv.slice(2),s=JSON.parse(process.argv[1]);
|
||||
const why=!s.host?"no host":s.host.business!==b?`host for ${s.host.business}`:s.host.live!==true?"host not live":String(s.host.pid)!==main?`host pid ${s.host.pid} is not ${main}, the host this run started`:!s.boot||s.boot.ok!==true?"boot status not ok":s.boot.business!==b?`boot status for ${s.boot.business}`:!(Date.parse(s.boot.at)>=Number(since))?"boot status is from before this start":"";
|
||||
process.stdout.write(why);' -- "$out" "$BUSINESS" "$R_HOST_PID" "$START_MS" 2>&1)" || { R_WHY="bus status unreadable"; return 1; }
|
||||
[ -z "$R_WHY" ] || return 1
|
||||
R_GATE=console-up
|
||||
r_alive "$R_CONSOLE_PID" || { R_WHY="the Console (pid $R_CONSOLE_PID) exited; see $R/console.log"; return 1; }
|
||||
grep -qxF "Mosaic Console: http://127.0.0.1:$R_PORT/" "$R/console.log" || { R_WHY="no \"Mosaic Console: http://127.0.0.1:$R_PORT/\" line in $R/console.log"; return 1; }
|
||||
R_GATE=healthz
|
||||
node -e '
|
||||
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$R_PORT" || return 1
|
||||
R_GATE=copy-match
|
||||
tr '\0' ' ' < "/proc/$R_HOST_PID/cmdline" | grep -qF " $copy/packages/cli/src/cli.mjs bus start $BUSINESS --no-launch" || { R_WHY="the host's command line is not $copy's cli.mjs bus start $BUSINESS --no-launch"; return 1; }
|
||||
tr '\0' ' ' < "/proc/$R_CONSOLE_PID/cmdline" | grep -qF " $copy/packages/webui/src/cli.mjs serve " || { R_WHY="the Console's command line is not $copy's webui cli.mjs serve"; return 1; }
|
||||
R_GATE=""
|
||||
}
|
||||
|
||||
r_gate() {
|
||||
local deadline=$((SECONDS + TIMEOUT))
|
||||
until r_gate_once; do
|
||||
[ "$SECONDS" -lt "$deadline" ] || { r_fail "$R_GATE" "${R_WHY:-no answer within ${TIMEOUT}s}"; return 1; }
|
||||
sleep 1
|
||||
done
|
||||
r_pass gate "host-up, bus-status, console-up, healthz and copy-match on $TO"
|
||||
}
|
||||
|
||||
# No role session: the host wrote no sessions.json and no launches directory or log.
|
||||
r_no_session() {
|
||||
local found=""
|
||||
[ ! -e "$R/data/bus-host/sessions.json" ] || found+=" bus-host/sessions.json"
|
||||
[ ! -e "$R/data/launches" ] || found+=" launches"
|
||||
if [ -z "$found" ]; then r_pass no-session "no sessions.json and no launches under $R/data"; else r_fail no-session "found$found under $R/data"; fi
|
||||
}
|
||||
|
||||
do_rehearse() {
|
||||
TO="$ID"
|
||||
trap 'r_fail signal "the rehearsal was interrupted"; r_end' INT TERM HUP
|
||||
read_pointer || r_abort no-pointer "no $POINTER; the pointer's copy is the old copy the checks use"
|
||||
[ "$P_BUSINESS" = "$BUSINESS" ] || r_abort business-mismatch "the pointer is for $P_BUSINESS"
|
||||
copy_exists "$TO" 2>/dev/null || r_abort copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || r_abort copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || r_abort copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
# mos-0961: an older copy's host has no --no-launch and would start the PM on its own.
|
||||
grep -qF -- '"--no-launch"' "$COPIES/$TO/packages/cli/src/cli.mjs" 2>/dev/null || r_abort copy-without-no-launch "$TO's bus host has no --no-launch flag"
|
||||
r_pass copy "$TO, schema $T_SCHEMA"
|
||||
local i
|
||||
local -a reserved=(7332 7333 "$(console_port)")
|
||||
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
|
||||
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
|
||||
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
|
||||
r_pass port "127.0.0.1:$R_PORT is free"
|
||||
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
|
||||
[ -f "$R_BK/bus/bus.sqlite" ] && [ -f "$R_BK/SHA256SUMS" ] || r_abort backup-missing "$R_BK has no bus/bus.sqlite and SHA256SUMS"
|
||||
(cd "$R_BK/bus" && sha256sum -c --quiet ../SHA256SUMS) >/dev/null 2>&1 || r_abort backup-sums "$R_BK/bus does not match its SHA256SUMS"
|
||||
r_pass backup "$R_BK matches its SHA256SUMS"
|
||||
LIVE_CFG="$(realpath -m "${MOSAIC_CONFIG:-$HOME/.config/mosaic-dev/config.json}")"
|
||||
LIVE_BIZ="$(dirname "$LIVE_CFG")/businesses/$BUSINESS.json"
|
||||
[ -f "$LIVE_BIZ" ] || r_abort business-missing "no $LIVE_BIZ"
|
||||
|
||||
local stamp before live
|
||||
before="$(r_tree_sums "$R_BK")" && live="$(sha256sum "$LIVE_CFG" "$LIVE_BIZ" 2>&1)" || r_abort sums "cannot read $R_BK or the live config files"
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)-${TO:0:12}"
|
||||
(umask 077; mkdir -p "$DATA/rehearsals" && mkdir "$DATA/rehearsals/$stamp") || r_abort rehearsal-dir "cannot make $DATA/rehearsals/$stamp (one rehearsal per second and copy)"
|
||||
R="$DATA/rehearsals/$stamp" STEPS="$DATA/rehearsals/$stamp/steps.log"
|
||||
printf '%s\n' "${R_EARLY[@]}" > "$STEPS" && printf '%s\n' "$before" > "$R/backup.before" && printf '%s\n' "$live" > "$R/live.before" ||
|
||||
r_abort scratch-build "cannot write the sums to $R"
|
||||
(
|
||||
umask 077
|
||||
mkdir "$R/config" "$R/config/businesses" "$R/placeholders" "$R/data" "$R/data/bus" "$R/data/notify" "$R/data/notify/$BUSINESS" &&
|
||||
cp "$R_BK"/bus/* "$R/data/bus/" &&
|
||||
node -e "$R_CONFIG_JS" -- "$LIVE_CFG" "$R/data" > "$R/config/config.json" &&
|
||||
node -e "$R_BUSINESS_JS" -- write "$LIVE_BIZ" "$R/config/businesses/$BUSINESS.json" "$R/placeholders" &&
|
||||
printf '{"notifyVersion":1,"binding":null}\n' > "$R/data/notify/$BUSINESS/notify.json"
|
||||
) 2>> "$STEPS" || r_abort scratch-build "cannot build the scratch root under $R; see $STEPS"
|
||||
local rc=0
|
||||
diff -u --label "$LIVE_BIZ (as JSON.stringify writes it)" --label "$R/config/businesses/$BUSINESS.json" \
|
||||
<(node -e "$R_BUSINESS_JS" -- normalize "$LIVE_BIZ") "$R/config/businesses/$BUSINESS.json" > "$R/business.diff" || rc=$?
|
||||
[ "$rc" -le 1 ] || r_abort scratch-build "cannot write $R/business.diff"
|
||||
r_pass scratch-root "$R/data from the backup, config and business file under $R/config, business.diff beside them"
|
||||
|
||||
local out
|
||||
rc=0; out="$(r_env node -e "$R_GUARD_JS" -- "$COPIES/$TO" "$BUSINESS" "$DATA" "$R/data" "$R/placeholders" 2>&1)" || rc=$?
|
||||
while IFS= read -r i; do r_line "$i"; done <<< "$out"
|
||||
[ "$rc" = 0 ] || { R_RC=1; r_end; }
|
||||
|
||||
r_migration
|
||||
|
||||
START_MS="$(date +%s%3N)"
|
||||
R_STARTED=1
|
||||
# env, not r_env: a function in the background is a subshell, and $! has to be the process
|
||||
# itself (env execs scripts/mosaic, which execs node), for the bus-status pid check and the stop.
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" "$COPIES/$TO/scripts/mosaic" bus start "$BUSINESS" --no-launch > "$R/host.log" 2>&1 &
|
||||
R_HOST_PID=$!
|
||||
env -i HOME="$HOME" PATH="$PATH" MOSAIC_CONFIG="$R/config/config.json" node "$COPIES/$TO/packages/webui/src/cli.mjs" serve --port "$R_PORT" --business "$BUSINESS" > "$R/console.log" 2>&1 &
|
||||
R_CONSOLE_PID=$!
|
||||
if r_gate; then r_no_session; fi
|
||||
r_end
|
||||
}
|
||||
|
||||
# --- Dispatch ---
|
||||
|
||||
[ $# -gt 0 ] || usage
|
||||
OP="$1"; shift
|
||||
ID=""
|
||||
case "$OP" in
|
||||
activate) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
activate|rehearse) [ $# -ge 2 ] || usage; ID="$1"; BUSINESS="$2"; shift 2; [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || usage ;;
|
||||
adopt|pm-on|rollback|status) [ $# -ge 1 ] || usage; BUSINESS="$1"; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) usage ;;
|
||||
@@ -1179,10 +1479,12 @@ BUS_UNIT="mosaic-bus@$BUSINESS.service"
|
||||
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
|
||||
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" PLAN_REFUSE=()
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
activate) do_activate ;;
|
||||
pm-on) do_pm_on ;;
|
||||
rollback) do_rollback ;;
|
||||
status) do_status ;;
|
||||
rehearse) do_rehearse ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user