diff --git a/AGENTS.md b/AGENTS.md index 15446b34d..38f3469f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,10 +25,15 @@ green at every step. Not production software — a proven foundation. 1. **Root is bootstrap-only.** First-class system configuration lives at the repository root; everything else gets a dedicated directory (`roles/`, `contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root. -2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system - config — created only by `scripts/bootstrap.sh`, never overwritten, - fail-closed on any problem. Repo-scoped role authority lives in - `roles/*.json` (versioned, reviewed commits only). +2. **Configuration**: each stack instance has exactly one system config. The + default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's + is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker + that names it. `~` is the home of the OS user that runs the instance. A + process reads only the config `MOSAIC_CONFIG` names, or the default's when + that is unset. Configs are created only by + `scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on + any problem. Repo-scoped role authority lives in `roles/*.json` + (versioned, reviewed commits only). 3. **Secrets** never enter the repository or container images; auth is runtime-only (read-only mount or environment variable). 4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and @@ -177,8 +182,8 @@ Full reference — usage, fields, exit codes, safety notes: ## Data map (canon) -- `~/.config/mosaic-dev/config.json` — system config (user-authored; never - auto-written). +- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json` + per named instance: system config (user-authored; never auto-written). - `` (from config; default `~/.mosaic-dev`): - `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`) - `sessions/` — pi JSONL session trees, one directory per named session diff --git a/docs/TOOLS.md b/docs/TOOLS.md index c45b35984..01ebe6403 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -15,7 +15,7 @@ and changes nothing. | Command | Purpose | Notes | |---|---|---| -| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten | +| `scripts/bootstrap.sh [--instance NAME]` | Create `~/.config/mosaic-dev/config.json` if absent; with `--instance NAME`, `~/.config/mosaic-NAME/config.json` and the claimed root `~/.mosaic-NAME` | Idempotent; existing config validated, never rewritten. See [Stack instances](#stack-instances---instance-name-row-190) | | `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version | | `scripts/hello.sh` | One-shot startup request | Prints model response on stdout | | `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills | @@ -321,12 +321,86 @@ stdin and stdout it exits 4 with the usage. Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input · `3` refused or config problem · `4` usage. Details: `packages/cli/README.md`. +## Stack instances (`--instance NAME`, row 190) + +Several stacks can run on one host, each with its own config, data root, +units, Console port and CLI wrapper. The default instance is today's stack +and keeps every name it had. A named instance is created once: + +``` +scripts/bootstrap.sh --instance NAME +``` + +NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4 +(`instance-name-invalid`), and `--instance dev` is the default. Bootstrap +writes `~/.config/mosaic-NAME/config.json` (directory 0700, file 0644) and +claims `~/.mosaic-NAME` (0700) with the marker `instance.json`, +`{"instanceVersion":1,"name":"NAME"}`. It refuses with exit 3 and writes +nothing when the root exists, is not empty and holds no marker naming NAME. +A second run changes nothing. `~` is the home of the OS user that runs the +instance. + +`--instance NAME` is taken, once and in any position, by `bootstrap.sh`, +`bus-service.sh`, `webui-service.sh`, `discord-service.sh`, `stack-copy.sh` +(`wrapper` and `check`) and `stack-release.sh`. Inside a process the config +travels as `MOSAIC_CONFIG`. Selection, checked before any read or write: + +| Given | Config | Names | +|---|---|---| +| neither | `~/.config/mosaic-dev/config.json` | the default's | +| `MOSAIC_CONFIG` only | that file | the default's | +| `--instance NAME` only | `~/.config/mosaic-NAME/config.json` | NAME's | +| both | must be one file (a symlink to it agrees), else exit 4 `instance-config-mismatch` | NAME's | + +Every selection checks the data root's marker. A named instance whose root +lacks its own marker refuses `instance-root-unclaimed`; a root whose marker +names another instance refuses `instance-root-mismatch`, also when a config +reaches it with no flag. Both exit 3 before anything is read or changed. A +missing config exits 3 with the bootstrap command that creates it. + +| | Default | Instance NAME | +|---|---|---| +| bus unit | `mosaic-bus@B.service` | `mosaic-bus-NAME@B.service` | +| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` | +| Discord unit | `mosaic-discord@BINDING.service` | `mosaic-discord-NAME@BINDING.service` | +| CLI wrapper | `$BIN/mosaic` (Q81 marker) | `$BIN/mosaic-NAME`, marker `# mosaic-cli wrapper, instance NAME (row 190)`, exports `MOSAIC_CONFIG` | +| bus session env | `%h/.config/mosaic-dev/secrets/claude-session.env` | `~/.config/mosaic-NAME/secrets/claude-session.env` | +| Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` | + +A named instance's units add `Environment=MOSAIC_CONFIG=` and +` [NAME]` after the Description. The default's renders are byte-identical +to the templates' renders before row 190. `stack-copy.sh wrapper --instance +NAME` creates `$BIN/mosaic-NAME` or replaces one that carries NAME's +marker, and refuses any other file. `check --instance NAME` needs +`--business ID`. + +For every instance, a Console `--port` or `--tester-port` that another +installed Console unit in the unit directory already takes refuses +`port-claimed`, naming that unit: `webui-service.sh install` exits 1, and +`stack-release.sh activate` and `rollback` exit 3 before anything stops +(`--plan` lists it). A unit with no `--port` holds 7332. + +`stack-release.sh --instance NAME` reads only NAME's pointer, log, store and +backups. Its busy check sees only NAME's bus units. Its drop-in scan adds +`mosaic-bus-NAME@*.service.d` and the shared `mosaic-bus-.service.d`. Its +rollback set holds NAME's two unit files, their own `.d` directories and +`$BIN/mosaic-NAME`; it never captures or restores the shared prefix +directories (`mosaic-bus-.service.d`, `mosaic-webui-.service.d`, +`mosaic-discord-.service.d`). The live-session guard refuses a path inside +any named instance's config directory or `~/.mosaic-NAME`, even when that +instance's config is unreadable. + +All instances run under one OS user for now. That user's processes can +read and control every instance; a separate user per instance is row 195. +A real second instance on this host is a Q173 window, and tunnel or Traefik +exposure of a named Console waits for Jason after Q175 SSO. + ## Stack copies (`scripts/stack-copy.sh`) ``` scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] -scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR] -scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID] +scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] +scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID] ``` One read-only tree supplies the bus, Console and human CLI (row 85, @@ -372,7 +446,7 @@ scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--mig scripts/stack-release.sh pm-on BUSINESS [--timeout S] scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan] scripts/stack-release.sh status BUSINESS -common options: --copies DIR, --bin DIR +common options: --copies DIR, --bin DIR, --instance NAME --events N is the total of the per-table counts the post-backup-events refusal prints: events and every table in COUNTED. ``` @@ -602,6 +676,7 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error. ```bash scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy +# render, install, uninstall and status also take --instance NAME (row 190) scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS] scripts/webui-service.sh uninstall [--dir DIR] [--no-reload] @@ -679,8 +754,9 @@ Each value is held to a plain character set the unit can carry (no space, `%`, `$`, quote, backslash, `&` or `|`), and the preview values must start with a letter or digit; anything else is refused. With `--auth-header X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik -uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`, -`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a +uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`, +`CLAUDE*`, `CODEX*` or `PI_*` variable; a named instance's sets only +`MOSAIC_CONFIG` (row 190). Serve's exit 2 (usage, config, or a port already in use) is not restarted. The update/rollback sequence is [Pinned stack update](#pinned-stack-update) diff --git a/packages/cli/README.md b/packages/cli/README.md index 2ddff0187..8bb35e827 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -717,6 +717,19 @@ without it, so Pi sessions still launch and a Claude Code launch refuses (or this line and this paragraph), then restart the unit; a running host keeps the environment it started with. +A named stack instance (row 190) takes `--instance NAME` with any verb, in +any position. The unit is then `mosaic-bus-NAME@.service`, it sets +`Environment=MOSAIC_CONFIG=` to `~/.config/mosaic-NAME/config.json`, its +Description ends in ` [NAME]`, and its `EnvironmentFile=` is +`secrets/claude-session.env` in that config directory, not mosaic-dev's. +Install's busy and drop-in checks cover only `mosaic-bus-NAME@*` units and +their `.d` directories, plus the prefix directories systemd applies to them: +`mosaic-bus-.service.d`, `mosaic-.service.d` and `service.d`. A data root +that another instance claims, or a named root with no `instance.json`, exits +3 before anything is read. Without the flag the unit renders byte for byte +as before. Selection, names and refusals are in +[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190). + The unit has no `network-online.target` ordering, since a user unit cannot order on that system target. The notifier needs no network at start: a send that fails is journaled and retried. diff --git a/packages/cli/systemd/mosaic-bus.service.in b/packages/cli/systemd/mosaic-bus.service.in index 50728f675..b76330227 100644 --- a/packages/cli/systemd/mosaic-bus.service.in +++ b/packages/cli/systemd/mosaic-bus.service.in @@ -13,6 +13,7 @@ StartLimitBurst=5 Type=simple WorkingDirectory=@REPO@ Environment=PATH=@PATH@ +@CONFIG_ENV@ # The Claude Code binary that managed sessions and the manifest's # claudeVersion run, pinned at install (`--claude PATH`, default # `command -v claude`). Without it they run `claude` from PATH above. @@ -21,7 +22,7 @@ Environment=PATH=@PATH@ # operator writes: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or a proxy's # ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL. The `-` lets the unit start # without it, and a Claude Code launch then refuses. -EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env +EnvironmentFile=-@SESSION_ENV@ # The host reads the system config and the business file, boots the broker # (packages/bus/src/process.mjs) and starts the notifier. Config problems and # refusals exit 3, usage 4, invalid input 2: none of those is retried. A diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index 3be5f1fa8..6983d18cf 100644 --- a/packages/cli/tests/host.test.mjs +++ b/packages/cli/tests/host.test.mjs @@ -837,7 +837,11 @@ test("bus start --no-launch says launches are disabled in its host-up line and s 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"); - const render = spawnSync(script, ["render"], { encoding: "utf8" }); + // Row 190: the script selects an instance from the system config first; a + // scratch path keeps it off ~/.config/mosaic-dev. A missing config selects + // the default names, as before. + const cfgEnv = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json") }; + const render = spawnSync(script, ["render"], { encoding: "utf8", env: cfgEnv }); 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/); @@ -855,7 +859,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", ( const fakeBin=join(dir,"bin");mkdirSync(fakeBin); writeFileSync(join(fakeBin,"systemctl"),"#!/bin/sh\ncase \"$*\" in *list-units*) exit 0;; *)exit 99;; esac\n");chmodSync(join(fakeBin,"systemctl"),0o755); writeFileSync(join(fakeBin,"systemd-analyze"),`#!/bin/sh\nprintf '%s\\n' '${dir}/unit-search'\n`);chmodSync(join(fakeBin,"systemd-analyze"),0o755); - const unitEnv={...process.env,PATH:`${fakeBin}:${process.env.PATH}`}; + const unitEnv={...cfgEnv,PATH:`${fakeBin}:${process.env.PATH}`}; const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }); assert.equal(first.status, 0, first.stderr); assert.match(first.stdout, /written: /); @@ -863,7 +867,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", ( assert.equal(readFileSync(join(dir, "mosaic-bus@.service"), "utf8"), render.stdout); assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /unchanged: /); assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /removed: /); - assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4); + assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8", env: cfgEnv }).status, 4); }); // Row 68: the unit pins the Claude Code binary as MOSAIC_CLAUDE_BIN, from @@ -882,7 +886,8 @@ test("bus-service.sh pins the claude binary, refuses one the unit can't carry, a const onPath = stand(join(dir, "bin", "claude")); const named = stand(join(dir, "claude-named")); symlinkSync(named, join(dir, "claude-link")); - const env = { ...process.env, PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` }; + // Row 190: a scratch MOSAIC_CONFIG keeps the instance selection off ~/.config/mosaic-dev. + const env = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json"), PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` }; const run = (args, e = env) => spawnSync(script, args, { encoding: "utf8", env: e }); const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1]; diff --git a/packages/cli/tests/stack-instances.test.mjs b/packages/cli/tests/stack-instances.test.mjs new file mode 100644 index 000000000..f9a1fc3ab --- /dev/null +++ b/packages/cli/tests/stack-instances.test.mjs @@ -0,0 +1,450 @@ +// Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2, +// the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and +// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T55 in +// stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and +// ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { once } from "node:events"; +import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { LiveSessionGuard } from "../../conversation/src/guard.mjs"; +import { businessDoc, rolesCopy, writeJson } from "../../business/tests/helpers.mjs"; +import { REPO_SKILLS } from "../../harness/tests/helpers.mjs"; +import { humanEnv, notifyConfig, tmp } from "./helpers.mjs"; + +const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url)); +const CONFIG = join(SCRIPTS, "mosaic-config.mjs"); +const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh"); +const BUS_SERVICE = join(SCRIPTS, "bus-service.sh"); +const MOSAIC = join(SCRIPTS, "mosaic"); + +// The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one. +function homeEnv(home, extra = {}) { + const env = humanEnv({ HOME: home, ...extra }); + if (!("MOSAIC_CONFIG" in extra)) delete env.MOSAIC_CONFIG; + delete env.XDG_CONFIG_HOME; + return env; +} + +function run(cmd, args, env) { + const r = spawnSync(cmd, args, { env, encoding: "utf8", timeout: 30000 }); + return { code: r.status, out: r.stdout, err: r.stderr }; +} + +const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home)); +const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra)); +const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json"); + +// The instance op's exports as an object. +function exportsOf(out) { + return Object.fromEntries(out.trim().split("\n").map((line) => { + const m = line.match(/^(\w+)='(.*)'$/); + assert.ok(m, `not a shell-safe export: ${line}`); + return [m[1], m[2]]; + })); +} + +// Every entry under `dir`: relative path, type, mode, size, mtime and content hash. +function snapshot(dir) { + const out = {}; + const walk = (d) => { + for (const name of readdirSync(d).sort()) { + const p = join(d, name); + const s = lstatSync(p); + const key = relative(dir, p); + if (s.isDirectory()) { + out[key] = { dir: true, mode: s.mode, mtime: s.mtimeMs }; + walk(p); + } else { + out[key] = { mode: s.mode, size: s.size, mtime: s.mtimeMs, sha: createHash("sha256").update(readFileSync(p)).digest("hex") }; + } + } + }; + if (existsSync(dir)) walk(dir); + return out; +} + +const mode = (p) => statSync(p).mode & 0o777; + +test("T-I1 bootstrap --instance creates each instance's config, root and marker once, and refuses what it must not claim", async (t) => { + await t.test("alpha and beta get a 0700 config dir, a config naming ~/.mosaic-NAME, a 0700 root and a 0644 marker", (t) => { + const home = tmp(t, "si-"); + for (const name of ["alpha", "beta"]) { + const r = bootstrap(home, "--instance", name); + assert.equal(r.code, 0, r.err); + const cfg = cfgOf(home, name), root = join(home, `.mosaic-${name}`); + assert.equal(r.err, `mosaic-config: created configuration for instance ${name}: ${cfg}, data root ${root}\n`); + assert.equal(mode(join(home, ".config", `mosaic-${name}`)), 0o700); + assert.equal(mode(cfg), 0o644); + assert.equal(JSON.parse(readFileSync(cfg, "utf8")).dataRoot, root); + assert.equal(mode(root), 0o700); + const marker = join(root, "instance.json"); + assert.equal(readFileSync(marker, "utf8"), `{"instanceVersion":1,"name":"${name}"}\n`); + assert.equal(mode(marker), 0o644); + assert.deepEqual(readdirSync(root), ["instance.json"]); + assert.deepEqual(readdirSync(join(home, ".config", `mosaic-${name}`)), ["config.json"]); + } + assert.equal(existsSync(join(home, ".config", "mosaic-dev")), false); + assert.equal(existsSync(join(home, ".mosaic-dev")), false); + }); + + await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => { + const home = tmp(t, "si-"); + for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); + const before = snapshot(home); + for (const name of ["alpha", "beta"]) { + const r = bootstrap(home, "--instance", name); + assert.equal(r.code, 0, r.err); + assert.equal(r.err, `mosaic-config: configuration already present, validated without changes: ${cfgOf(home, name)}\n`); + } + assert.deepEqual(snapshot(home), before); + }); + + await t.test("a non-empty root without its own marker refuses before anything is written, worktrees included", (t) => { + const home = tmp(t, "si-"); + mkdirSync(join(home, ".mosaic-gamma")); + writeFileSync(join(home, ".mosaic-gamma", "notes.txt"), "someone's data\n"); + mkdirSync(join(home, ".mosaic-worktrees", "row190"), { recursive: true }); + mkdirSync(join(home, ".mosaic-delta")); + writeFileSync(join(home, ".mosaic-delta", "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`); + writeFileSync(join(home, ".mosaic-delta", "other"), "x"); + const before = snapshot(home); + for (const name of ["gamma", "worktrees", "delta"]) { + const r = bootstrap(home, "--instance", name); + assert.equal(r.code, 3, r.err); + assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`)); + assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false); + } + assert.deepEqual(snapshot(home), before); + // A root that is a file refuses the same way. + writeFileSync(join(home, ".mosaic-file"), "x"); + const r = bootstrap(home, "--instance", "file"); + assert.equal(r.code, 3); + assert.match(r.err, /instance-root-unclaimed: .* exists and is not a directory; nothing was written/); + assert.equal(existsSync(join(home, ".config", "mosaic-file")), false); + }); + + await t.test("an empty existing root is claimed, and an existing config whose root lost its marker refuses, not repairs", (t) => { + const home = tmp(t, "si-"); + mkdirSync(join(home, ".mosaic-alpha"), { mode: 0o700 }); + assert.equal(bootstrap(home, "--instance", "alpha").code, 0); + assert.equal(readFileSync(join(home, ".mosaic-alpha", "instance.json"), "utf8"), `{"instanceVersion":1,"name":"alpha"}\n`); + rmSync(join(home, ".mosaic-alpha", "instance.json")); + const before = snapshot(home); + const r = bootstrap(home, "--instance", "alpha"); + assert.equal(r.code, 3); + assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/); + assert.deepEqual(snapshot(home), before); + }); + + await t.test("a dangling symlink at the config path is never written through, named or default", (t) => { + const home = tmp(t, "si-"); + for (const [name, args] of [["alpha", ["--instance", "alpha"]], ["dev", []]]) { + const dir = join(home, ".config", `mosaic-${name}`), target = join(home, `elsewhere-${name}.json`); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + symlinkSync(target, join(dir, "config.json")); + const r = bootstrap(home, ...args); + assert.equal(r.code, 2, `${name}: ${r.err}`); + assert.match(r.err, /configuration path must be a regular, non-symbolic-link file/); + assert.equal(existsSync(target), false, `${name}: the link's target was created`); + assert.deepEqual(readdirSync(dir), ["config.json"]); + assert.ok(lstatSync(join(dir, "config.json")).isSymbolicLink()); + } + assert.deepEqual(readdirSync(home).sort(), [".config"]); + }); + + await t.test("bad names and stray arguments exit 4 and write nothing", (t) => { + const home = tmp(t, "si-"); + for (const name of ["A", "a-b", "x", "a", "abcdefghijklmnopq", "../x", "1ab", "a.b", ""]) { + const r = bootstrap(home, "--instance", name); + assert.equal(r.code, 4, `${name}: ${r.err}`); + assert.match(r.err, /instance-name-invalid/); + } + assert.equal(bootstrap(home, "--instance", "abcdefghijklmnop").code, 0, "16 characters pass"); + rmSync(join(home, ".config"), { recursive: true }); + rmSync(join(home, ".mosaic-abcdefghijklmnop"), { recursive: true }); + for (const args of [["--instance"], ["--instance", "alpha", "extra"], ["extra"], ["--name", "alpha"]]) { + const r = bootstrap(home, ...args); + assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`); + assert.match(r.err, /usage: mosaic-config\.mjs bootstrap \[--instance NAME\]/); + } + assert.deepEqual(readdirSync(home), []); + }); + + await t.test("--instance dev writes exactly what no flag writes", (t) => { + const plain = tmp(t, "si-"), dev = tmp(t, "si-"); + const a = bootstrap(plain), b = bootstrap(dev, "--instance", "dev"); + assert.equal(a.code, 0, a.err); + assert.equal(b.code, 0, b.err); + assert.equal(b.err.replaceAll(dev, "HOME"), a.err.replaceAll(plain, "HOME")); + const shape = (home) => Object.fromEntries(Object.entries(snapshot(home)).map(([k, v]) => [k, { ...v, mtime: 0, sha: v.sha && createHash("sha256").update(readFileSync(join(home, k), "utf8").replaceAll(home, "HOME")).digest("hex") }])); + assert.deepEqual(shape(dev), shape(plain)); + assert.deepEqual(Object.keys(snapshot(plain)), [".config", ".config/mosaic-dev", ".config/mosaic-dev/config.json"]); + }); +}); + +test("T-I2 selection: the four rows of D1 through the instance op and through an entry point", async (t) => { + const home = tmp(t, "si-"); + for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); + const scratch = join(home, "scratch-config.json"); + writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: join(home, "scratch-data"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } }); + + await t.test("row 1: no flag and no MOSAIC_CONFIG is the default; a missing default config leaves the data root empty", () => { + const r = config(home, {}, "instance"); + assert.equal(r.code, 0, r.err); + assert.deepEqual(exportsOf(r.out), { + MOSAIC_INSTANCE: "dev", + MOSAIC_INSTANCE_CONFIG: cfgOf(home, "dev"), + MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", "mosaic-dev"), + MOSAIC_INSTANCE_DATA_ROOT: "", + MOSAIC_UNIT_SUFFIX: "", + }); + assert.deepEqual(exportsOf(config(home, {}, "instance", "--instance", "dev").out), exportsOf(r.out)); + }); + + await t.test("row 2: MOSAIC_CONFIG alone is that config with the default names", () => { + const r = config(home, { MOSAIC_CONFIG: scratch }, "instance"); + assert.equal(r.code, 0, r.err); + assert.deepEqual(exportsOf(r.out), { + MOSAIC_INSTANCE: "dev", + MOSAIC_INSTANCE_CONFIG: scratch, + MOSAIC_INSTANCE_CONFIG_DIR: home, + MOSAIC_INSTANCE_DATA_ROOT: join(home, "scratch-data"), + MOSAIC_UNIT_SUFFIX: "", + }); + }); + + await t.test("row 3: --instance NAME alone is NAME's config, root and suffix", () => { + for (const name of ["alpha", "beta"]) { + const r = config(home, {}, "instance", "--instance", name); + assert.equal(r.code, 0, r.err); + assert.deepEqual(exportsOf(r.out), { + MOSAIC_INSTANCE: name, + MOSAIC_INSTANCE_CONFIG: cfgOf(home, name), + MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", `mosaic-${name}`), + MOSAIC_INSTANCE_DATA_ROOT: join(home, `.mosaic-${name}`), + MOSAIC_UNIT_SUFFIX: `-${name}`, + }); + } + }); + + await t.test("row 4: both must name one file; a symlink to it agrees, any other file exits 4", () => { + const link = join(home, "alpha-link.json"); + symlinkSync(cfgOf(home, "alpha"), link); + for (const given of [cfgOf(home, "alpha"), link]) { + const r = config(home, { MOSAIC_CONFIG: given }, "instance", "--instance", "alpha"); + assert.equal(r.code, 0, r.err); + assert.equal(exportsOf(r.out).MOSAIC_INSTANCE_CONFIG, cfgOf(home, "alpha")); + } + for (const given of [cfgOf(home, "beta"), scratch, join(home, "missing.json")]) { + for (const op of ["instance", "validate", "env", "bootstrap"]) { + const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha"); + assert.equal(r.code, 4, `${op} ${given}: ${r.err}`); + assert.equal(r.out, ""); + assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`)); + } + } + }); + + await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => { + const r = config(home, {}, "instance", "--instance", "gamma"); + assert.equal(r.code, 3); + assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/); + }); + + await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => { + const first = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home)); + assert.equal(first.code, 0, first.err); + assert.match(first.out, new RegExp(`^Environment=MOSAIC_CONFIG=${cfgOf(home, "alpha")}$`, "m")); + assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home)).out, first.out); + assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") })).out, first.out); + const mismatch = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "beta") })); + assert.equal(mismatch.code, 4); + assert.equal(mismatch.out, ""); + assert.match(mismatch.err, /instance-config-mismatch/); + for (const args of [["--instance", "alpha", "render", "--instance", "alpha"], ["render", "--instance"]]) { + const r = run(BUS_SERVICE, args, homeEnv(home)); + assert.equal(r.code, 4, args.join(" ")); + assert.equal(r.out, ""); + assert.match(r.err, /instance: usage: --instance NAME, given once/); + } + const bad = run(BUS_SERVICE, ["--instance", "a-b", "render"], homeEnv(home)); + assert.equal(bad.code, 4); + assert.match(bad.err, /instance-name-invalid/); + }); +}); + +test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => { + const home = tmp(t, "si-"); + for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); + const edit = (name, dataRoot) => writeJson(cfgOf(home, name), { ...JSON.parse(readFileSync(cfgOf(home, name), "utf8")), dataRoot }); + const scratch = join(home, "scratch-config.json"); + const scratchRoot = join(home, "scratch-data"); + writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: scratchRoot, execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } }); + + await t.test("alpha's config pointing at beta's root refuses instance-root-mismatch in every op", () => { + edit("alpha", join(home, ".mosaic-beta")); + for (const op of ["instance", "validate", "env", "bootstrap"]) { + const r = config(home, {}, op, "--instance", "alpha"); + assert.equal(r.code, 3, `${op}: ${r.err}`); + assert.equal(r.out, ""); + assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`)); + } + edit("alpha", join(home, ".mosaic-alpha")); + assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0); + }); + + await t.test("MOSAIC_CONFIG naming alpha's config with no flag refuses: the root is alpha's", () => { + for (const op of ["instance"]) { + const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op); + assert.equal(r.code, 3, r.err); + assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`)); + } + }); + + await t.test("a default config whose root carries alpha's marker refuses; one naming dev, or none, passes", () => { + mkdirSync(scratchRoot, { recursive: true }); + assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0, "no marker passes as today"); + writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`); + const r = config(home, { MOSAIC_CONFIG: scratch }, "instance"); + assert.equal(r.code, 3); + assert.match(r.err, /claimed by instance alpha; select it with --instance alpha/); + writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"dev"}\n`); + assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0); + rmSync(join(scratchRoot, "instance.json")); + }); + + await t.test("a named root whose marker is missing, a symlink, unparsable or the wrong shape refuses instance-root-unclaimed", () => { + const marker = join(home, ".mosaic-alpha", "instance.json"); + const good = readFileSync(marker, "utf8"); + const cases = [ + ["missing", () => {}, /holds no instance\.json naming alpha/], + ["a symlink", () => { writeFileSync(join(home, "real-marker.json"), good); symlinkSync(join(home, "real-marker.json"), marker); }, /must be a regular, non-symbolic-link file/], + ["a directory", () => mkdirSync(marker), /must be a regular, non-symbolic-link file/], + ["unparsable", () => writeFileSync(marker, "{"), /is not a valid marker/], + ["an extra key", () => writeFileSync(marker, `{"instanceVersion":1,"name":"alpha","x":1}\n`), /is not a valid marker/], + ["version 2", () => writeFileSync(marker, `{"instanceVersion":2,"name":"alpha"}\n`), /is not a valid marker/], + ["a bad name", () => writeFileSync(marker, `{"instanceVersion":1,"name":"Alpha"}\n`), /is not a valid marker/], + ]; + for (const [name, change, why] of cases) { + rmSync(marker, { recursive: true, force: true }); + change(); + const r = config(home, {}, "instance", "--instance", "alpha"); + assert.equal(r.code, 3, `${name}: ${r.err}`); + assert.match(r.err, /instance-root-unclaimed/, name); + assert.match(r.err, why, name); + rmSync(marker, { recursive: true, force: true }); + writeFileSync(marker, good); + } + assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0); + }); +}); + +async function until(check, ms) { + const end = Date.now() + ms; + while (!check()) { + if (Date.now() > end) throw new Error("timed out"); + await new Promise((r) => setTimeout(r, 50)); + } +} + +// One named instance in `home`, ready for `bus start acme --no-launch`: bootstrap, a business file in +// its config dir, its own roles copy, the skills its roles name, and a notifier with no binding. +function hostInstance(home, root, name) { + assert.equal(bootstrap(home, "--instance", name).code, 0); + const cfg = cfgOf(home, name), dataRoot = join(home, `.mosaic-${name}`); + const files = join(root, `${name}-files`); + const roles = rolesCopy(files); + const doc = businessDoc(files, "acme"); + for (const r of Object.values(doc.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars }; + writeJson(join(home, ".config", `mosaic-${name}`, "businesses", "acme.json"), { ...doc, plugins: { ...doc.plugins, harness: ["pi", "claude-code"] } }); + cpSync(REPO_SKILLS, join(dataRoot, "skills-enabled"), { recursive: true }); + notifyConfig(dataRoot, "acme", null); + return { name, cfg, dataRoot, env: homeEnv(home, { MOSAIC_CONFIG: cfg, MOSAIC_ROLES_DIR: roles }) }; +} + +test("T-I5 two real bus hosts, one per instance, run at once and each answers from its own socket under its own root", async (t) => { + const root = tmp(t, "si-"); + const home = join(root, "h"); + mkdirSync(home); + const both = ["alpha", "beta"].map((name) => hostInstance(home, root, name)); + for (const inst of both) { + const sock = join(inst.dataRoot, "bus", "broker.sock"); + // The kernel's limit for a Unix socket path is 107 bytes (the row 177 lesson). + assert.ok(Buffer.byteLength(sock) <= 107, `socket path is ${Buffer.byteLength(sock)} bytes: ${sock}`); + inst.sock = sock; + inst.child = spawn(MOSAIC, ["bus", "start", "acme", "--no-launch"], { env: inst.env, stdio: ["ignore", "pipe", "pipe"] }); + t.after(() => inst.child.exitCode === null && inst.child.kill("SIGKILL")); + inst.out = ""; + inst.child.stdout.on("data", (b) => (inst.out += b)); + inst.child.stderr.on("data", (b) => (inst.out += b)); + } + for (const inst of both) await until(() => /bus host up: business acme.*\n/.test(inst.out) || inst.child.exitCode !== null, 30000); + for (const inst of both) { + assert.match(inst.out, new RegExp(`bus host up: business acme, socket ${inst.sock}, notifier off, launches disabled\n`), inst.out); + assert.ok(lstatSync(inst.sock).isSocket()); + } + for (const inst of both) { + const r = run(MOSAIC, ["bus", "status", "--json"], inst.env); + assert.equal(r.code, 0, r.err); + const s = JSON.parse(r.out); + assert.equal(s.host.business, "acme"); + assert.equal(s.host.pid, inst.child.pid); + assert.equal(s.host.live, true); + assert.equal(s.socket, true); + // The broker process under the host holds the writer lock. + assert.equal(s.writerLock.live, true); + inst.lockPid = s.writerLock.pid; + } + assert.notEqual(both[0].child.pid, both[1].child.pid); + assert.notEqual(both[0].lockPid, both[1].lockPid); + // Stopping alpha leaves beta up. + const [alpha, beta] = both; + const exitA = once(alpha.child, "exit"); + const stopA = run(MOSAIC, ["bus", "stop"], alpha.env); + assert.equal(stopA.code, 0, stopA.err); + assert.match(stopA.out, new RegExp(`stopped bus host for acme \\(pid ${alpha.child.pid}\\)`)); + assert.equal((await exitA)[0], 0, alpha.out); + assert.equal(beta.child.exitCode, null); + const still = JSON.parse(run(MOSAIC, ["bus", "status", "--json"], beta.env).out); + assert.equal(still.host.pid, beta.child.pid); + assert.equal(still.writerLock.pid, beta.lockPid); + assert.equal(still.writerLock.live, true); + assert.equal(existsSync(join(alpha.dataRoot, "bus", "writer.lock")), false); + const exitB = once(beta.child, "exit"); + assert.equal(run(MOSAIC, ["bus", "stop"], beta.env).code, 0); + assert.equal((await exitB)[0], 0, beta.out); +}); + +test("T-I10 the guard protects every instance's config dir and data roots, even under a corrupt config", (t) => { + const root = tmp(t, "si-"); + const home = join(root, "h"); + mkdirSync(home); + for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0); + // alpha's root moved by its operator; beta's config is broken. + const moved = join(root, "alpha-data"); + mkdirSync(moved); + writeJson(cfgOf(home, "alpha"), { ...JSON.parse(readFileSync(cfgOf(home, "alpha"), "utf8")), dataRoot: moved }); + writeFileSync(cfgOf(home, "beta"), "{ not json"); + for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) mkdirSync(join(home, ".config", dir)); + const guard = new LiveSessionGuard({ fixtureRoot: root, homes: [home] }); + const refuses = [ + [join(home, ".config", "mosaic-alpha", "x"), "stack instance alpha's config dir"], + [join(home, ".mosaic-alpha", "x"), "stack instance alpha's default data root"], + [join(moved, "sessions", "x"), "stack instance alpha's configured data root"], + [join(home, ".config", "mosaic-beta", "x"), "stack instance beta's config dir"], + [join(home, ".mosaic-beta", "x"), "stack instance beta's default data root"], + [join(home, ".config"), "stack instance alpha's config dir"], + ]; + for (const [p, why] of refuses) { + assert.throws(() => guard.check({ workspace: p }, "bind"), (e) => e.message === `workspace overlaps ${why} (bind)`, p); + } + for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) { + assert.equal(guard.check({ workspace: join(home, ".config", dir, "x") }, "bind"), true, dir); + } + assert.equal(guard.check({ workspace: join(root, "work") }, "bind"), true); +}); diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index a104beb9a..d2c059929 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -25,7 +25,8 @@ const script=(fn)=>`#!${NODE}\n(${fn})();\n`; // systemctl --user: unit state in $SD_STATE/units.json, every call in calls.log. A bus start runs // holder.cjs (the store held open in WAL mode, writer.lock, host.json) and, with --pm in the unit's // ExecStart, writes the `launched` journal line and the launches entry unless a pm- fault -// names another case. Faults are files in $SD_STATE/faults named for the copy they hit. +// names another case. Faults are files in $SD_STATE/faults named for the copy they hit. Units of a +// named instance (row 190) run on their own data root and host files beside the default's. function systemctl(){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'),crypto=process.getBuiltinModule('node:crypto'); const sd=process.env.SD_STATE,data=process.env.SD_DATA,unitDir=path.join(process.env.XDG_CONFIG_HOME,'systemd','user'); @@ -37,12 +38,16 @@ function systemctl(){ const save=(s)=>{fs.writeFileSync(stFile+'.tmp',JSON.stringify(s));fs.renameSync(stFile+'.tmp',stFile)}; const sleep=(ms)=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms); const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}}; - const isBus=(u)=>/^mosaic-bus@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice('mosaic-bus@'.length,-'.service'.length); + // Row 190: a bus unit is the default's mosaic-bus@B or instance NAME's mosaic-bus-NAME@B; %i is B. + const isBus=(u)=>/^mosaic-bus(-[a-z][a-z0-9]{1,15})?@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice(u.indexOf('@')+1,-'.service'.length); const unitFile=(u)=>{ - const main=isBus(u)?'mosaic-bus@.service':u,file=path.join(unitDir,main); + const main=isBus(u)?u.replace(/@[^@]*$/,'@.service'):u,file=path.join(unitDir,main); if(!fs.existsSync(file))return null; - const drops=[]; - for(const d of isBus(u)?[main+'.d',u+'.d']:[u+'.d']){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))} + // The drop-in directories in systemd's order: service.d, the name cut after each hyphen, then + // the template's and the unit's own. + const stem=main.slice(0,main.search(/@?\.service$/)),dirs=['service.d'],drops=[]; + for(let i=0;i{ - const dir=path.join(data,'bus'),files={}; + // A named instance's unit sets MOSAIC_CONFIG, and its bus works on that config's data root. A unit + // without it is the default's, on $SD_DATA. Each data root has its own host and launches files. + const dataOf=(f)=>{const c=f&&f.env.find((v)=>v.startsWith('MOSAIC_CONFIG='));return c?JSON.parse(fs.readFileSync(c.slice('MOSAIC_CONFIG='.length),'utf8')).dataRoot:data}; + const perData=(d,n)=>path.join(sd,d===data?`${n}.json`:`${n}-${path.basename(d)}.json`); + const snap=(ev,d)=>{ + const dir=path.join(d,'bus'),files={}; try{for(const n of fs.readdirSync(dir).sort()){const p=path.join(dir,n),st=fs.statSync(p);if(st.isFile())files[n]=[st.size,st.mtimeMs,crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')]}}catch{} fs.appendFileSync(path.join(sd,'snap.jsonl'),JSON.stringify({ev,files})+'\n'); }; - const pm=(b,id,pid,off)=>{ + const pm=(unit,d,id,pid,off)=>{ let mode='';try{mode=fs.readFileSync(faultFile(`pm-${id}`),'utf8').trim()}catch{} - const now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`,unit=`mosaic-bus@${b}.service`; + const b=inst(unit),now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`; const line=(text)=>fs.appendFileSync(path.join(sd,'journal.jsonl'),JSON.stringify({epoch,unit,text})+'\n'); const ok=()=>line(`launched pm-${b}: run ${run}, claude opus`); - const lf=path.join(sd,'launches.json');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{} + const lf=perData(d,'launches');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{} const entry=(o)=>list.push({run,business:b,live:true,startedAt:at,...o}); line(`bus host up: business ${b}, socket /fixture/bus.sock, notifier off`); switch(mode){ @@ -107,26 +116,26 @@ function systemctl(){ } if(verb==='start'){ const u=rest[0],f=unitFile(u);if(!f){console.error(`Unit ${u} not found.`);process.exit(5)} - const s=load(),id=path.basename(f.wd); + const s=load(),id=path.basename(f.wd),d=dataOf(f); if(!isBus(u)){s[u]={state:'active',pid:null,wd:f.wd};save(s);process.exit(0)} if(fault(`start-dies-${id}`)){s[u]={state:'failed',pid:null,wd:f.wd};save(s);process.exit(0)} // Row 185 (T48): the copy's start writes one event before the holder opens the store, so the // store differs from a backup the run took before the start. The close leaves no -wal. if(fault(`start-writes-row-${id}`)){ - const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite')); + const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite')); db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close(); } - snap('start'); + snap('start',d); const off=process.env.SD_CLOCK_OFFSET||'0'; // The holder must not inherit stack-release.sh's lock (fd 9) or any other fd above 2. const close='for fd in /proc/self/fd/*; do n=${fd##*/}; [ "$n" -gt 2 ] && eval "exec $n>&-" 2>/dev/null; done; exec "$@"'; - const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),data,inst(u),sd,off],{detached:true,stdio:'ignore'}); + const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),d,inst(u),sd,off,perData(d,'host')],{detached:true,stdio:'ignore'}); ch.unref(); fs.appendFileSync(path.join(sd,'pids.log'),ch.pid+'\n'); const ready=path.join(sd,`ready-${ch.pid}`),end=Date.now()+10000; while(!fs.existsSync(ready)){if(Date.now()>end){console.error('fixture holder did not start');process.exit(1)}sleep(20)} s[u]={state:'active',pid:ch.pid,wd:f.wd};save(s); - if(f.exec.split(' ').slice(1).includes('--pm'))pm(inst(u),id,ch.pid,Number(off)); + if(f.exec.split(' ').slice(1).includes('--pm'))pm(u,d,id,ch.pid,Number(off)); process.exit(0); } if(verb==='stop'){ @@ -138,17 +147,18 @@ function systemctl(){ } if(st){s[u]={...st,state:'inactive',pid:null};save(s)} if(isBus(u)){ + const d=dataOf(unitFile(u)); // One-shot: the stop leaves an empty -wal or -shm beside the store, as an unclean close would. - for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(data,'bus','bus.sqlite'+ext),'')} + for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(d,'bus','bus.sqlite'+ext),'')} // One-shot (T45): the stop writes one row after any STAMP into the table the fault names, as a // write the bus made between rollback's count and its stop would. The close leaves no -wal. if(fault('stop-writes-row')){ const t=fs.readFileSync(faultFile('stop-writes-row'),'utf8');fs.rmSync(faultFile('stop-writes-row')); - const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite')); + const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite')); db.exec(`CREATE TABLE IF NOT EXISTS ${t} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`); db.prepare(`INSERT INTO ${t} (at) VALUES (?)`).run('2999-01-01T00:00:00.000Z');db.close(); } - snap('stop'); + snap('stop',d); } process.exit(0); } @@ -178,10 +188,11 @@ function systemdAnalyze(){ if(fs.existsSync(extra))console.log(fs.readFileSync(extra,'utf8')); } -// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does. +// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does. It writes its +// status to the host file systemctl names for its data root. function holder(){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite'); - const [data,business,sd,offset]=process.argv.slice(2),bus=path.join(data,'bus'); + const [data,business,sd,offset,host]=process.argv.slice(2),bus=path.join(data,'bus'); fs.mkdirSync(bus,{recursive:true,mode:0o700}); const db=new DatabaseSync(path.join(bus,'bus.sqlite')); if(!db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='meta'").get().n){ @@ -191,7 +202,7 @@ function holder(){ db.prepare("INSERT INTO meta VALUES ('schema_version', '3g')").run(); } fs.writeFileSync(path.join(bus,'writer.lock'),String(process.pid)); - const at=new Date(Date.now()-Number(offset||0)*1000).toISOString(),host=path.join(sd,'host.json'); + const at=new Date(Date.now()-Number(offset||0)*1000).toISOString(); fs.writeFileSync(host+'.tmp',JSON.stringify({host:{business,pid:process.pid,live:true,startedAt:at,notifier:'ok'},boot:{ok:true,business,at}})); fs.renameSync(host+'.tmp',host); process.on('SIGTERM',()=>{db.close();fs.rmSync(path.join(bus,'writer.lock'),{force:true});process.exit(0)}); @@ -199,21 +210,24 @@ function holder(){ setInterval(()=>{},1<<30); } -// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote. +// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote +// for the data root of the config it runs under (row 190), the default's being $SD_DATA. function mosaicStub(){ const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'); const id=path.basename(path.resolve(__dirname,'..')),sd=process.env.SD_STATE,a=process.argv.slice(2).join(' '); const fault=(n)=>fs.existsSync(path.join(sd,'faults',n)); + let d=process.env.SD_DATA;try{d=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot}catch{} + const perData=(n)=>path.join(sd,d===process.env.SD_DATA?`${n}.json`:`${n}-${path.basename(d)}.json`); const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}}; if(a==='bus status --json'){ if(fault(`status-${id}`))process.exit(1); let s={host:null,boot:null}; - try{s=JSON.parse(fs.readFileSync(path.join(sd,'host.json'),'utf8'));s.host.live=alive(s.host.pid)}catch{} + try{s=JSON.parse(fs.readFileSync(perData('host'),'utf8'));s.host.live=alive(s.host.pid)}catch{} console.log(JSON.stringify(s)); }else if(a==='bus status')console.log(`bus: fixture status for ${id}`); else if(a==='launches list --json'){ if(fault(`launches-${id}`))process.exit(1); - let l=[];try{l=JSON.parse(fs.readFileSync(path.join(sd,'launches.json'),'utf8'))}catch{} + let l=[];try{l=JSON.parse(fs.readFileSync(perData('launches'),'utf8'))}catch{} console.log(JSON.stringify(l)); }else{console.error(`fixture mosaic: ${a}`);process.exit(4)} } @@ -289,7 +303,7 @@ const posts=(e)=>readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt- const backupSum=(e,act)=>readFileSync(join(e.data,'backups','stack',act,'SHA256SUMS'),'utf8').split(' ')[0]; const commitLines=(range)=>git(repo,'log','--oneline','--no-merges','--max-count=200',range).split('\n').map((l)=>` ${l}`); // The units, the wrapper, the pointer and the store, by sha256. -const fp=(e)=>({bus:sha(join(e.units,'mosaic-bus@.service')),console:sha(join(e.units,CON)),wrapper:sha(join(e.bin,'mosaic')),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)}); +const fp=(e)=>({bus:sha(join(e.units,e.tmpl)),console:sha(join(e.units,e.con)),wrapper:sha(join(e.bin,e.wrapper)),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)}); // The schema version: readOnly while the holder has the store open, immutable when it is stopped. function storeVersion(file,held){ const db=held?new DatabaseSync(file,{readOnly:true}):new DatabaseSync(pathToFileURL(file).href+'?immutable=1',{readOnly:true}); @@ -317,6 +331,8 @@ function biz(e,plugins){ async function mk(t){ const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-'))); const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')}; + // The default instance's names; named() sets NAME's (row 190). + Object.assign(e,{flags:[],bus:BUS,tmpl:'mosaic-bus@.service',con:CON,wrapper:'mosaic'}); Object.assign(e,{state:join(e.data,'state'),pointer:join(e.data,'state','stack-active.json'),log:join(e.data,'state','stack-activation-log.jsonl'),store:join(e.data,'bus','bus.sqlite'),lock:join(e.data,'state','stack-release.lock')}); for(const d of [join(root,'home'),dirname(e.cfg),join(e.data,'bus'),e.bin,dirname(e.claude),join(e.sd,'faults'),e.units,join(root,'xdata')])mkdirSync(d,{recursive:true}); chmodSync(join(e.data,'bus'),0o700); @@ -326,25 +342,47 @@ async function mk(t){ writeFileSync(join(e.bin,'mosaic-old'),'#!/bin/sh\nexit 0\n',{mode:0o755}); writeFileSync(e.claude,'#!/bin/sh\necho "9.9.9 (Claude Code fixture)"\n',{mode:0o755}); e.env={PATH:`${SBIN}:${dirname(NODE)}:/usr/bin:/bin`,HOME:join(root,'home'),XDG_CONFIG_HOME:join(root,'xcfg'),XDG_DATA_HOME:join(root,'xdata'),MOSAIC_CONFIG:e.cfg,SD_STATE:e.sd,SD_DATA:e.data,TMPDIR:tmpdir()}; - // The Console's /healthz: 200 while the stand-in has it active and no healthz- fault names its copy. - const server=createServer((q,r)=>{ - let st={};try{st=units(e)[CON]||{}}catch{} - const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`)); - r.writeHead(ok?200:500);r.end(); - }); - server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port); + const server=await healthz(e); t.after(()=>{ for(const pid of lines(join(e.sd,'pids.log')).map(Number)){ try{if(readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(join(base,'holder.cjs')))process.kill(pid,'SIGKILL')}catch{} } - server.close();execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true}); + for(const s of [server,...(e.servers??[])])s.close(); + execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true}); }); return e; } +// The Console's /healthz on e.port: 200 while the stand-in has e.con active and no healthz- +// fault names its copy. +async function healthz(e){ + const server=createServer((q,r)=>{ + let st={};try{st=units(e)[e.con]||{}}catch{} + const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`)); + r.writeHead(ok?200:500);r.end(); + }); + server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port); + return server; +} + +// Row 190: stack instance NAME on e's host. It shares e's stand-ins, unit directory and $BIN, and has +// its own config and data root from bootstrap --instance, its own Console port and business acme. +// Its runs pass --instance NAME and no MOSAIC_CONFIG. +async function named(e,name){ + const env={...e.env};delete env.MOSAIC_CONFIG; + execFileSync('bash',[join(repo,'scripts','bootstrap.sh'),'--instance',name],{env,stdio:'ignore'}); + const data=join(env.HOME,`.mosaic-${name}`); + const n={...e,env,data,cfg:join(env.HOME,'.config',`mosaic-${name}`,'config.json'),flags:['--instance',name],bus:`mosaic-bus-${name}@acme.service`,tmpl:`mosaic-bus-${name}@.service`,con:`mosaic-webui-${name}.service`,wrapper:`mosaic-${name}`,name}; + Object.assign(n,{state:join(data,'state'),pointer:join(data,'state','stack-active.json'),log:join(data,'state','stack-activation-log.jsonl'),store:join(data,'bus','bus.sqlite'),lock:join(data,'state','stack-release.lock')}); + mkdirSync(join(data,'bus'),{mode:0o700}); + biz(n); + (e.servers??=[]).push(await healthz(n)); + return n; +} + async function run(e,...args){ const before=existsSync(e.data)?kept(e):[]; - const ch=spawn('bash',[e.sr??SR,...args,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']}); + const ch=spawn('bash',[e.sr??SR,...args,...e.flags,'--copies',COPIES,'--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}); const timer=setTimeout(()=>ch.kill('SIGKILL'),120000); const [code]=await once(ch,'close');clearTimeout(timer); @@ -364,12 +402,12 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){ for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`); db.close(); const sh=(name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']}); - sh('bus-service.sh','install','--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude); - sh('webui-service.sh','install','--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme'); - sh('stack-copy.sh','wrapper',copy,'--copies',COPIES,'--bin',e.bin); + sh('bus-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude); + sh('webui-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme'); + sh('stack-copy.sh','wrapper',copy,...e.flags,'--copies',COPIES,'--bin',e.bin); // The seeded host started two minutes ago, so its boot status and PM run predate any run's start. - execFileSync(join(SBIN,'systemctl'),['--user','start',BUS],{env:{...e.env,SD_CLOCK_OFFSET:'120'}}); - sysctl(e,'start',CON); + execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}}); + sysctl(e,'start',e.con); if(how==='adopt'){const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err)} else if(how==='hand'){ mkdirSync(e.state,{recursive:true}); @@ -2191,3 +2229,142 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`); } }); + +// --- Row 190 (#1688): stack instances on one host. Design and the T-I table: +// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in +// stack-instances.test.mjs; these need the units, the stand-ins and stack-release.sh. Every existing +// test above is D1's "no flag, no marker" case and passes unchanged. + +const shOut=(env,name,...a)=>spawnSync('bash',[join(repo,'scripts',name),...a],{env,encoding:'utf8'}); +const SHARED_DROPINS=['mosaic-bus-.service.d','mosaic-webui-.service.d','mosaic-discord-.service.d']; +// Every path under each of ps with its mode, size, mtime and, for a file, its sha256. +function stamp(...ps){ + const out=[]; + const walk=(q)=>{let st;try{st=lstatSync(q)}catch{out.push(`${q} absent`);return} + out.push(`${q} ${st.mode.toString(8)} ${st.size} ${st.mtimeMs}${st.isFile()?' '+sha(q):''}`); + if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))}; + for(const p of ps)walk(p);return out; +} +const dirsUnder=(p)=>stamp(p).filter((l)=>!l.endsWith(' absent')).map((l)=>l.split(' ')[0]).filter((q)=>lstatSync(q).isDirectory()); +// The T53 inotify watch on a directory: it counts each open or read of the directory or a file in it. +async function watchDir(e,d){ + const ch=spawn('python3',['-I','-c',WATCH,d],{stdio:['pipe','pipe','inherit']}); + let out='';ch.stdout.on('data',(b)=>{out+=b}); + await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))}); + WATCHERS.add(ch);ch.on('close',()=>WATCHERS.delete(ch)); + (e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return [d,Number(out.split('\n')[1])]}); +} + +test('T55 row 190: named instances side by side on one unit directory, $BIN and set of stand-ins',async(t)=>{ + await t.test('T-I3 each instance renders its own config, session env and Description, and nothing of another',async(t)=>{ + const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta'); + for(const [x,y] of [[a,b],[b,a]]){ + const renders={ + bus:shOut(x.env,'bus-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--claude',e.claude), + console:shOut(x.env,'webui-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--port',x.port,'--business','acme'), + discord:shOut(x.env,'discord-service.sh','render','--instance',x.name), + }; + for(const [k,r] of Object.entries(renders)){ + assert.equal(r.status,0,`${x.name} ${k}: ${r.stderr}`); + assert.ok(r.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${x.cfg}\n`),`${x.name} ${k}`); + assert.match(r.stdout,new RegExp(`\\nDescription=[^\\n]* \\[${x.name}\\]\\n`),`${x.name} ${k}`); + for(const not of [y.name,'mosaic-dev'])assert.ok(!r.stdout.includes(not),`${x.name} ${k} holds ${not}`); + } + assert.ok(renders.bus.stdout.includes(`\nEnvironmentFile=-${dirname(x.cfg)}/secrets/claude-session.env\n`),x.name); + assert.equal(renders.bus.stdout.match(/^Environment(File)?=.*$/gm).filter((l)=>l.includes('MOSAIC_CONFIG')||l.startsWith('EnvironmentFile')).length,2,x.name); + } + await seed(a);await seed(b); + for(const x of [a,b]){ + const r=shOut(x.env,'discord-service.sh','install','--instance',x.name,'--dir',e.units,'--no-reload');assert.equal(r.status,0,r.stderr); + for(const u of [x.tmpl,x.con,`mosaic-discord-${x.name}@.service`])assert.ok(existsSync(join(e.units,u)),u); + } + assert.deepEqual(readdirSync(e.units).sort(),[a.tmpl,a.con,`mosaic-discord-alpha@.service`,b.tmpl,b.con,`mosaic-discord-beta@.service`].sort()); + for(const x of [a,b])assert.equal(readFileSync(join(e.bin,x.wrapper),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${x.name} (row 190)`); + }); + + await t.test('T-I4 a Console port another installed Console takes refuses port-claimed, and a named instance keeps off the default ports',async(t)=>{ + const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');await seed(a); + const held=`port ${a.port} is taken by ${join(e.units,a.con)}`,free=await freePort(); + const install=(x,...flags)=>shOut(x.env,'webui-service.sh','install',...x.flags,'--copy',B,'--copies',COPIES,...flags,'--business','acme'); + const unitsBefore=stamp(e.units),binBefore=stamp(e.bin); + for(const [label,x,flags,code,msg] of [ + ['beta on alpha\'s port',b,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`], + ['beta\'s tester port on alpha\'s port',b,['--port',free,'--tester-port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`], + ['the default on alpha\'s port',e,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`], + ['beta with no --port',b,[],4,'webui-service: stack instance beta needs --port; it has no default port\n'], + ...['7330','7332','7333'].map((p)=>[`beta on ${p}`,b,['--port',p],4,`webui-service: port-reserved: ${p} is reserved for the default instance (7330 7332 7333)\n`]), + ['beta\'s tester port on 7333',b,['--port',free,'--tester-port','7333'],4,'webui-service: port-reserved: 7333 is reserved for the default instance (7330 7332 7333)\n'], + ]){ + const r=install(x,...flags);assert.equal(r.status,code,`${label}: ${r.stderr}`);assert.equal(r.stderr,msg,label); + assert.deepEqual(stamp(e.units),unitsBefore,label);assert.deepEqual(stamp(e.bin),binBefore,label); + } + // beta on its own port passes: the seed installs both its units and adopts. + await seed(b);assert.ok(existsSync(join(e.units,b.con))); + // stack-release: a target Console port another Console takes refuses before anything stops. + const refused=async(label,...args)=>{ + const before=fp(b),n=calls(e).length,logs=logLines(b).length; + const r=await run(b,'activate',C1,'acme',...args);assert.equal(r.code,3,`${label}: ${r.err}`); + assert.equal(r.err,`stack-release: refusing port-claimed: ${held}\n`,label); + assert.deepEqual(fp(b),before,label);assert.deepEqual(startsStops(e,n),[],label); + assert.equal(logLines(b).length,logs+1,label);assert.deepEqual([logLines(b).at(-1).result,logLines(b).at(-1).code],['refused','port-claimed'],label); + }; + await refused('--tester-port','--tester-port',a.port); + const p=pointer(b);p.console[1]=a.port;writeFileSync(b.pointer,JSON.stringify(p)+'\n'); + await refused('the pointer\'s --port'); + const plan=await run(b,'activate',C1,'acme','--plan');assert.equal(plan.code,3,plan.err); + assert.ok(plan.out.split('\n').includes(`refuses port-claimed: ${held}`),plan.out); + }); + + // T-I6: activate and rollback on x leave y's every file, directory and the shared prefix drop-in + // directories as they were, and open nothing of y's config dir or data root. + const noCrossReach=async(t,xn,yn)=>{ + const e=await mk(t),x=await named(e,xn),y=await named(e,yn); + for(const d of [...SHARED_DROPINS,`${x.tmpl}.d`,`${x.con}.d`,`${y.tmpl}.d`,`${y.con}.d`]){mkdirSync(join(e.units,d));writeFileSync(join(e.units,d,'t-i6.conf'),`# row 190 T-I6: ${d}\n`)} + await seed(x);await seed(y); + const mine=()=>stamp(dirname(y.cfg),y.data,...[y.tmpl,y.con,`${y.tmpl}.d`,`${y.con}.d`,...SHARED_DROPINS].map((u)=>join(e.units,u)),join(e.bin,y.wrapper),join(e.bin,'mosaic')); + const before=mine(),n=calls(e).length; + const watched=[...dirsUnder(dirname(y.cfg)),...dirsUnder(y.data)]; + for(const d of [dirname(y.cfg),y.data,join(y.data,'bus'),y.state])assert.ok(watched.includes(d),d); + for(const d of watched)await watchDir(e,d); + const act=await run(x,'activate',C1,'acme');assert.equal(act.code,0,act.out+act.err); + const back=await run(x,'rollback','acme');assert.equal(back.code,0,back.out+back.err); + assert.deepEqual((await Promise.all(e.watches.map((s)=>s()))).filter(([,c])=>c!==0),[],`${xn}'s runs opened ${yn}'s files`); + assert.deepEqual(mine(),before); + assert.deepEqual(calls(e).slice(n).filter((c)=>c.includes(yn)),[]); + const sets=readdirSync(join(x.state,'stack-rollback')).sort();assert.equal(sets.length,2,sets.join(' ')); + for(const s of sets){ + const rs=join(x.state,'stack-rollback',s); + assert.deepEqual(readdirSync(rs).sort(),[x.tmpl,`${x.tmpl}.d`,x.con,`${x.con}.d`,'wrapper','WRAPPER_MODE',`show-${x.bus}.txt`,`show-${x.con}.txt`,'SHA256SUMS','steps.log'].sort(),s); + assert.equal(readFileSync(join(rs,'wrapper'),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${xn} (row 190)`); + } + assert.equal(pointer(x).copy,B);assert.equal(pointer(y).copy,B); + }; + await t.test('T-I6 alpha\'s activate and rollback reach nothing of beta\'s',(t)=>noCrossReach(t,'alpha','beta')); + await t.test('T-I6 swapped: beta\'s reach nothing of alpha\'s',(t)=>noCrossReach(t,'beta','alpha')); + + await t.test('T-I7 MOSAIC_CONFIG naming alpha\'s config with no flag refuses instance-root-mismatch and changes nothing',async(t)=>{ + const e=await mk(t),a=await named(e,'alpha');await seed(a); + const x={...a,env:{...a.env,MOSAIC_CONFIG:a.cfg},flags:[]}; + const before=fp(a),n=calls(e).length,logs=logLines(a).length,unitsBefore=stamp(e.units),binBefore=stamp(e.bin); + const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.err);assert.match(r.err,/instance-root-mismatch/);assert.equal(r.out,''); + const i=shOut(x.env,'bus-service.sh','install','--copy',C1,'--copies',COPIES,'--claude',e.claude); + assert.equal(i.status,3,i.stderr);assert.match(i.stderr,/instance-root-mismatch/); + assert.deepEqual(calls(e).slice(n),[]);assert.deepEqual(fp(a),before);assert.equal(logLines(a).length,logs); + assert.deepEqual(stamp(e.units),unitsBefore);assert.deepEqual(stamp(e.bin),binBefore); + }); + + await t.test('T-I9 another instance\'s bus active refuses nothing; one of this instance\'s does',async(t)=>{ + const e=await mk(t);await seed(e); + const a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);await seed(b); + for(const x of [e,b]){const r=await run(x,'activate',C1,'acme');assert.equal(r.code,0,`${x.name??'default'}: ${r.out+r.err}`)} + const setUnit=(u,state)=>{const s=units(e);if(state)s[u]={...(s[u]||{pid:null,wd:''}),state};else delete s[u];writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))}; + for(const [u,state,msg] of [['mosaic-bus-alpha@other.service','active','mosaic-bus-alpha@other.service is active; the copy and wrapper switch reach it too'],[a.bus,'activating',`${a.bus} is activating`]]){ + const was=units(e)[u];setUnit(u,state); + const before=fp(a),n=calls(e).length; + const r=await run(a,'activate',C1,'acme');assert.equal(r.code,3,r.err); + assert.equal(r.err,`stack-release: refusing instance-busy: ${msg}\n`);assert.deepEqual(fp(a),before);assert.deepEqual(startsStops(e,n),[]); + setUnit(u,was&&was.state);if(was){const s=units(e);s[u]=was;writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))} + } + const r=await run(a,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err); + }); +}); diff --git a/packages/conversation/src/guard.mjs b/packages/conversation/src/guard.mjs index ad8caa637..9871676ae 100644 --- a/packages/conversation/src/guard.mjs +++ b/packages/conversation/src/guard.mjs @@ -4,8 +4,9 @@ // every session path are constructor arguments, with no default. At // construction and again at bind, their real paths must lie inside the // explicit fixture root and outside every protected location: the -// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any -// path a seat registration names. A path is refused when it is inside a +// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root, every +// stack instance's config dir and data roots (row 190) and any path a seat +// registration names. A path is refused when it is inside a // protected location or contains one. The real protections always apply; // options only add to them, so a test can't switch them off. // @@ -44,9 +45,9 @@ export function realPath(p) { const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep); const overlaps = (a, b) => inside(a, b) || inside(b, a); -function configuredDataRoot(home) { +function configuredDataRoot(home, name = "dev") { try { - const cfg = JSON.parse(readFileSync(join(home, ".config", "mosaic-dev", "config.json"), "utf8")); + const cfg = JSON.parse(readFileSync(join(home, ".config", `mosaic-${name}`, "config.json"), "utf8")); if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home); } catch { // An unreadable config adds no location; the default data root still applies. @@ -54,6 +55,21 @@ function configuredDataRoot(home) { return null; } +// Row 190: the stack instances in `home`, one per ~/.config/mosaic-NAME whose +// NAME passes the instance name rule (scripts/mosaic-config.mjs), so +// ~/.config/mosaic.bak adds nothing. Only the names are read here. +const INSTANCE_NAME = /^[a-z][a-z0-9]{1,15}$/; +function instanceNames(home) { + try { + return readdirSync(join(home, ".config")) + .filter((entry) => entry.startsWith("mosaic-") && INSTANCE_NAME.test(entry.slice("mosaic-".length))) + .map((entry) => entry.slice("mosaic-".length)) + .sort(); + } catch { + return []; + } +} + // Registrations under /seats///registration.json. An // unreadable one adds nothing; the data root itself is protected anyway. function registrationsUnder(dataRoot) { @@ -111,6 +127,14 @@ export class LiveSessionGuard { protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" }); const configured = configuredDataRoot(h); if (configured) protectedPaths.push({ path: configured, why: "the configured data root" }); + // Each instance's config dir and ~/.mosaic-NAME whatever its config + // says, so a broken config can't drop them, then its config's dataRoot. + for (const name of instanceNames(h)) { + protectedPaths.push({ path: join(h, ".config", `mosaic-${name}`), why: `stack instance ${name}'s config dir` }); + protectedPaths.push({ path: join(h, `.mosaic-${name}`), why: `stack instance ${name}'s default data root` }); + const root = configuredDataRoot(h, name); + if (root) protectedPaths.push({ path: root, why: `stack instance ${name}'s configured data root` }); + } } for (const d of dataRoots) protectedPaths.push({ path: d, why: "the data root" }); const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path); diff --git a/packages/discord/README.md b/packages/discord/README.md index 29b9fc7cc..d0a0249e8 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -133,6 +133,12 @@ the copy, and retains the unit's restart, brake and stop behavior. Without pinning. Status reads effective WorkingDirectory, including drop-ins, and prints the tree and copy id or checkout path. +`--instance NAME` (row 190, any verb) renders `mosaic-discord-NAME@.service`, +which sets `Environment=MOSAIC_CONFIG=` to NAME's config, and status reads +that config. Uninstall's active check covers only `mosaic-discord-NAME@*`. +Without the flag the unit is as before. See +[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190). + Every live step requires Mos's go after the SetSpark freeze lifts. Re-pin: make the new pushed copy with stack-copy.sh; run `node /packages/discord/src/cli.mjs check --repo --offline`; diff --git a/packages/discord/systemd/mosaic-discord@.service.in b/packages/discord/systemd/mosaic-discord@.service.in index f8d41e493..eeffd72ca 100644 --- a/packages/discord/systemd/mosaic-discord@.service.in +++ b/packages/discord/systemd/mosaic-discord@.service.in @@ -14,6 +14,7 @@ StartLimitBurst=5 Type=simple WorkingDirectory=@REPO@ Environment=PATH=@PATH@ +@CONFIG_ENV@ # A supervised run first clears a lock left by a crash or a reboot, and # refuses with exit 3 while STOP is present or the binding is held. Exit 3 # is never retried: the brake belongs to the operator. This happens inside diff --git a/packages/discord/tests/copy-service.test.mjs b/packages/discord/tests/copy-service.test.mjs index c2445701f..b1a6a8dd8 100644 --- a/packages/discord/tests/copy-service.test.mjs +++ b/packages/discord/tests/copy-service.test.mjs @@ -14,7 +14,8 @@ test('pinned Discord render/install and missing prerequisites; checkout bytes un for(const line of r.stdout.split('\n').filter(l=>/^(ExecStart|WorkingDirectory)=/.test(l)))assert.ok(!line.includes(ROOT),line); const legacy=s.run(['render']);assert.equal(legacy.status,0,legacy.stderr); const nodeDir=dirname(process.execPath),originalPath='/usr/local/bin:/usr/bin:/bin',path=originalPath.split(':').includes(nodeDir)?originalPath:nodeDir+':'+originalPath; - const expected=readFileSync(join(ROOT,'packages/discord/systemd/mosaic-discord@.service.in'),'utf8').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected); + // Row 190: the default instance deletes the @CONFIG_ENV@ line. + const expected=readFileSync(join(ROOT,'packages/discord/systemd/mosaic-discord@.service.in'),'utf8').replace('@CONFIG_ENV@\n','').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected); assert.equal(r.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'),legacy.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n')); const installed=s.run(['install',...args,'--dir',s.units,'--no-reload']);assert.equal(installed.status,0,installed.stderr);assert.equal(readFileSync(join(s.units,'mosaic-discord@.service'),'utf8'),r.stdout); for(const id of ['','short','b'.repeat(40)])assert.equal(s.run(['install','--copy',id,'--copies',s.copies,'--dir',join(s.root,'missing'),'--no-reload']).status,1); diff --git a/packages/webui/README.md b/packages/webui/README.md index 429be8a1f..669d128cd 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -122,6 +122,16 @@ allow-user must be the 64-character Authentik uid (decision 101). Status names the copy and refactor drift. Files and directories are read-only; copies are retained. Existing Console-only copies are not migrated or pruned. +With `--instance NAME` (row 190) every verb works on +`mosaic-webui-NAME.service`, which sets NAME's `MOSAIC_CONFIG`; install +checks NAME's `mosaic-bus-NAME@` units for the copy. render and install then +need `--port`, and 7330, 7332 or 7333 as `--port` or `--tester-port` exits 4 +with port-reserved. For every instance, install refuses a `--port` or +`--tester-port` that another installed Console unit in the unit directory +already takes. That refusal is port-claimed, exit 1, and writes nothing. A +unit with no `--port` holds 7332. See +[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190). + The single update and rollback sequence is [TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update). A bus restart ends managed sessions, so no PM turn may be in flight. diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in index 53c357718..35902ac09 100644 --- a/packages/webui/systemd/mosaic-webui.service.in +++ b/packages/webui/systemd/mosaic-webui.service.in @@ -23,6 +23,7 @@ StartLimitBurst=5 Type=simple WorkingDirectory=@REPO@ Environment=PATH=@PATH@ +@CONFIG_ENV@ # The server binds 127.0.0.1 only. Usage, config and listen refusals # (a port already in use included) exit 2 and are not retried; fix the # flags or free the port, then start it again. diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs index f2bdfa025..3c1fb9672 100644 --- a/packages/webui/tests/pinned-copy.test.mjs +++ b/packages/webui/tests/pinned-copy.test.mjs @@ -146,8 +146,10 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c test('copy writes nothing when an archived path is missing at the commit', async (t) => { const { dir, repo, script } = fixtureRepo(t); const copies = join(dir, 'copies'); - git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs'); - git(repo, 'commit', '-q', '-m', 'drop the config check'); + // Row 190: the entry point runs scripts/mosaic-config.mjs to select the + // instance before it copies, so the missing path is one only the copy reads. + git(repo, 'rm', '-q', 'packages/webui/README.md'); + git(repo, 'commit', '-q', '-m', 'drop the Console README'); git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD')); const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]); assert.equal(r.status, 1); diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index d64928cb4..a6e66608a 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -35,11 +35,12 @@ const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic // The unit as the base script renders it (row 75): the template with its four // placeholders filled in, nothing else. Row 83 adds --send and must leave -// every unit rendered without it byte for byte as this. +// every unit rendered without it byte for byte as this. Row 190: the +// default instance deletes the @CONFIG_ENV@ line, so the unit is unchanged. function filled(flags, root = REPO) { const base = '/usr/local/bin:/usr/bin:/bin'; const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`; - return TEMPLATE.replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags); + return TEMPLATE.replace('@CONFIG_ENV@\n', '').replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags); } // A loopback port with nothing listening, so status's /healthz request diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 0312d2cac..2e8e72210 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -3,9 +3,11 @@ # # Creates ~/.config/mosaic-dev/config.json (or $MOSAIC_CONFIG) only when # absent. An existing configuration is validated, never modified. +# `--instance NAME` (row 190) creates ~/.config/mosaic-NAME/config.json +# instead, with the data root ~/.mosaic-NAME claimed by its instance.json. # Normal run paths (build/hello/verify) deliberately do NOT auto-bootstrap: # missing configuration is an error there, not something to invent. set -euo pipefail cd "$(dirname "$0")/.." -exec node scripts/mosaic-config.mjs bootstrap +exec node scripts/mosaic-config.mjs bootstrap "$@" diff --git a/scripts/bus-service.sh b/scripts/bus-service.sh index f71250204..0b1d681f3 100755 --- a/scripts/bus-service.sh +++ b/scripts/bus-service.sh @@ -19,12 +19,24 @@ # (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is # kept as given, so a symlink that tracks updates keeps tracking them. # -# Exit codes: 0 ok, 1 operation failed, 4 usage. +# --instance NAME (row 190, any position) selects stack instance NAME: the +# unit is mosaic-bus-NAME@.service, it sets MOSAIC_CONFIG to +# ~/.config/mosaic-NAME/config.json, and its session env file sits in that +# config directory. Busy and drop-in checks then cover only NAME's bus units +# and the prefix directories systemd applies to them. See scripts/instance.sh. +# +# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another +# instance or not claimed (scripts/instance.sh), 4 usage. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in" -UNIT_NAME="mosaic-bus@.service" -USAGE="usage: scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status " +# shellcheck source=scripts/instance.sh +. "$REPO/scripts/instance.sh" +instance_args "$@" +set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"} +instance_select +UNIT_NAME="$BUS_TEMPLATE" +USAGE="usage: scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status " die() { echo "bus-service: $*" >&2; exit 1; } usage() { echo "$USAGE" >&2; exit 4; } @@ -76,7 +88,7 @@ render() { case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac local claude claude="$(claude_line)" || exit 1 - sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE" + sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "${INSTANCE_SED[@]}" "$TEMPLATE" } UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" @@ -99,9 +111,10 @@ parse_flags() { # Any failed read refuses before a write, including --no-reload installations. install_guard() { local out unit file - out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed" + out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed" while read -r unit load state rest; do - [[ "$unit" == mosaic-bus@*.service ]] || continue + # shellcheck disable=SC2053 + [[ "$unit" == $BUS_GLOB ]] || continue case "$state" in inactive|failed) ;; *) die "refusing: $unit is running ($state); Jason or Mos stops it first, then install, then start it" ;; esac done <<< "$out" # systemctl show rejects a template name. The lookup-path scan also @@ -114,8 +127,12 @@ install_guard() { case "$directory" in /*) ;; *) die "refusing: invalid systemd unit path $directory; nothing was changed" ;; esac [ -e "$directory" ] || [ -L "$directory" ] || continue [ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed" - # * includes the empty instance, i.e. mosaic-bus@.service.d. - for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do + # * includes the empty instance, i.e. mosaic-bus@.service.d (or + # mosaic-bus-NAME@.service.d). The globs come from scripts/instance.sh. + local glob dropdirs=() + # shellcheck disable=SC2206 + for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done + for dropdir in "${dropdirs[@]}"; do [ -e "$dropdir" ] || [ -L "$dropdir" ] || continue [ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed" for file in "$dropdir"/*.conf; do @@ -161,10 +178,10 @@ next, for one business (one per data root): mkdir -m 0700 -p /notify/ the notifier refuses a looser directory write /notify//notify.json, mode 0600: {"notifyVersion": 1, "binding": ""} or "binding": null for no DMs - systemctl --user enable --now mosaic-bus@ start now and at login - systemctl --user status mosaic-bus@ - journalctl --user -u mosaic-bus@ -f the host's log - systemctl --user stop mosaic-bus@ SIGTERM; restartable + systemctl --user enable --now ${BUS_TEMPLATE%.service} start now and at login + systemctl --user status ${BUS_TEMPLATE%.service} + journalctl --user -u ${BUS_TEMPLATE%.service} -f the host's log + systemctl --user stop ${BUS_TEMPLATE%.service} SIGTERM; restartable survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)} MSG } @@ -174,7 +191,7 @@ uninstall_unit() { [ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage local target="$UNIT_DIR/$UNIT_NAME" local out active - out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed" + out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed" active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")" [ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first" if [ -f "$target" ]; then @@ -193,7 +210,7 @@ status_unit() { [ $# -eq 1 ] || usage local business="$1" case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac - local unit="mosaic-bus@$business.service" + local unit="${BUS_TEMPLATE%.service}$business.service" echo "unit: $unit" echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)" echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)" diff --git a/scripts/discord-service.sh b/scripts/discord-service.sh index ea726e08e..6982ded52 100755 --- a/scripts/discord-service.sh +++ b/scripts/discord-service.sh @@ -14,12 +14,21 @@ # remove the unit; refuses while an instance is active # status unit state, STOP and run.lock for one binding # -# Exit codes: 0 ok, 1 operation failed, 4 usage. +# Every verb takes --instance NAME (row 190, scripts/instance.sh): the unit is +# then mosaic-discord-NAME@.service, it sets NAME's MOSAIC_CONFIG, and status +# reads NAME's config. Without it, everything is as before. +# +# Exit codes: 0 ok, 1 operation failed, 3 instance claim refused, 4 usage. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TEMPLATE="$REPO/packages/discord/systemd/mosaic-discord@.service.in" -UNIT_NAME="mosaic-discord@.service" -USAGE="usage: scripts/discord-service.sh render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status " +. "$REPO/scripts/instance.sh" +instance_args "$@" +set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"} +instance_select +UNIT_NAME="$DISCORD_TEMPLATE" +UNIT_PREFIX="${DISCORD_TEMPLATE%.service}" +USAGE="usage: scripts/discord-service.sh [--instance NAME] render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status " die() { echo "discord-service: $*" >&2; exit 1; } usage() { echo "$USAGE" >&2; exit 4; } @@ -48,7 +57,7 @@ render() { case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac local unit - unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE")" || return 1 + unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "${INSTANCE_SED[@]}" "$TEMPLATE")" || return 1 if [ -n "$root" ]; then local node node="$(command -v node)" @@ -101,10 +110,10 @@ install_unit() { fi cat < start now and at login - systemctl --user status mosaic-discord@ - journalctl --user -u mosaic-discord@ -f the connector's log - systemctl --user stop mosaic-discord@ SIGTERM; restartable + systemctl --user enable --now $UNIT_PREFIX start now and at login + systemctl --user status $UNIT_PREFIX + journalctl --user -u $UNIT_PREFIX -f the connector's log + systemctl --user stop $UNIT_PREFIX SIGTERM; restartable scripts/discord.sh stop the brake: writes STOP, the unit stays down until STOP is removed survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)} MSG @@ -115,7 +124,7 @@ uninstall_unit() { local target="$UNIT_DIR/$UNIT_NAME" if [ "$RELOAD" = 1 ]; then local active - active="$(systemctl --user list-units --no-legend --plain 'mosaic-discord@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')" + active="$(systemctl --user list-units --no-legend --plain "$UNIT_PREFIX*" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')" [ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first" fi if [ -f "$target" ]; then @@ -134,7 +143,7 @@ status_unit() { [ $# -eq 1 ] || usage local binding="$1" case "$binding" in ''|*[!A-Za-z0-9_-]*) die "binding name must be [A-Za-z0-9_-]+" ;; esac - local unit="mosaic-discord@$binding.service" + local unit="$UNIT_PREFIX$binding.service" echo "unit: $unit" echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)" echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)" @@ -151,6 +160,7 @@ status_unit() { echo " checkout: $working" fi local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json" + [ -z "$MOSAIC_UNIT_SUFFIX" ] || config="$MOSAIC_INSTANCE_CONFIG" local data_root data_root="$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(c.dataRoot||""))' "$config" 2>/dev/null || true)" if [ -z "$data_root" ]; then diff --git a/scripts/instance.sh b/scripts/instance.sh new file mode 100644 index 000000000..e37436842 --- /dev/null +++ b/scripts/instance.sh @@ -0,0 +1,140 @@ +# Sourced, not run: stack instance selection for the shell entry points (row 190). +# +# instance_args "$@" take `--instance NAME` out of the arguments. Sets +# INSTANCE_FLAG (NAME, or empty) and INSTANCE_ARGS (the +# rest, in order). A second --instance or a missing +# NAME exits 4. +# instance_select run `mosaic-config.mjs instance [--instance NAME]`, +# which maps NAME to ~/.config/mosaic-NAME/config.json +# and checks the data root's instance.json claim, then +# set the names below. A refusal exits with the +# loader's code (3 claim or missing config, 4 name or +# mismatch) before the caller reads or writes anything. +# +# With no flag, every name is today's and MOSAIC_CONFIG is left as the caller +# had it. With --instance NAME (other than dev), MOSAIC_CONFIG is exported as +# NAME's config, so everything the entry point runs reads that config. +# +# BUS_TEMPLATE mosaic-bus@.service | mosaic-bus-NAME@.service +# BUS_GLOB 'mosaic-bus@*.service' | 'mosaic-bus-NAME@*.service' +# BUS_DROPIN_GLOBS unit-path globs whose .conf files can override the bus +# ExecStart: mosaic-bus@*.service.d for the default; +# mosaic-bus-NAME@*.service.d and the shared prefix +# mosaic-bus-.service.d for NAME; then mosaic-.service.d +# and service.d for both +# CONSOLE_UNIT mosaic-webui.service | mosaic-webui-NAME.service +# DISCORD_TEMPLATE mosaic-discord@.service | mosaic-discord-NAME@.service +# WRAPPER_NAME mosaic | mosaic-NAME +# SESSION_ENV %h/.config/mosaic-dev/secrets/claude-session.env +# | /secrets/claude-session.env +# INSTANCE_FLAGS () or (--instance NAME), for the entry points this one runs +# INSTANCE_RESERVED ports a named instance's Console never takes: 7330 (the +# operator's bare Console), 7332 (the tunnel) and 7333 +# (the default's tester); empty for the default +# INSTANCE_SED sed arguments that fill @CONFIG_ENV@ and @SESSION_ENV@ +# in a unit template: the default deletes the +# @CONFIG_ENV@ line, so its render is byte-identical to +# the template before row 190; NAME gets a comment and +# Environment=MOSAIC_CONFIG=, and " [NAME]" after +# the Description +# +# Also set: MOSAIC_INSTANCE (dev or NAME), MOSAIC_INSTANCE_CONFIG, +# MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT (empty when no flag +# was given and the config is missing or invalid; the caller's own load +# refuses that as before) and MOSAIC_UNIT_SUFFIX (empty or -NAME). + +INSTANCE_FLAG="" +INSTANCE_ARGS=() +INSTANCE_FLAGS=() +INSTANCE_RESERVED=() + +instance_args() { + local seen=0 + INSTANCE_FLAG="" + INSTANCE_ARGS=() + while [ $# -gt 0 ]; do + if [ "$1" = "--instance" ]; then + if [ "$seen" = 1 ] || [ $# -lt 2 ]; then + echo "instance: usage: --instance NAME, given once" >&2 + exit 4 + fi + INSTANCE_FLAG="$2" + seen=1 + shift 2 + else + INSTANCE_ARGS+=("$1") + shift + fi + done +} + +instance_select() { + local here out + here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [ -n "$INSTANCE_FLAG" ]; then + out="$(node "$here/mosaic-config.mjs" instance --instance "$INSTANCE_FLAG")" || exit $? + else + out="$(node "$here/mosaic-config.mjs" instance)" || exit $? + fi + eval "$out" + local s="$MOSAIC_UNIT_SUFFIX" + BUS_TEMPLATE="mosaic-bus$s@.service" + BUS_GLOB="mosaic-bus$s@*.service" + CONSOLE_UNIT="mosaic-webui$s.service" + DISCORD_TEMPLATE="mosaic-discord$s@.service" + WRAPPER_NAME="mosaic$s" + if [ -z "$s" ]; then + BUS_DROPIN_GLOBS=('mosaic-bus@*.service.d' 'mosaic-.service.d' 'service.d') + SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env' + INSTANCE_SED=(-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g") + return 0 + fi + # systemd expands % and splits Environment= on spaces, and the sed + # expressions use |, so a named instance's paths keep to a plain set. + case "$MOSAIC_INSTANCE_CONFIG" in + *[!A-Za-z0-9/._+-]*) + echo "instance: config path contains a character a unit cannot carry: $MOSAIC_INSTANCE_CONFIG" >&2 + exit 4 ;; + esac + export MOSAIC_CONFIG="${MOSAIC_CONFIG:-$MOSAIC_INSTANCE_CONFIG}" + INSTANCE_FLAGS=(--instance "$MOSAIC_INSTANCE") + INSTANCE_RESERVED=(7330 7332 7333) + BUS_DROPIN_GLOBS=("mosaic-bus$s@*.service.d" 'mosaic-bus-.service.d' 'mosaic-.service.d' 'service.d') + SESSION_ENV="$MOSAIC_INSTANCE_CONFIG_DIR/secrets/claude-session.env" + INSTANCE_SED=( + -e "s|^@CONFIG_ENV@\$|# Stack instance $MOSAIC_INSTANCE (row 190): the system config its processes read.\\nEnvironment=MOSAIC_CONFIG=$MOSAIC_INSTANCE_CONFIG|" + -e "s|@SESSION_ENV@|$SESSION_ENV|g" + -e "s|^\\(Description=.*\\)\$|\\1 [$MOSAIC_INSTANCE]|" + ) +} + +# instance_port_holder DIR PORT: the installed Console unit in DIR, other than +# this instance's, whose ExecStart takes PORT as --port or --tester-port. +# A unit with no --port holds 7332, serve's default. Prints the unit and +# returns 0, or prints nothing and returns 1. Unit files are host +# configuration, so reading another instance's is not reading its state. +instance_port_holder() { + local dir="$1" want="$2" file unit line tok next port + want="$((10#$want))" + for file in "$dir"/mosaic-webui.service "$dir"/mosaic-webui-*.service; do + [ -f "$file" ] || continue + unit="${file##*/}" + [ "$unit" != "$CONSOLE_UNIT" ] || continue + line="$(sed -n 's/^ExecStart=//p' "$file" | tail -n 1)" + port=7332 next="" + local -a ports=() toks=() + read -r -a toks <<< "$line" + for tok in ${toks[@]+"${toks[@]}"}; do + case "$next" in + --port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && port="$((10#$tok))" ;; + --tester-port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && ports+=("$((10#$tok))") ;; + esac + next="$tok" + done + ports+=("$port") + for port in "${ports[@]}"; do + if [ "$port" = "$want" ]; then echo "$unit"; return 0; fi + done + done + return 1 +} diff --git a/scripts/mosaic-config.mjs b/scripts/mosaic-config.mjs index e18fda675..056ade75e 100755 --- a/scripts/mosaic-config.mjs +++ b/scripts/mosaic-config.mjs @@ -8,13 +8,30 @@ * validate Load and strictly validate; print resolved config on stdout. * env Print shell-safe exports for launcher scripts: * MOSAIC_DATA_ROOT, MOSAIC_PROVIDER, MOSAIC_MODEL. + * instance Print shell-safe exports naming the selected stack instance + * (row 190): MOSAIC_INSTANCE, MOSAIC_INSTANCE_CONFIG, + * MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT and + * MOSAIC_UNIT_SUFFIX. scripts/instance.sh reads them. * - * Config location: $MOSAIC_CONFIG or ~/.config/mosaic-dev/config.json + * Each operation takes an optional `--instance NAME`. NAME matches + * ^[a-z][a-z0-9]{1,15}$ and selects ~/.config/mosaic-NAME/config.json; + * `dev` is the default instance, so `--instance dev` is the same as no flag. + * Without the flag the config is $MOSAIC_CONFIG or + * ~/.config/mosaic-dev/config.json, as before. + * + * A named instance's data root carries /instance.json, written by + * `bootstrap --instance NAME`, naming the instance. With the flag every + * operation refuses a root whose marker is missing or names another + * instance; `instance` without the flag refuses a root that a named + * instance claims. * * Exit codes: * 0 success * 2 configuration exists but is invalid (never modified by this tool) - * 3 configuration is missing for a read operation (validate/env) + * 3 configuration is missing for a read operation (validate/env/instance), + * or the data root's instance marker refuses the selection + * 4 usage: a bad instance name (instance-name-invalid), or --instance and + * $MOSAIC_CONFIG naming different files (instance-config-mismatch) * * Invariants (docs/plans/2026-09-02_atomic-mosaic-foundation.md): * - Existing configuration is never overwritten. @@ -33,16 +50,112 @@ const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]); const SUPPORTED_BACKENDS = new Set(["docker"]); const SUPPORTED_ADAPTERS = new Set(["pi", "mock"]); // mock: test-only, see adapters/README.md const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/; +// Row 190: stack instance names. No hyphen, so systemd's prefix drop-ins +// for mosaic-bus-NAME@ stay a fixed list and no name prefixes another's units. +const INSTANCE_PATTERN = /^[a-z][a-z0-9]{1,15}$/; +const DEFAULT_INSTANCE = "dev"; +const MARKER = "instance.json"; -function fail(exitCode, message) { - process.stderr.write(`mosaic-config: ${message}\n`); - process.exit(exitCode); +class Failure extends Error { + constructor(exitCode, message) { + super(message); + this.exitCode = exitCode; + } } -function configPath() { - return process.env.MOSAIC_CONFIG - ? path.resolve(process.env.MOSAIC_CONFIG) - : path.join(os.homedir(), ".config", "mosaic-dev", "config.json"); +function fail(exitCode, message) { + throw new Failure(exitCode, message); +} + +function instancePath(name) { + return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json"); +} + +function realOrNull(file) { + try { + return fs.realpathSync(file); + } catch { + return null; + } +} + +// The config file for a selection. Without --instance, as before. With it, +// NAME's file; a $MOSAIC_CONFIG beside it must be the same file. +function configPath(instance) { + if (instance === null) { + return process.env.MOSAIC_CONFIG + ? path.resolve(process.env.MOSAIC_CONFIG) + : instancePath(DEFAULT_INSTANCE); + } + const named = instancePath(instance); + if (process.env.MOSAIC_CONFIG) { + const given = path.resolve(process.env.MOSAIC_CONFIG); + const same = given === named || (realOrNull(given) !== null && realOrNull(given) === realOrNull(named)); + if (!same) { + fail(4, `instance-config-mismatch: --instance ${instance} names ${named}, but MOSAIC_CONFIG names ${given}`); + } + } + return named; +} + +// Operation arguments: nothing, or `--instance NAME` once. +function parseArgs(args) { + if (args.length === 0) return null; + if (args.length !== 2 || args[0] !== "--instance") { + fail(4, `usage: mosaic-config.mjs ${operation ?? "OP"} [--instance NAME]`); + } + const name = args[1]; + if (!INSTANCE_PATTERN.test(name)) { + fail(4, `instance-name-invalid: ${JSON.stringify(name)} must match ${INSTANCE_PATTERN}`); + } + return name; +} + +function isNamed(instance) { + return instance !== null && instance !== DEFAULT_INSTANCE; +} + +// The marker in a data root: null when absent, else its name. Anything else +// at the path refuses. +function readMarker(dataRoot) { + const file = path.join(dataRoot, MARKER); + let stat; + try { + stat = fs.lstatSync(file); + } catch (error) { + if (error.code === "ENOENT") return null; + fail(3, `instance-root-unclaimed: cannot read ${file} (${error.code})`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + fail(3, `instance-root-unclaimed: ${file} must be a regular, non-symbolic-link file`); + } + let marker; + try { + marker = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`); + } + if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name" + || marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) { + fail(3, `instance-root-unclaimed: ${file} is not a valid marker ({"instanceVersion":1,"name":NAME})`); + } + return marker.name; +} + +// The claim check (row 190 D2). A named instance needs its own marker; the +// default needs none, or one naming dev. +function checkClaim(dataRoot, instance) { + const owner = readMarker(dataRoot); + if (isNamed(instance)) { + if (owner === null) { + fail(3, `instance-root-unclaimed: ${dataRoot} holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`); + } + if (owner !== instance) { + fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}, not ${instance}`); + } + } else if (owner !== null && owner !== DEFAULT_INSTANCE) { + fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}; select it with --instance ${owner}`); + } } function readRaw(file) { @@ -182,10 +295,11 @@ async function validate(document, file) { }; } -async function load(file) { +async function load(file, instance = null) { const raw = readRaw(file); if (raw === null) { - fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`); + const bootstrap = isNamed(instance) ? `scripts/bootstrap.sh --instance ${instance}` : "scripts/bootstrap.sh"; + fail(3, `configuration not found: ${file} (run ${bootstrap} to create it)`); } let document; try { @@ -200,67 +314,178 @@ function shellQuote(value) { return `'${String(value).replaceAll("'", `'\\''`)}'`; } -const DEFAULT_CONFIG = { - configVersion: 1, - environment: "development", - dataRoot: path.join(os.homedir(), ".mosaic-dev"), - execution: { - backend: "docker", - provider: "zai", - model: "glm-5.3-flash", - }, -}; +function defaultConfig(dataRoot) { + return { + configVersion: 1, + environment: "development", + dataRoot, + execution: { + backend: "docker", + provider: "zai", + model: "glm-5.3-flash", + }, + }; +} + +const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev")); + +// 'wx': creation is exclusive; an existing file is never overwritten, and a +// symbolic link at the path is never followed. +function createExclusive(file, text, label = file) { + let fd; + try { + fd = fs.openSync(file, "wx", 0o644); + fs.writeFileSync(fd, text); + return true; + } catch (error) { + if (error.code === "EEXIST") return false; + fail(1, `unable to create ${label}: ${error.message}`); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +async function bootstrapDefault(file) { + if (fs.existsSync(file)) { + await load(file); // validate only; never rewrite + process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`); + return; + } + fs.mkdirSync(path.dirname(file), { recursive: true }); + if (!createExclusive(file, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "configuration")) { + await load(file); + return; + } + await load(file); + process.stderr.write(`mosaic-config: created default configuration: ${file}\n`); +} + +// Row 190 D2: NAME's config dir (0700), its config, then the claimed data +// root. The root is checked before anything is written, so a refusal +// writes nothing. +async function bootstrapNamed(file, instance) { + if (fs.existsSync(file)) { + const resolved = await load(file, instance); // validate only; never rewrite + checkClaim(resolved.dataRoot, instance); + process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`); + return; + } + const dataRoot = path.join(os.homedir(), `.mosaic-${instance}`); + let entries = null; + try { + const stat = fs.lstatSync(dataRoot); + if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: ${dataRoot} exists and is not a directory; nothing was written`); + entries = fs.readdirSync(dataRoot); + } catch (error) { + if (error instanceof Failure) throw error; + if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read ${dataRoot} (${error.code}); nothing was written`); + } + if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) { + fail(3, `instance-root-unclaimed: ${dataRoot} exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`); + } + const configDir = path.dirname(file); + fs.mkdirSync(path.dirname(configDir), { recursive: true }); + try { + fs.mkdirSync(configDir, { mode: 0o700 }); + } catch (error) { + if (error.code !== "EEXIST") fail(1, `unable to create ${configDir}: ${error.message}`); + } + if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`)) { + const resolved = await load(file, instance); + checkClaim(resolved.dataRoot, instance); + return; + } + const resolved = await load(file, instance); + try { + fs.mkdirSync(resolved.dataRoot, { mode: 0o700 }); + } catch (error) { + if (error.code !== "EEXIST") fail(1, `unable to create ${resolved.dataRoot}: ${error.message}`); + } + createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`); + checkClaim(resolved.dataRoot, instance); + process.stderr.write(`mosaic-config: created configuration for instance ${instance}: ${file}, data root ${resolved.dataRoot}\n`); +} + +// Without --instance, a missing or invalid config is left to the caller's +// own load, as before; only a loaded config's root is checked. +async function selected(instance) { + const file = configPath(instance); + let dataRoot = ""; + if (isNamed(instance)) { + dataRoot = (await load(file, instance)).dataRoot; + checkClaim(dataRoot, instance); + } else { + let resolved = null; + try { + resolved = readRaw(file) === null ? null : await load(file); + } catch (error) { + if (!(error instanceof Failure)) throw error; + } + if (resolved !== null) { + dataRoot = resolved.dataRoot; + checkClaim(dataRoot, instance); + } + } + const name = isNamed(instance) ? instance : DEFAULT_INSTANCE; + return [ + `MOSAIC_INSTANCE=${shellQuote(name)}`, + `MOSAIC_INSTANCE_CONFIG=${shellQuote(path.resolve(file))}`, + `MOSAIC_INSTANCE_CONFIG_DIR=${shellQuote(path.dirname(path.resolve(file)))}`, + `MOSAIC_INSTANCE_DATA_ROOT=${shellQuote(dataRoot)}`, + `MOSAIC_UNIT_SUFFIX=${shellQuote(isNamed(instance) ? `-${instance}` : "")}`, + "", + ].join("\n"); +} + +async function loadSelected(instance) { + const file = configPath(instance); + const resolved = await load(file, instance); + if (isNamed(instance)) checkClaim(resolved.dataRoot, instance); + return resolved; +} const operation = process.argv[2]; -const file = configPath(); -switch (operation) { - case "bootstrap": { - if (fs.existsSync(file)) { - await load(file); // validate only; never rewrite - process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`); +try { + if (!["bootstrap", "validate", "env", "instance"].includes(operation)) { + fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env | instance)`); + } + const instance = parseArgs(process.argv.slice(3)); + switch (operation) { + case "bootstrap": { + const file = configPath(instance); + if (isNamed(instance)) await bootstrapNamed(file, instance); + else await bootstrapDefault(file); process.exit(0); } - fs.mkdirSync(path.dirname(file), { recursive: true }); - let fd; - try { - // 'wx': creation is exclusive; an existing file is never overwritten. - fd = fs.openSync(file, "wx", 0o644); - fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`); - } catch (error) { - if (error.code === "EEXIST") { - await load(file); - process.exit(0); - } - fail(1, `unable to create configuration: ${error.message}`); - } finally { - if (fd !== undefined) fs.closeSync(fd); + + case "validate": { + const resolved = await loadSelected(instance); + process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`); + process.exit(0); } - await load(file); - process.stderr.write(`mosaic-config: created default configuration: ${file}\n`); - process.exit(0); - } - case "validate": { - const resolved = await load(file); - process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`); - process.exit(0); - } + case "env": { + const resolved = await loadSelected(instance); + process.stdout.write( + [ + `MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`, + `MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`, + `MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`, + `MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`, + "", + ].join("\n"), + ); + process.exit(0); + } - case "env": { - const resolved = await load(file); - process.stdout.write( - [ - `MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`, - `MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`, - `MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`, - `MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`, - "", - ].join("\n"), - ); - process.exit(0); + case "instance": { + process.stdout.write(await selected(instance)); + process.exit(0); + } } - - default: - fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env)`); +} catch (error) { + if (!(error instanceof Failure)) throw error; + process.stderr.write(`mosaic-config: ${error.message}\n`); + process.exit(error.exitCode); } diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh index 24bbb02a6..9c8c975c7 100755 --- a/scripts/stack-copy.sh +++ b/scripts/stack-copy.sh @@ -1,20 +1,22 @@ #!/usr/bin/env bash # One immutable pushed tree for the bus, Console and human CLI (#1579). # copy creates files only; wrapper is a separately Mos-gated live step. +# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME +# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies" BIN="$HOME/.npm-global/bin" -BUSINESS=mosaic-stack +BUSINESS=mosaic-stack BUSINESS_SET=0 ALLOW_WORKTREE=0 MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)' -usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--copies DIR] [--bin DIR] | check [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; } +usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] | check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; } die() { echo "stack-copy: $*" >&2; exit 1; } plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; } parse() { while [ $# -gt 0 ]; do case "$1" in --copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;; --bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;; - --business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";shift 2;; + --business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;shift 2;; --allow-worktree) [ "$CMD" = copy ] || usage; ALLOW_WORKTREE=1;shift;; *) usage;; esac; done; plain_path "$COPIES" 'copies directory'; } copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-character commit id'; [ -f "$COPIES/$ID/scripts/mosaic" ] || die "no copy $ID in $COPIES"; echo "$COPIES/$ID"; } @@ -64,6 +66,7 @@ copy_commit() { echo "copied: $target" } wrapper() { + [ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; } plain_path "$BIN" 'bin directory' local root file lease tmp root="$(copy_root)";file="$BIN/mosaic" @@ -77,6 +80,22 @@ wrapper() { chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT echo "wrapper: $root" } +# Row 190: $BIN/mosaic-NAME. It creates the file or replaces one with its own marker, never another. +# NAME's config path is plain (instance.sh refuses any other), so it goes in single quotes as is. +wrapper_instance() { + plain_path "$BIN" 'bin directory' + local root file tmp + root="$(copy_root)";file="$BIN/$WRAPPER_NAME" + if [ -e "$file" ] || [ -L "$file" ]; then + [ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is not regular, or a symlink: $file" + [ "$(sed -n '2p' "$file")" = "$INSTANCE_MARKER" ] || die "refusing: foreign wrapper (missing the instance $MOSAIC_INSTANCE marker on line 2): $file" + fi + tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")" + trap 'rm -f "$tmp"' EXIT + printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\nexport MOSAIC_CONFIG='"'"'%s'"'"'\nexec "%s/scripts/mosaic" "$@"\n' "$INSTANCE_MARKER" "$root" "$MOSAIC_INSTANCE_CONFIG" "$root" > "$tmp" + chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT + echo "wrapper: $root" +} unit_root() { local out load root exec_start out="$(systemctl --user show "$1" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || { echo 'absent (systemctl read failed)';return; } @@ -84,17 +103,18 @@ unit_root() { exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")" if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi case "$1" in - mosaic-bus@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };; + mosaic-bus@*|mosaic-bus-*@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };; mosaic-webui*) [[ "$exec_start" == *"$root/packages/webui/src/cli.mjs serve"* ]] || { echo "$root (ExecStart mismatch)";return; };; esac echo "$root" } check() { plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id' - local bus console cli=absent line path - bus="$(unit_root "mosaic-bus@$BUSINESS.service")";console="$(unit_root mosaic-webui.service)" - if [ -f "$BIN/mosaic" ] && [ ! -L "$BIN/mosaic" ] && [ "$(sed -n '2p' "$BIN/mosaic")" = "$MARKER" ]; then - line="$(tail -n 1 "$BIN/mosaic")" + local bus console cli=absent line path marker="$MARKER" wfile="$BIN/$WRAPPER_NAME" + [ -z "$MOSAIC_UNIT_SUFFIX" ] || marker="$INSTANCE_MARKER" + bus="$(unit_root "${BUS_TEMPLATE%.service}$BUSINESS.service")";console="$(unit_root "$CONSOLE_UNIT")" + if [ -f "$wfile" ] && [ ! -L "$wfile" ] && [ "$(sed -n '2p' "$wfile")" = "$marker" ]; then + line="$(tail -n 1 "$wfile")" path="${line#exec \"}";path="${path%\" \"\$@\"}" if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi fi @@ -103,10 +123,15 @@ check() { } # Sourced (stack-release.sh): define the functions, run nothing. [ "${BASH_SOURCE[0]}" = "$0" ] || return 0 +. "$REPO/scripts/instance.sh" +instance_args "$@" +set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"} [ $# -gt 0 ] || usage CMD="$1";shift +case "$CMD" in check|wrapper) instance_select;; *) [ -z "$INSTANCE_FLAG" ] || usage;; esac +INSTANCE_MARKER="# mosaic-cli wrapper, instance ${MOSAIC_INSTANCE:-dev} (row 190)" case "$CMD" in copy|wrapper) [ $# -gt 0 ] || usage;ID="$1";shift;case "$ID" in -*)usage;;esac;parse "$@";if [ "$CMD" = copy ];then copy_commit;else wrapper;fi;; - check) parse "$@";check;; + check) parse "$@";[ -z "$MOSAIC_UNIT_SUFFIX" ] || [ "$BUSINESS_SET" = 1 ] || usage;check;; -h|--help) usage 0;; *) usage;;esac diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 3b068b154..2d9afe4ce 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -24,11 +24,11 @@ set -euo pipefail cd "$(dirname "$0")/.." REPO="$(pwd)" source scripts/common.sh +source scripts/instance.sh COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies" 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 NO_SEND=0 R_BACKUP="" R_PORT="" @@ -41,7 +41,9 @@ usage: stack-release.sh adopt BUSINESS 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 +common options: --copies DIR, --bin DIR, --instance NAME +--instance NAME (row 190) runs on stack instance NAME: its config, data root, units and the wrapper +mosaic-NAME. Without it, the default instance, as before. --events N is the total of the per-table counts the post-backup-events refusal prints: events and every table in COUNTED. USAGE @@ -69,7 +71,10 @@ refuse() { # Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage). valid_tester_port() { [[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1024 ] && [ "$((10#$1))" -le 65535 ] && - [ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ] + [ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ] || return 1 + # Row 190: a named instance's Console never takes 7330, 7332 or 7333. + local r + for r in "${INSTANCE_RESERVED[@]}"; do [ "$((10#$1))" -ne "$r" ] || return 1; done } parse_opts() { @@ -416,16 +421,17 @@ capture() { mkdir -m 0700 "$RS" 2>/dev/null || return 1 STEPS="$RS/steps.log" local u d - for u in mosaic-bus@.service "$CONSOLE_UNIT"; do + for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do [ -f "$UNIT_DIR/$u" ] || { say "capture: no $UNIT_DIR/$u"; return 1; } cp -p "$UNIT_DIR/$u" "$RS/$u" || return 1 done - for d in mosaic-bus@.service.d "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do + # This instance's own directories only; the shared prefix ones are never captured (row 190). + for d in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do [ ! -d "$UNIT_DIR/$d" ] || cp -pr "$UNIT_DIR/$d" "$RS/$d" || return 1 done - [ -f "$BIN/mosaic" ] || { say "capture: no wrapper $BIN/mosaic"; return 1; } - cp -p "$BIN/mosaic" "$RS/wrapper" || return 1 - stat -c %a "$BIN/mosaic" > "$RS/WRAPPER_MODE" || return 1 + [ -f "$BIN/$WRAPPER_NAME" ] || { say "capture: no wrapper $BIN/$WRAPPER_NAME"; return 1; } + cp -p "$BIN/$WRAPPER_NAME" "$RS/wrapper" || return 1 + stat -c %a "$BIN/$WRAPPER_NAME" > "$RS/WRAPPER_MODE" || return 1 for u in "$BUS_UNIT" "$CONSOLE_UNIT"; do show "$u" > "$RS/show-$u.txt" || return 1; done sums "$RS" > "$RS/SHA256SUMS" || return 1 say "captured the rollback set: $RS" @@ -458,9 +464,9 @@ install_stack() { local id="$1" pm="$2" claude="$3"; shift 3 local -a pmflag=() [ "$pm" = 0 ] || pmflag=(--pm) - scripts/bus-service.sh install --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; } - scripts/webui-service.sh install --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; } - scripts/stack-copy.sh wrapper "$id" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; } + scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; } + scripts/webui-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; } + scripts/stack-copy.sh wrapper "$id" "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; } say "installed $id" } @@ -486,7 +492,7 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$main" "$START_MS" 2>&1)" || 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));' -- "$(console_port)" || return 1 GATE=copy-check - out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1 + out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1 # check passes when the three agree on one copy; this run needs it to be the target (row 166 item 3). out="$(sed -n 's/^bus: //p' <<< "$out")" [ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; } @@ -533,10 +539,10 @@ gate() { # Puts back the captured unit files, drop-ins and wrapper, checked against SHA256SUMS. put_back() { local u f rel tmp - for u in mosaic-bus@.service "$CONSOLE_UNIT"; do + for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do tmp="$(mktemp "$UNIT_DIR/.$u.XXXXXX")" && cp -p "$RS/$u" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u" || return 1 done - for u in mosaic-bus@.service.d "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do + for u in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do [ -d "$RS/$u" ] || continue while IFS= read -r -d '' f; do rel="${f#"$RS/$u/"}" @@ -545,12 +551,12 @@ put_back() { tmp="$(mktemp "$UNIT_DIR/$u/.restore.XXXXXX")" && cp -p "$f" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u/$rel" || return 1 done < <(find "$RS/$u" -type f -print0) done - tmp="$(mktemp "$BIN/.mosaic.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/mosaic" || return 1 - for u in mosaic-bus@.service "$CONSOLE_UNIT"; do + tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/$WRAPPER_NAME" || return 1 + for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do [ "$(sha256sum < "$UNIT_DIR/$u" | cut -d' ' -f1)" = "$(sum_of "$RS" "$u")" ] || return 1 done - [ "$(sha256sum < "$BIN/mosaic" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1 - [ "$(stat -c %a "$BIN/mosaic")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1 + [ "$(sha256sum < "$BIN/$WRAPPER_NAME" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1 + [ "$(stat -c %a "$BIN/$WRAPPER_NAME")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1 systemctl --user daemon-reload || return 1 say "put back the unit files and the wrapper from $RS" } @@ -607,9 +613,9 @@ lock() { instance_busy() { local out unit load state rest - out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown" + out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown" while read -r unit load state rest; do - [[ "$unit" == mosaic-bus@*.service ]] || continue + [[ "$unit" == $BUS_GLOB ]] || continue if [ "$unit" = "$BUS_UNIT" ]; then case "$state" in activating|deactivating|reloading) refuse instance-busy "$unit is $state" ;; esac else @@ -621,14 +627,17 @@ instance_busy() { # install_guard's drop-in scan (bus-service.sh), done before anything stops. A path it can't read # refuses here, as install_guard would refuse it after the stop (row 166 item 1). drop_in_override() { - local paths directory dropdir file content + local paths directory dropdir file content glob + local -a dropdirs paths="$(systemd-analyze --user unit-paths 2>/dev/null)" && [ -n "$paths" ] || refuse drop-in-override "systemd unit paths are unknown" while IFS= read -r directory; do [ -n "$directory" ] || continue case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac [ -e "$directory" ] || [ -L "$directory" ] || continue [ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || refuse drop-in-override "cannot inspect unit path $directory" - for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do + dropdirs=() + for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done + for dropdir in "${dropdirs[@]}"; do [ -e "$dropdir" ] || [ -L "$dropdir" ] || continue [ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir" for file in "$dropdir"/*.conf; do @@ -684,6 +693,7 @@ console_for_target() { [ "$PLAN" = 1 ] || usage local other="" [ "$main" = 7332 ] || other=" and the Console's --port $main" + [ "${#INSTANCE_RESERVED[@]}" = 0 ] || other+=" and instance $MOSAIC_INSTANCE's reserved ${INSTANCE_RESERVED[*]}" check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other" fi [ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port" @@ -813,10 +823,30 @@ proof_file_problem() { [ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as" } +# Row 190: the target Console's --port (7332 when absent) and --tester-port against every other +# installed Console unit in UNIT_DIR, the default's and each named instance's. +ports_unclaimed() { + local main=7332 i holder + local -a ports=() + for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do + case "${RUN_CONSOLE[i]}" in + --port) main="${RUN_CONSOLE[i + 1]:-7332}" ;; + --tester-port) ports+=("${RUN_CONSOLE[i + 1]:-}") ;; + esac + done + for i in "$main" "${ports[@]}"; do + [[ "$i" =~ ^[0-9]{1,5}$ ]] || continue + if holder="$(instance_port_holder "$UNIT_DIR" "$i")"; then + check_refuse port-claimed "port $i is taken by $UNIT_DIR/$holder" + fi + done +} + # The item 3 checks after the pointer, shared by activate and rollback, for target $TO. precheck() { check_target console_for_target + ports_unclaimed instance_busy drop_in_override read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT" @@ -857,7 +887,7 @@ do_adopt() { lock [ ! -f "$POINTER" ] || { read_pointer; FROM="$P_COPY"; refuse already-adopted "$POINTER exists (copy $P_COPY)"; } local out - out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out" + out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out" TO="$(sed -n 's/^bus: //p' <<< "$out")" TO="${TO#"$COPIES/"}" read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT" @@ -970,12 +1000,13 @@ plan() { plan_commits [ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM" [ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on" - echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE" + local flags="${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }" + echo "bus: scripts/bus-service.sh install $flags--copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE" [ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it" echo "$PROOF_PLAN" echo "$SEND_PLAN" - echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}" - echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN" + echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}" + echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN" local r for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done [ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3 @@ -1047,7 +1078,7 @@ do_activate() { pointer_for_business run_pm [ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first" - if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi + if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi precheck plugin_checks new_activation @@ -1059,7 +1090,7 @@ do_rollback() { pointer_for_business TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY" run_pm - if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi + if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi precheck plugin_checks [ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION" @@ -1153,7 +1184,7 @@ do_pm_on() { capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped" trap 'pm_on_fail signal' INT TERM HUP stop_bus || pm_on_fail stop-timeout - scripts/bus-service.sh install --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus + scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus say "installed $BUS_UNIT for $P_COPY with --pm" start_bus || pm_on_fail start gate "$P_COPY" 1 || pm_on_fail "$GATE" @@ -1173,7 +1204,7 @@ do_status() { open="$(interrupted | paste -sd' ' -)" [ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)" echo "check:" - if scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi + if scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi [ -n "$P_COPY" ] || return 0 echo "bus status:" "$COPIES/$P_COPY/scripts/mosaic" bus status 2>&1 | sed 's/^/ /' || true @@ -1437,6 +1468,7 @@ do_rehearse() { 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 + if i="$(instance_port_holder "$UNIT_DIR" "$R_PORT")"; then r_abort port-reserved "--port $R_PORT is $UNIT_DIR/$i's Console port"; fi 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 @@ -1489,6 +1521,9 @@ do_rehearse() { # --- Dispatch --- +# Row 190: --instance NAME may come anywhere; instance.sh takes it out and sets the names. +instance_args "$@" +set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"} [ $# -gt 0 ] || usage OP="$1"; shift ID="" @@ -1501,13 +1536,14 @@ esac [[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage parse_opts "$@" COPIES_DIR="$COPIES" +instance_select load_config DATA="$MOSAIC_DATA_ROOT" STATE="$DATA/state" POINTER="$STATE/stack-active.json" LOG="$STATE/stack-activation-log.jsonl" STORE="$DATA/bus/bus.sqlite" -BUS_UNIT="mosaic-bus@$BUSINESS.service" +BUS_UNIT="${BUS_TEMPLATE%.service}$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="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=() diff --git a/scripts/test-discord.sh b/scripts/test-discord.sh index 109dfdfa5..114994936 100755 --- a/scripts/test-discord.sh +++ b/scripts/test-discord.sh @@ -170,6 +170,10 @@ check "scripts/discord.sh reload without a binding exits 4" $? # --- the service unit: rendered from the template, never touching systemd here --- UNITS="$SANDBOX/units" +# Row 190: discord-service.sh reads the selected config first, so these runs name a scratch one +# that does not exist (the default's names, no data root) instead of ~/.config/mosaic-dev. +CALLER_MOSAIC_CONFIG="${MOSAIC_CONFIG-}" +export MOSAIC_CONFIG="$SANDBOX/absent/config.json" scripts/discord-service.sh >/dev/null 2>&1 [ $? -eq 4 ] check "scripts/discord-service.sh without a command exits 4" $? @@ -246,6 +250,7 @@ HOME="$SANDBOX/home" XDG_CONFIG_HOME="$SANDBOX/config-home" PATH="$SANDBOX/fake- check "status names the copy from fake systemctl effective cwd" $? grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1" check "checkout install explicitly recommends --copy" $? +if [ -n "$CALLER_MOSAIC_CONFIG" ]; then export MOSAIC_CONFIG="$CALLER_MOSAIC_CONFIG"; else unset MOSAIC_CONFIG; fi # Offline wrapper entrypoint. Only a synthetic token/binding/config are used. node - "$SANDBOX" <<'EOF_OFFLINE' diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index 0a32547c5..36fc87d2d 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -65,12 +65,27 @@ # --no-reload: a unit rewritten under a running Console would take the new # ExecStart at its next Restart=on-failure, with nobody starting it. # -# Exit codes: 0 ok, 1 operation failed, 4 usage. +# --instance NAME (row 190, any position) selects stack instance NAME: the +# unit is mosaic-webui-NAME.service and sets MOSAIC_CONFIG to +# ~/.config/mosaic-NAME/config.json, its bus units are mosaic-bus-NAME@, +# and render and install need --port, which may not be 7330, 7332 or 7333 +# (exit 4, port-reserved). For every instance, install refuses a --port or +# --tester-port that another installed Console unit in the unit directory +# already takes (port-claimed). See scripts/instance.sh. +# +# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another +# instance or not claimed (scripts/instance.sh), 4 usage. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in" -UNIT_NAME="mosaic-webui.service" -USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] +# shellcheck source=scripts/instance.sh +. "$REPO/scripts/instance.sh" +instance_args "$@" +set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"} +instance_select +UNIT_NAME="$CONSOLE_UNIT" +BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}" +USAGE="usage: scripts/webui-service.sh [--instance NAME] copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR] FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH] --tester-port: 1024..65535, different from --port and never 7332; loopback tester listener. --allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout." @@ -86,6 +101,7 @@ ALLOW_WORKTREE=0 RELOAD=1 DIR_FLAGS=0 PORT=7332 +PORT_SET=0 TESTER_PORT="" TESTER_SET=0 BUSINESS="" @@ -108,7 +124,7 @@ parse_flags() { --copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;; --allow-worktree) ALLOW_WORKTREE=1; shift ;; --no-reload) RELOAD=0; DIR_FLAGS=1; shift ;; - --port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;; + --port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;; --tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;; --business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;; --board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;; @@ -127,6 +143,31 @@ parse_flags() { [ "$TESTER_SET" = 0 ] || tester_port_ok } +# Row 190: a named instance's Console names its own port, outside the +# default's ports. Called by render and install only. +instance_ports_ok() { + [ "${#INSTANCE_RESERVED[@]}" -gt 0 ] || return 0 + [ "$PORT_SET" = 1 ] || { echo "webui-service: stack instance $MOSAIC_INSTANCE needs --port; it has no default port" >&2; exit 4; } + local p r + for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do + [[ "$p" =~ ^[0-9]{1,5}$ ]] || continue + for r in "${INSTANCE_RESERVED[@]}"; do + [ "$((10#$p))" != "$r" ] || { echo "webui-service: port-reserved: $p is reserved for the default instance (${INSTANCE_RESERVED[*]})" >&2; exit 4; } + done + done +} + +# Row 190: refuse a port another installed Console unit already takes. +ports_unclaimed() { + local p holder + for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do + [[ "$p" =~ ^[0-9]{1,5}$ ]] || continue + if holder="$(instance_port_holder "$UNIT_DIR" "$p")"; then + die "refusing port-claimed: port $p is taken by $UNIT_DIR/$holder; nothing was changed" + fi + done +} + tester_port_usage() { echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2 exit 4 @@ -259,7 +300,7 @@ render() { local flags flags="$(serve_flags)" || exit 1 [ -z "$COPY_ID" ] || flags="--checkout $REPO $flags" - sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE" + sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "${INSTANCE_SED[@]}" "$TEMPLATE" } # The unit's active state from list-units, printed when it is anything but @@ -279,16 +320,17 @@ running() { # only with a bus on that same copy: install refuses unless every bus unit # it would call has an ExecStart path inside the copy. With --business the # unit is mosaic-bus@ID.service; without it, every mosaic-bus@ unit systemd -# has loaded, and none loaded refuses. Like the state read, a failed call +# has loaded, and none loaded refuses. A named instance reads its own +# mosaic-bus-NAME@ units only. Like the state read, a failed call # refuses with one line. bus_on_copy() { local copy="$1" units unit out load paths p working if [ -n "$BUSINESS" ]; then - units="mosaic-bus@$BUSINESS.service" + units="$BUS_UNIT_PREFIX$BUSINESS.service" else - out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed" - units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')" - [ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID" + out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain $BUS_GLOB failed, so the bus unit is unknown; nothing was changed" + units="$(printf '%s\n' "$out" | awk -v p="$BUS_UNIT_PREFIX" 'index($1, p) == 1 && length($1) > length(p) + 8 && substr($1, length($1) - 7) == ".service" {print $1}')" + [ -n "$units" ] || die "refusing: no $BUS_UNIT_PREFIX unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID" fi for unit in $units; do out="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed" @@ -308,13 +350,15 @@ install_unit() { parse_flags "$@" canonical [ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT" + instance_ports_ok local unit unit="$(render)" || exit 1 + ports_unclaimed bus_on_copy "$COPIES/$COPY_ID" # N1: a running unit would take the new ExecStart at its next restart. local state state="$(running)" || exit 1 - [ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it" + [ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service}), then install, then start it" mkdir -p "$UNIT_DIR" local target="$UNIT_DIR/$UNIT_NAME" tmp tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")" @@ -336,11 +380,11 @@ install_unit() { this script starts nothing: the Console holds the human's capability. Jason starts and restarts it, or Mos under lead decision 102 (sends then enter the trail as Jason); no other agent does: - systemctl --user enable --now mosaic-webui start now and at login - systemctl --user status mosaic-webui - journalctl --user -u mosaic-webui -f the Console's log - systemctl --user stop mosaic-webui SIGTERM; restartable - scripts/webui-service.sh status unit state and /healthz + systemctl --user enable --now ${UNIT_NAME%.service} start now and at login + systemctl --user status ${UNIT_NAME%.service} + journalctl --user -u ${UNIT_NAME%.service} -f the Console's log + systemctl --user stop ${UNIT_NAME%.service} SIGTERM; restartable + scripts/webui-service.sh ${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }status unit state and /healthz survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)} MSG } @@ -352,7 +396,7 @@ uninstall_unit() { # N3: checked with --no-reload too, which skips only daemon-reload. local state state="$(running)" || exit 1 - [ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)" + [ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service})" if [ -f "$target" ]; then rm -f "$target" echo "removed: $target" @@ -439,6 +483,7 @@ case "$cmd" in parse_flags "$@" [ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage { [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage + instance_ports_ok render ;; install) install_unit "$@" ;; uninstall) uninstall_unit "$@" ;;