Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
2612 lines
148 KiB
Diff
2612 lines
148 KiB
Diff
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).
|
|
- `<dataRoot>` (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 f2892ca04..47765cdd5 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 | `[email protected]` | `[email protected]` |
|
|
+| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` |
|
|
+| Discord unit | `[email protected]` | `[email protected]` |
|
|
+| 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=<its 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.
|
|
```
|
|
@@ -594,6 +668,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]
|
|
@@ -671,8 +746,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 `[email protected]`, 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, "[email protected]"), "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 ba200c76a..3d9df6920 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-<copy> 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)?'[email protected]':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<stem.length;i++)if(stem[i]==='-')dirs.push(stem.slice(0,i+1)+'.service.d');
|
|
+ for(const d of [...dirs,...(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))}
|
|
const r={wd:'',exec:'',env:[],drops};
|
|
for(const f of [file,...drops])for(let line of fs.readFileSync(f,'utf8').split('\n')){
|
|
line=line.replace(/%i/g,isBus(u)?inst(u):'').replace(/%h/g,process.env.HOME);
|
|
@@ -51,17 +56,21 @@ function systemctl(){
|
|
}
|
|
return r;
|
|
};
|
|
- const snap=(ev)=>{
|
|
- 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,'[email protected]')),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:'[email protected]',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-<copy> 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-<copy>
|
|
+// 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});
|
|
@@ -2084,3 +2122,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,`[email protected]`,b.tmpl,b.con,`[email protected]`].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 [['[email protected]','active','[email protected] 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 <dataRoot>/seats/<layout>/<seat>/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 `[email protected]`,
|
|
+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 <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`;
|
|
diff --git a/packages/discord/systemd/[email protected] b/packages/discord/systemd/[email protected]
|
|
index f8d41e493..eeffd72ca 100644
|
|
--- a/packages/discord/systemd/[email protected]
|
|
+++ b/packages/discord/systemd/[email protected]
|
|
@@ -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/[email protected]'),'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/[email protected]'),'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,'[email protected]'),'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 634ceaead..1442b0e3e 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 d8ff70edb..33d5c41ad 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 [email protected], 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="[email protected]"
|
|
-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 <business>"
|
|
+# 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 <business>"
|
|
|
|
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. [email protected].
|
|
- for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
|
|
+ # * includes the empty instance, i.e. [email protected] (or
|
|
+ # [email protected]). 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 <dataRoot>/notify/<business> the notifier refuses a looser directory
|
|
write <dataRoot>/notify/<business>/notify.json, mode 0600:
|
|
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
|
|
- systemctl --user enable --now mosaic-bus@<business> start now and at login
|
|
- systemctl --user status mosaic-bus@<business>
|
|
- journalctl --user -u mosaic-bus@<business> -f the host's log
|
|
- systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
|
|
+ systemctl --user enable --now ${BUS_TEMPLATE%.service}<business> start now and at login
|
|
+ systemctl --user status ${BUS_TEMPLATE%.service}<business>
|
|
+ journalctl --user -u ${BUS_TEMPLATE%.service}<business> -f the host's log
|
|
+ systemctl --user stop ${BUS_TEMPLATE%.service}<business> 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 <binding> 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 [email protected], 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/[email protected]"
|
|
-UNIT_NAME="[email protected]"
|
|
-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 <binding>"
|
|
+. "$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 <binding>"
|
|
|
|
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 <<MSG
|
|
next, for one binding:
|
|
- systemctl --user enable --now mosaic-discord@<binding> start now and at login
|
|
- systemctl --user status mosaic-discord@<binding>
|
|
- journalctl --user -u mosaic-discord@<binding> -f the connector's log
|
|
- systemctl --user stop mosaic-discord@<binding> SIGTERM; restartable
|
|
+ systemctl --user enable --now $UNIT_PREFIX<binding> start now and at login
|
|
+ systemctl --user status $UNIT_PREFIX<binding>
|
|
+ journalctl --user -u $UNIT_PREFIX<binding> -f the connector's log
|
|
+ systemctl --user stop $UNIT_PREFIX<binding> SIGTERM; restartable
|
|
scripts/discord.sh stop <binding> 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 [email protected] | [email protected]
|
|
+# 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 [email protected] | [email protected]
|
|
+# WRAPPER_NAME mosaic | mosaic-NAME
|
|
+# SESSION_ENV %h/.config/mosaic-dev/secrets/claude-session.env
|
|
+# | <config dir>/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=<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="[email protected]"
|
|
+ BUS_GLOB="mosaic-bus$s@*.service"
|
|
+ CONSOLE_UNIT="mosaic-webui$s.service"
|
|
+ DISCORD_TEMPLATE="[email protected]"
|
|
+ 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 cd66560d0..a8e73caa1 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 <dataRoot>/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.
|
|
@@ -32,16 +49,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";
|
|
+
|
|
+class Failure extends Error {
|
|
+ constructor(exitCode, message) {
|
|
+ super(message);
|
|
+ this.exitCode = exitCode;
|
|
+ }
|
|
+}
|
|
|
|
function fail(exitCode, message) {
|
|
- process.stderr.write(`mosaic-config: ${message}\n`);
|
|
- process.exit(exitCode);
|
|
+ throw new Failure(exitCode, message);
|
|
+}
|
|
+
|
|
+function instancePath(name) {
|
|
+ return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
|
|
}
|
|
|
|
-function configPath() {
|
|
- return process.env.MOSAIC_CONFIG
|
|
- ? path.resolve(process.env.MOSAIC_CONFIG)
|
|
- : path.join(os.homedir(), ".config", "mosaic-dev", "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) {
|
|
@@ -159,10 +272,11 @@ function validate(document, file) {
|
|
};
|
|
}
|
|
|
|
-function load(file) {
|
|
+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 {
|
|
@@ -177,67 +291,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 operation = process.argv[2];
|
|
-const file = configPath();
|
|
+const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
|
|
|
|
-switch (operation) {
|
|
- case "bootstrap": {
|
|
- if (fs.existsSync(file)) {
|
|
- load(file); // validate only; never rewrite
|
|
- process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
|
|
- process.exit(0);
|
|
- }
|
|
- fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
- let fd;
|
|
+// '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);
|
|
+ }
|
|
+}
|
|
+
|
|
+function bootstrapDefault(file) {
|
|
+ if (fs.existsSync(file)) {
|
|
+ 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")) {
|
|
+ load(file);
|
|
+ return;
|
|
+ }
|
|
+ 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.
|
|
+function bootstrapNamed(file, instance) {
|
|
+ if (fs.existsSync(file)) {
|
|
+ const resolved = 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 = load(file, instance);
|
|
+ checkClaim(resolved.dataRoot, instance);
|
|
+ return;
|
|
+ }
|
|
+ const resolved = 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.
|
|
+function selected(instance) {
|
|
+ const file = configPath(instance);
|
|
+ let dataRoot = "";
|
|
+ if (isNamed(instance)) {
|
|
+ dataRoot = load(file, instance).dataRoot;
|
|
+ checkClaim(dataRoot, instance);
|
|
+ } else {
|
|
+ let resolved = null;
|
|
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`);
|
|
+ resolved = readRaw(file) === null ? null : load(file);
|
|
} catch (error) {
|
|
- if (error.code === "EEXIST") {
|
|
- load(file);
|
|
- process.exit(0);
|
|
- }
|
|
- fail(1, `unable to create configuration: ${error.message}`);
|
|
- } finally {
|
|
- if (fd !== undefined) fs.closeSync(fd);
|
|
+ if (!(error instanceof Failure)) throw error;
|
|
+ }
|
|
+ if (resolved !== null) {
|
|
+ dataRoot = resolved.dataRoot;
|
|
+ checkClaim(dataRoot, instance);
|
|
}
|
|
- load(file);
|
|
- process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
|
|
- process.exit(0);
|
|
}
|
|
+ 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");
|
|
+}
|
|
|
|
- case "validate": {
|
|
- const resolved = load(file);
|
|
- process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
|
- process.exit(0);
|
|
- }
|
|
+function loadSelected(instance) {
|
|
+ const file = configPath(instance);
|
|
+ const resolved = load(file, instance);
|
|
+ if (isNamed(instance)) checkClaim(resolved.dataRoot, instance);
|
|
+ return resolved;
|
|
+}
|
|
+
|
|
+const operation = process.argv[2];
|
|
|
|
- case "env": {
|
|
- const resolved = 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);
|
|
+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)) bootstrapNamed(file, instance);
|
|
+ else bootstrapDefault(file);
|
|
+ process.exit(0);
|
|
+ }
|
|
+
|
|
+ case "validate": {
|
|
+ const resolved = loadSelected(instance);
|
|
+ process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
|
+ process.exit(0);
|
|
+ }
|
|
|
|
- default:
|
|
- fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env)`);
|
|
+ case "env": {
|
|
+ const resolved = 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 "instance": {
|
|
+ process.stdout.write(selected(instance));
|
|
+ process.exit(0);
|
|
+ }
|
|
+ }
|
|
+} 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 0053f35d1..05ad6fd57 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 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() {
|
|
@@ -413,16 +418,17 @@ capture() {
|
|
mkdir -m 0700 "$RS" 2>/dev/null || return 1
|
|
STEPS="$RS/steps.log"
|
|
local u d
|
|
- for u in [email protected] "$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 [email protected] "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"
|
|
@@ -455,9 +461,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"
|
|
}
|
|
|
|
@@ -483,7 +489,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; }
|
|
@@ -530,10 +536,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 [email protected] "$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 [email protected] "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/"}"
|
|
@@ -542,12 +548,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 [email protected] "$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"
|
|
}
|
|
@@ -604,9 +610,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
|
|
@@ -618,14 +624,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
|
|
@@ -681,6 +690,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"
|
|
@@ -783,10 +793,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"
|
|
@@ -827,7 +857,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"
|
|
@@ -940,11 +970,12 @@ 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 "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
|
|
@@ -1015,7 +1046,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
|
|
@@ -1027,7 +1058,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"
|
|
@@ -1121,7 +1152,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"
|
|
@@ -1141,7 +1172,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
|
|
@@ -1405,6 +1436,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
|
|
@@ -1457,6 +1489,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=""
|
|
@@ -1469,13 +1504,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="" 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 d34af09dc..17876e8eb 100755
|
|
--- a/scripts/webui-service.sh
|
|
+++ b/scripts/webui-service.sh
|
|
@@ -62,12 +62,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] [--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."
|
|
@@ -83,6 +98,7 @@ ALLOW_WORKTREE=0
|
|
RELOAD=1
|
|
DIR_FLAGS=0
|
|
PORT=7332
|
|
+PORT_SET=0
|
|
TESTER_PORT=""
|
|
TESTER_SET=0
|
|
BUSINESS=""
|
|
@@ -104,7 +120,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 ;;
|
|
@@ -122,6 +138,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
|
|
@@ -249,7 +290,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
|
|
@@ -269,16 +310,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 [email protected]; 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"
|
|
@@ -298,13 +340,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")"
|
|
@@ -326,11 +370,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
|
|
}
|
|
@@ -342,7 +386,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"
|
|
@@ -426,6 +470,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 "$@" ;;
|