stack: several Mosaic Stack instances on one host (row 190, #1688)
--instance NAME on the shell entry points through scripts/instance.sh;
per-instance config, data root and marker, units, wrapper and ports;
the claim checks in mosaic-config.mjs; the guard protects every
instance's config and data root. Brief blob 4e8f1e0528, design
agents/darkwing/work/queue-190/DESIGN.md rev 3 (Filbert approved).
This commit is contained in:
1 parent
088087ac96
commit
ceaf93837c
24 files changed
+1458
-180
No files matched your search
@@ -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
|
1. **Root is bootstrap-only.** First-class system configuration lives at the
|
||||||
repository root; everything else gets a dedicated directory (`roles/`,
|
repository root; everything else gets a dedicated directory (`roles/`,
|
||||||
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
|
||||||
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
|
2. **Configuration**: each stack instance has exactly one system config. The
|
||||||
config — created only by `scripts/bootstrap.sh`, never overwritten,
|
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
|
||||||
fail-closed on any problem. Repo-scoped role authority lives in
|
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
|
||||||
`roles/*.json` (versioned, reviewed commits only).
|
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
|
3. **Secrets** never enter the repository or container images; auth is
|
||||||
runtime-only (read-only mount or environment variable).
|
runtime-only (read-only mount or environment variable).
|
||||||
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
|
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)
|
## Data map (canon)
|
||||||
|
|
||||||
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
|
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
|
||||||
auto-written).
|
per named instance: system config (user-authored; never auto-written).
|
||||||
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
|
||||||
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
|
||||||
- `sessions/` — pi JSONL session trees, one directory per named session
|
- `sessions/` — pi JSONL session trees, one directory per named session
|
||||||
|
|||||||
+82
-6
@@ -15,7 +15,7 @@ and changes nothing.
|
|||||||
|
|
||||||
| Command | Purpose | Notes |
|
| 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/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/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 |
|
| `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 ·
|
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
|
||||||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
`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`)
|
## Stack copies (`scripts/stack-copy.sh`)
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
|
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
|
||||||
scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR]
|
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR]
|
||||||
scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID]
|
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,
|
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 pm-on BUSINESS [--timeout S]
|
||||||
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||||
scripts/stack-release.sh status BUSINESS
|
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
|
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||||
every table in COUNTED.
|
every table in COUNTED.
|
||||||
```
|
```
|
||||||
@@ -602,6 +676,7 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
|
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 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 install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
|
||||||
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
|
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
|
||||||
@@ -679,8 +754,9 @@ Each value is held to a plain character set the unit can carry (no space,
|
|||||||
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
|
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
|
||||||
with a letter or digit; anything else is refused. With `--auth-header
|
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
|
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_*`,
|
uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`,
|
||||||
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
|
`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.
|
port already in use) is not restarted.
|
||||||
|
|
||||||
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
|
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
|
||||||
|
|||||||
@@ -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
|
(or this line and this paragraph), then restart the unit; a running host
|
||||||
keeps the environment it started with.
|
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
|
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
|
order on that system target. The notifier needs no network at start: a
|
||||||
send that fails is journaled and retried.
|
send that fails is journaled and retried.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ StartLimitBurst=5
|
|||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=@REPO@
|
WorkingDirectory=@REPO@
|
||||||
Environment=PATH=@PATH@
|
Environment=PATH=@PATH@
|
||||||
|
@CONFIG_ENV@
|
||||||
# The Claude Code binary that managed sessions and the manifest's
|
# The Claude Code binary that managed sessions and the manifest's
|
||||||
# claudeVersion run, pinned at install (`--claude PATH`, default
|
# claudeVersion run, pinned at install (`--claude PATH`, default
|
||||||
# `command -v claude`). Without it they run `claude` from PATH above.
|
# `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
|
# 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
|
# ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL. The `-` lets the unit start
|
||||||
# without it, and a Claude Code launch then refuses.
|
# 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
|
# 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
|
# (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
|
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
|
||||||
|
|||||||
@@ -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) => {
|
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
|
||||||
const dir = tmp(t);
|
const dir = tmp(t);
|
||||||
const script = join(REPO, "scripts", "bus-service.sh");
|
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.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, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
|
||||||
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
|
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);
|
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,"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);
|
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 });
|
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv });
|
||||||
assert.equal(first.status, 0, first.stderr);
|
assert.equal(first.status, 0, first.stderr);
|
||||||
assert.match(first.stdout, /written: /);
|
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.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, ["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.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
|
// 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 onPath = stand(join(dir, "bin", "claude"));
|
||||||
const named = stand(join(dir, "claude-named"));
|
const named = stand(join(dir, "claude-named"));
|
||||||
symlinkSync(named, join(dir, "claude-link"));
|
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 run = (args, e = env) => spawnSync(script, args, { encoding: "utf8", env: e });
|
||||||
const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1];
|
const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
|
// 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
|
// 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
|
// 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(){
|
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 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');
|
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 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 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 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 unitFile=(u)=>{
|
||||||
const main=isBus(u)?'mosaic-bus@.service':u,file=path.join(unitDir,main);
|
const main=isBus(u)?u.replace(/@[^@]*$/,'@.service'):u,file=path.join(unitDir,main);
|
||||||
if(!fs.existsSync(file))return null;
|
if(!fs.existsSync(file))return null;
|
||||||
const drops=[];
|
// The drop-in directories in systemd's order: service.d, the name cut after each hyphen, then
|
||||||
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 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};
|
const r={wd:'',exec:'',env:[],drops};
|
||||||
for(const f of [file,...drops])for(let line of fs.readFileSync(f,'utf8').split('\n')){
|
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);
|
line=line.replace(/%i/g,isBus(u)?inst(u):'').replace(/%h/g,process.env.HOME);
|
||||||
@@ -51,17 +56,21 @@ function systemctl(){
|
|||||||
}
|
}
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
const snap=(ev)=>{
|
// A named instance's unit sets MOSAIC_CONFIG, and its bus works on that config's data root. A unit
|
||||||
const dir=path.join(data,'bus'),files={};
|
// 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{}
|
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');
|
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{}
|
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 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 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});
|
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`);
|
line(`bus host up: business ${b}, socket /fixture/bus.sock, notifier off`);
|
||||||
switch(mode){
|
switch(mode){
|
||||||
@@ -107,26 +116,26 @@ function systemctl(){
|
|||||||
}
|
}
|
||||||
if(verb==='start'){
|
if(verb==='start'){
|
||||||
const u=rest[0],f=unitFile(u);if(!f){console.error(`Unit ${u} not found.`);process.exit(5)}
|
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(!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)}
|
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
|
// 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.
|
// store differs from a backup the run took before the start. The close leaves no -wal.
|
||||||
if(fault(`start-writes-row-${id}`)){
|
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();
|
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';
|
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.
|
// 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 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();
|
ch.unref();
|
||||||
fs.appendFileSync(path.join(sd,'pids.log'),ch.pid+'\n');
|
fs.appendFileSync(path.join(sd,'pids.log'),ch.pid+'\n');
|
||||||
const ready=path.join(sd,`ready-${ch.pid}`),end=Date.now()+10000;
|
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)}
|
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);
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if(verb==='stop'){
|
if(verb==='stop'){
|
||||||
@@ -138,17 +147,18 @@ function systemctl(){
|
|||||||
}
|
}
|
||||||
if(st){s[u]={...st,state:'inactive',pid:null};save(s)}
|
if(st){s[u]={...st,state:'inactive',pid:null};save(s)}
|
||||||
if(isBus(u)){
|
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.
|
// 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
|
// 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.
|
// write the bus made between rollback's count and its stop would. The close leaves no -wal.
|
||||||
if(fault('stop-writes-row')){
|
if(fault('stop-writes-row')){
|
||||||
const t=fs.readFileSync(faultFile('stop-writes-row'),'utf8');fs.rmSync(faultFile('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.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();
|
db.prepare(`INSERT INTO ${t} (at) VALUES (?)`).run('2999-01-01T00:00:00.000Z');db.close();
|
||||||
}
|
}
|
||||||
snap('stop');
|
snap('stop',d);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -178,10 +188,11 @@ function systemdAnalyze(){
|
|||||||
if(fs.existsSync(extra))console.log(fs.readFileSync(extra,'utf8'));
|
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(){
|
function holder(){
|
||||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
|
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});
|
fs.mkdirSync(bus,{recursive:true,mode:0o700});
|
||||||
const db=new DatabaseSync(path.join(bus,'bus.sqlite'));
|
const db=new DatabaseSync(path.join(bus,'bus.sqlite'));
|
||||||
if(!db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='meta'").get().n){
|
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();
|
db.prepare("INSERT INTO meta VALUES ('schema_version', '3g')").run();
|
||||||
}
|
}
|
||||||
fs.writeFileSync(path.join(bus,'writer.lock'),String(process.pid));
|
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.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);
|
fs.renameSync(host+'.tmp',host);
|
||||||
process.on('SIGTERM',()=>{db.close();fs.rmSync(path.join(bus,'writer.lock'),{force:true});process.exit(0)});
|
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);
|
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(){
|
function mosaicStub(){
|
||||||
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
|
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 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));
|
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}};
|
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(a==='bus status --json'){
|
||||||
if(fault(`status-${id}`))process.exit(1);
|
if(fault(`status-${id}`))process.exit(1);
|
||||||
let s={host:null,boot:null};
|
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));
|
console.log(JSON.stringify(s));
|
||||||
}else if(a==='bus status')console.log(`bus: fixture status for ${id}`);
|
}else if(a==='bus status')console.log(`bus: fixture status for ${id}`);
|
||||||
else if(a==='launches list --json'){
|
else if(a==='launches list --json'){
|
||||||
if(fault(`launches-${id}`))process.exit(1);
|
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));
|
console.log(JSON.stringify(l));
|
||||||
}else{console.error(`fixture mosaic: ${a}`);process.exit(4)}
|
}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 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}`);
|
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.
|
// 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.
|
// The schema version: readOnly while the holder has the store open, immutable when it is stopped.
|
||||||
function storeVersion(file,held){
|
function storeVersion(file,held){
|
||||||
const db=held?new DatabaseSync(file,{readOnly:true}):new DatabaseSync(pathToFileURL(file).href+'?immutable=1',{readOnly:true});
|
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){
|
async function mk(t){
|
||||||
const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
|
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')};
|
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')});
|
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});
|
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);
|
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(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});
|
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()};
|
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=await healthz(e);
|
||||||
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);
|
|
||||||
t.after(()=>{
|
t.after(()=>{
|
||||||
for(const pid of lines(join(e.sd,'pids.log')).map(Number)){
|
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{}
|
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;
|
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){
|
async function run(e,...args){
|
||||||
const before=existsSync(e.data)?kept(e):[];
|
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});
|
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 timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
|
||||||
const [code]=await once(ch,'close');clearTimeout(timer);
|
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`);
|
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
|
||||||
db.close();
|
db.close();
|
||||||
const sh=(name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']});
|
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('bus-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
|
||||||
sh('webui-service.sh','install','--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
|
sh('webui-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
|
||||||
sh('stack-copy.sh','wrapper',copy,'--copies',COPIES,'--bin',e.bin);
|
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.
|
// 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'}});
|
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
|
||||||
sysctl(e,'start',CON);
|
sysctl(e,'start',e.con);
|
||||||
if(how==='adopt'){const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err)}
|
if(how==='adopt'){const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err)}
|
||||||
else if(how==='hand'){
|
else if(how==='hand'){
|
||||||
mkdirSync(e.state,{recursive:true});
|
mkdirSync(e.state,{recursive:true});
|
||||||
@@ -2191,3 +2229,142 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
|
|||||||
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,9 @@
|
|||||||
// every session path are constructor arguments, with no default. At
|
// every session path are constructor arguments, with no default. At
|
||||||
// construction and again at bind, their real paths must lie inside the
|
// construction and again at bind, their real paths must lie inside the
|
||||||
// explicit fixture root and outside every protected location: the
|
// explicit fixture root and outside every protected location: the
|
||||||
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any
|
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root, every
|
||||||
// path a seat registration names. A path is refused when it is inside a
|
// 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;
|
// protected location or contains one. The real protections always apply;
|
||||||
// options only add to them, so a test can't switch them off.
|
// 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 inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
||||||
const overlaps = (a, b) => inside(a, b) || inside(b, a);
|
const overlaps = (a, b) => inside(a, b) || inside(b, a);
|
||||||
|
|
||||||
function configuredDataRoot(home) {
|
function configuredDataRoot(home, name = "dev") {
|
||||||
try {
|
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);
|
if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home);
|
||||||
} catch {
|
} catch {
|
||||||
// An unreadable config adds no location; the default data root still applies.
|
// An unreadable config adds no location; the default data root still applies.
|
||||||
@@ -54,6 +55,21 @@ function configuredDataRoot(home) {
|
|||||||
return null;
|
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
|
// Registrations under <dataRoot>/seats/<layout>/<seat>/registration.json. An
|
||||||
// unreadable one adds nothing; the data root itself is protected anyway.
|
// unreadable one adds nothing; the data root itself is protected anyway.
|
||||||
function registrationsUnder(dataRoot) {
|
function registrationsUnder(dataRoot) {
|
||||||
@@ -111,6 +127,14 @@ export class LiveSessionGuard {
|
|||||||
protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" });
|
protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" });
|
||||||
const configured = configuredDataRoot(h);
|
const configured = configuredDataRoot(h);
|
||||||
if (configured) protectedPaths.push({ path: configured, why: "the configured data root" });
|
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" });
|
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);
|
const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path);
|
||||||
|
|||||||
@@ -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
|
pinning. Status reads effective WorkingDirectory, including drop-ins, and
|
||||||
prints the tree and copy id or checkout path.
|
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:
|
Every live step requires Mos's go after the SetSpark freeze lifts. Re-pin:
|
||||||
make the new pushed copy with stack-copy.sh; run
|
make the new pushed copy with stack-copy.sh; run
|
||||||
`node <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`;
|
`node <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ StartLimitBurst=5
|
|||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=@REPO@
|
WorkingDirectory=@REPO@
|
||||||
Environment=PATH=@PATH@
|
Environment=PATH=@PATH@
|
||||||
|
@CONFIG_ENV@
|
||||||
# A supervised run first clears a lock left by a crash or a reboot, and
|
# 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
|
# 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
|
# is never retried: the brake belongs to the operator. This happens inside
|
||||||
|
|||||||
@@ -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);
|
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 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 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'));
|
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);
|
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);
|
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);
|
||||||
|
|||||||
@@ -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;
|
names the copy and refactor drift. Files and directories are read-only;
|
||||||
copies are retained. Existing Console-only copies are not migrated or pruned.
|
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
|
The single update and rollback sequence is
|
||||||
[TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
|
[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.
|
A bus restart ends managed sessions, so no PM turn may be in flight.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ StartLimitBurst=5
|
|||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=@REPO@
|
WorkingDirectory=@REPO@
|
||||||
Environment=PATH=@PATH@
|
Environment=PATH=@PATH@
|
||||||
|
@CONFIG_ENV@
|
||||||
# The server binds 127.0.0.1 only. Usage, config and listen refusals
|
# 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
|
# (a port already in use included) exit 2 and are not retried; fix the
|
||||||
# flags or free the port, then start it again.
|
# flags or free the port, then start it again.
|
||||||
|
|||||||
@@ -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) => {
|
test('copy writes nothing when an archived path is missing at the commit', async (t) => {
|
||||||
const { dir, repo, script } = fixtureRepo(t);
|
const { dir, repo, script } = fixtureRepo(t);
|
||||||
const copies = join(dir, 'copies');
|
const copies = join(dir, 'copies');
|
||||||
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
|
// Row 190: the entry point runs scripts/mosaic-config.mjs to select the
|
||||||
git(repo, 'commit', '-q', '-m', 'drop the config check');
|
// 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'));
|
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
|
||||||
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
|
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
|
||||||
assert.equal(r.status, 1);
|
assert.equal(r.status, 1);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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) {
|
function filled(flags, root = REPO) {
|
||||||
const base = '/usr/local/bin:/usr/bin:/bin';
|
const base = '/usr/local/bin:/usr/bin:/bin';
|
||||||
const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`;
|
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
|
// A loopback port with nothing listening, so status's /healthz request
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#
|
#
|
||||||
# Creates ~/.config/mosaic-dev/config.json (or $MOSAIC_CONFIG) only when
|
# Creates ~/.config/mosaic-dev/config.json (or $MOSAIC_CONFIG) only when
|
||||||
# absent. An existing configuration is validated, never modified.
|
# 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:
|
# Normal run paths (build/hello/verify) deliberately do NOT auto-bootstrap:
|
||||||
# missing configuration is an error there, not something to invent.
|
# missing configuration is an error there, not something to invent.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
exec node scripts/mosaic-config.mjs bootstrap
|
exec node scripts/mosaic-config.mjs bootstrap "$@"
|
||||||
+31
-14
@@ -19,12 +19,24 @@
|
|||||||
# (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is
|
# (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is
|
||||||
# kept as given, so a symlink that tracks updates keeps tracking them.
|
# 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
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
|
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
|
||||||
UNIT_NAME="[email protected]"
|
# shellcheck source=scripts/instance.sh
|
||||||
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>"
|
. "$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; }
|
die() { echo "bus-service: $*" >&2; exit 1; }
|
||||||
usage() { echo "$USAGE" >&2; exit 4; }
|
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
|
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
|
||||||
local claude
|
local claude
|
||||||
claude="$(claude_line)" || exit 1
|
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"
|
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.
|
# Any failed read refuses before a write, including --no-reload installations.
|
||||||
install_guard() {
|
install_guard() {
|
||||||
local out unit file
|
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
|
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
|
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"
|
done <<< "$out"
|
||||||
# systemctl show rejects a template name. The lookup-path scan also
|
# 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
|
case "$directory" in /*) ;; *) die "refusing: invalid systemd unit path $directory; nothing was changed" ;; esac
|
||||||
[ -e "$directory" ] || [ -L "$directory" ] || continue
|
[ -e "$directory" ] || [ -L "$directory" ] || continue
|
||||||
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed"
|
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed"
|
||||||
# * includes the empty instance, i.e. [email protected].
|
# * includes the empty instance, i.e. [email protected] (or
|
||||||
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
|
# mosaic-bus-NAME@.service.d). The globs come from scripts/instance.sh.
|
||||||
|
local glob dropdirs=()
|
||||||
|
# shellcheck disable=SC2206
|
||||||
|
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
|
||||||
|
for dropdir in "${dropdirs[@]}"; do
|
||||||
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
|
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
|
||||||
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed"
|
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed"
|
||||||
for file in "$dropdir"/*.conf; do
|
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
|
mkdir -m 0700 -p <dataRoot>/notify/<business> the notifier refuses a looser directory
|
||||||
write <dataRoot>/notify/<business>/notify.json, mode 0600:
|
write <dataRoot>/notify/<business>/notify.json, mode 0600:
|
||||||
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
|
{"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 enable --now ${BUS_TEMPLATE%.service}<business> start now and at login
|
||||||
systemctl --user status mosaic-bus@<business>
|
systemctl --user status ${BUS_TEMPLATE%.service}<business>
|
||||||
journalctl --user -u mosaic-bus@<business> -f the host's log
|
journalctl --user -u ${BUS_TEMPLATE%.service}<business> -f the host's log
|
||||||
systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
|
systemctl --user stop ${BUS_TEMPLATE%.service}<business> SIGTERM; restartable
|
||||||
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
||||||
MSG
|
MSG
|
||||||
}
|
}
|
||||||
@@ -174,7 +191,7 @@ uninstall_unit() {
|
|||||||
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
|
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
|
||||||
local target="$UNIT_DIR/$UNIT_NAME"
|
local target="$UNIT_DIR/$UNIT_NAME"
|
||||||
local out active
|
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")"
|
active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")"
|
||||||
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
|
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
|
||||||
if [ -f "$target" ]; then
|
if [ -f "$target" ]; then
|
||||||
@@ -193,7 +210,7 @@ status_unit() {
|
|||||||
[ $# -eq 1 ] || usage
|
[ $# -eq 1 ] || usage
|
||||||
local business="$1"
|
local business="$1"
|
||||||
case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac
|
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 "unit: $unit"
|
||||||
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
||||||
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
|
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
|
||||||
|
|||||||
+20
-10
@@ -14,12 +14,21 @@
|
|||||||
# remove the unit; refuses while an instance is active
|
# remove the unit; refuses while an instance is active
|
||||||
# status <binding> unit state, STOP and run.lock for one binding
|
# 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
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
|
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
|
||||||
UNIT_NAME="[email protected]"
|
. "$REPO/scripts/instance.sh"
|
||||||
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>"
|
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; }
|
die() { echo "discord-service: $*" >&2; exit 1; }
|
||||||
usage() { echo "$USAGE" >&2; exit 4; }
|
usage() { echo "$USAGE" >&2; exit 4; }
|
||||||
@@ -48,7 +57,7 @@ render() {
|
|||||||
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
|
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
|
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
|
||||||
local unit
|
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
|
if [ -n "$root" ]; then
|
||||||
local node
|
local node
|
||||||
node="$(command -v node)"
|
node="$(command -v node)"
|
||||||
@@ -101,10 +110,10 @@ install_unit() {
|
|||||||
fi
|
fi
|
||||||
cat <<MSG
|
cat <<MSG
|
||||||
next, for one binding:
|
next, for one binding:
|
||||||
systemctl --user enable --now mosaic-discord@<binding> start now and at login
|
systemctl --user enable --now $UNIT_PREFIX<binding> start now and at login
|
||||||
systemctl --user status mosaic-discord@<binding>
|
systemctl --user status $UNIT_PREFIX<binding>
|
||||||
journalctl --user -u mosaic-discord@<binding> -f the connector's log
|
journalctl --user -u $UNIT_PREFIX<binding> -f the connector's log
|
||||||
systemctl --user stop mosaic-discord@<binding> SIGTERM; restartable
|
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
|
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)}
|
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
||||||
MSG
|
MSG
|
||||||
@@ -115,7 +124,7 @@ uninstall_unit() {
|
|||||||
local target="$UNIT_DIR/$UNIT_NAME"
|
local target="$UNIT_DIR/$UNIT_NAME"
|
||||||
if [ "$RELOAD" = 1 ]; then
|
if [ "$RELOAD" = 1 ]; then
|
||||||
local active
|
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"
|
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
|
||||||
fi
|
fi
|
||||||
if [ -f "$target" ]; then
|
if [ -f "$target" ]; then
|
||||||
@@ -134,7 +143,7 @@ status_unit() {
|
|||||||
[ $# -eq 1 ] || usage
|
[ $# -eq 1 ] || usage
|
||||||
local binding="$1"
|
local binding="$1"
|
||||||
case "$binding" in ''|*[!A-Za-z0-9_-]*) die "binding name must be [A-Za-z0-9_-]+" ;; esac
|
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 "unit: $unit"
|
||||||
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
||||||
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
|
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
|
||||||
@@ -151,6 +160,7 @@ status_unit() {
|
|||||||
echo " checkout: $working"
|
echo " checkout: $working"
|
||||||
fi
|
fi
|
||||||
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
|
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
|
||||||
|
[ -z "$MOSAIC_UNIT_SUFFIX" ] || config="$MOSAIC_INSTANCE_CONFIG"
|
||||||
local data_root
|
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)"
|
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
|
if [ -z "$data_root" ]; then
|
||||||
|
|||||||
@@ -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="mosaic-bus$s@.service"
|
||||||
|
BUS_GLOB="mosaic-bus$s@*.service"
|
||||||
|
CONSOLE_UNIT="mosaic-webui$s.service"
|
||||||
|
DISCORD_TEMPLATE="mosaic-discord$s@.service"
|
||||||
|
WRAPPER_NAME="mosaic$s"
|
||||||
|
if [ -z "$s" ]; then
|
||||||
|
BUS_DROPIN_GLOBS=('mosaic-bus@*.service.d' 'mosaic-.service.d' 'service.d')
|
||||||
|
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
|
||||||
|
INSTANCE_SED=(-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g")
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# systemd expands % and splits Environment= on spaces, and the sed
|
||||||
|
# expressions use |, so a named instance's paths keep to a plain set.
|
||||||
|
case "$MOSAIC_INSTANCE_CONFIG" in
|
||||||
|
*[!A-Za-z0-9/._+-]*)
|
||||||
|
echo "instance: config path contains a character a unit cannot carry: $MOSAIC_INSTANCE_CONFIG" >&2
|
||||||
|
exit 4 ;;
|
||||||
|
esac
|
||||||
|
export MOSAIC_CONFIG="${MOSAIC_CONFIG:-$MOSAIC_INSTANCE_CONFIG}"
|
||||||
|
INSTANCE_FLAGS=(--instance "$MOSAIC_INSTANCE")
|
||||||
|
INSTANCE_RESERVED=(7330 7332 7333)
|
||||||
|
BUS_DROPIN_GLOBS=("mosaic-bus$s@*.service.d" 'mosaic-bus-.service.d' 'mosaic-.service.d' 'service.d')
|
||||||
|
SESSION_ENV="$MOSAIC_INSTANCE_CONFIG_DIR/secrets/claude-session.env"
|
||||||
|
INSTANCE_SED=(
|
||||||
|
-e "s|^@CONFIG_ENV@\$|# Stack instance $MOSAIC_INSTANCE (row 190): the system config its processes read.\\nEnvironment=MOSAIC_CONFIG=$MOSAIC_INSTANCE_CONFIG|"
|
||||||
|
-e "s|@SESSION_ENV@|$SESSION_ENV|g"
|
||||||
|
-e "s|^\\(Description=.*\\)\$|\\1 [$MOSAIC_INSTANCE]|"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# instance_port_holder DIR PORT: the installed Console unit in DIR, other than
|
||||||
|
# this instance's, whose ExecStart takes PORT as --port or --tester-port.
|
||||||
|
# A unit with no --port holds 7332, serve's default. Prints the unit and
|
||||||
|
# returns 0, or prints nothing and returns 1. Unit files are host
|
||||||
|
# configuration, so reading another instance's is not reading its state.
|
||||||
|
instance_port_holder() {
|
||||||
|
local dir="$1" want="$2" file unit line tok next port
|
||||||
|
want="$((10#$want))"
|
||||||
|
for file in "$dir"/mosaic-webui.service "$dir"/mosaic-webui-*.service; do
|
||||||
|
[ -f "$file" ] || continue
|
||||||
|
unit="${file##*/}"
|
||||||
|
[ "$unit" != "$CONSOLE_UNIT" ] || continue
|
||||||
|
line="$(sed -n 's/^ExecStart=//p' "$file" | tail -n 1)"
|
||||||
|
port=7332 next=""
|
||||||
|
local -a ports=() toks=()
|
||||||
|
read -r -a toks <<< "$line"
|
||||||
|
for tok in ${toks[@]+"${toks[@]}"}; do
|
||||||
|
case "$next" in
|
||||||
|
--port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && port="$((10#$tok))" ;;
|
||||||
|
--tester-port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && ports+=("$((10#$tok))") ;;
|
||||||
|
esac
|
||||||
|
next="$tok"
|
||||||
|
done
|
||||||
|
ports+=("$port")
|
||||||
|
for port in "${ports[@]}"; do
|
||||||
|
if [ "$port" = "$want" ]; then echo "$unit"; return 0; fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
+258
-33
@@ -8,13 +8,30 @@
|
|||||||
* validate Load and strictly validate; print resolved config on stdout.
|
* validate Load and strictly validate; print resolved config on stdout.
|
||||||
* env Print shell-safe exports for launcher scripts:
|
* env Print shell-safe exports for launcher scripts:
|
||||||
* MOSAIC_DATA_ROOT, MOSAIC_PROVIDER, MOSAIC_MODEL.
|
* 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:
|
* Exit codes:
|
||||||
* 0 success
|
* 0 success
|
||||||
* 2 configuration exists but is invalid (never modified by this tool)
|
* 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):
|
* Invariants (docs/plans/2026-09-02_atomic-mosaic-foundation.md):
|
||||||
* - Existing configuration is never overwritten.
|
* - Existing configuration is never overwritten.
|
||||||
@@ -32,16 +49,112 @@ const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
|
|||||||
const SUPPORTED_BACKENDS = new Set(["docker"]);
|
const SUPPORTED_BACKENDS = new Set(["docker"]);
|
||||||
const SUPPORTED_ADAPTERS = new Set(["pi", "mock"]); // mock: test-only, see adapters/README.md
|
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}$/;
|
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
|
||||||
|
// Row 190: stack instance names. No hyphen, so systemd's prefix drop-ins
|
||||||
|
// for mosaic-bus-NAME@ stay a fixed list and no name prefixes another's units.
|
||||||
|
const INSTANCE_PATTERN = /^[a-z][a-z0-9]{1,15}$/;
|
||||||
|
const DEFAULT_INSTANCE = "dev";
|
||||||
|
const MARKER = "instance.json";
|
||||||
|
|
||||||
function fail(exitCode, message) {
|
class Failure extends Error {
|
||||||
process.stderr.write(`mosaic-config: ${message}\n`);
|
constructor(exitCode, message) {
|
||||||
process.exit(exitCode);
|
super(message);
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function configPath() {
|
function fail(exitCode, message) {
|
||||||
|
throw new Failure(exitCode, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function instancePath(name) {
|
||||||
|
return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function realOrNull(file) {
|
||||||
|
try {
|
||||||
|
return fs.realpathSync(file);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The config file for a selection. Without --instance, as before. With it,
|
||||||
|
// NAME's file; a $MOSAIC_CONFIG beside it must be the same file.
|
||||||
|
function configPath(instance) {
|
||||||
|
if (instance === null) {
|
||||||
return process.env.MOSAIC_CONFIG
|
return process.env.MOSAIC_CONFIG
|
||||||
? path.resolve(process.env.MOSAIC_CONFIG)
|
? path.resolve(process.env.MOSAIC_CONFIG)
|
||||||
: path.join(os.homedir(), ".config", "mosaic-dev", "config.json");
|
: 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) {
|
function readRaw(file) {
|
||||||
@@ -159,10 +272,11 @@ function validate(document, file) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function load(file) {
|
function load(file, instance = null) {
|
||||||
const raw = readRaw(file);
|
const raw = readRaw(file);
|
||||||
if (raw === null) {
|
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;
|
let document;
|
||||||
try {
|
try {
|
||||||
@@ -177,55 +291,159 @@ function shellQuote(value) {
|
|||||||
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
return `'${String(value).replaceAll("'", `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
function defaultConfig(dataRoot) {
|
||||||
|
return {
|
||||||
configVersion: 1,
|
configVersion: 1,
|
||||||
environment: "development",
|
environment: "development",
|
||||||
dataRoot: path.join(os.homedir(), ".mosaic-dev"),
|
dataRoot,
|
||||||
execution: {
|
execution: {
|
||||||
backend: "docker",
|
backend: "docker",
|
||||||
provider: "zai",
|
provider: "zai",
|
||||||
model: "glm-5.3-flash",
|
model: "glm-5.3-flash",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const operation = process.argv[2];
|
const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
|
||||||
const file = configPath();
|
|
||||||
|
|
||||||
switch (operation) {
|
// 'wx': creation is exclusive; an existing file is never overwritten, and a
|
||||||
case "bootstrap": {
|
// symbolic link at the path is never followed.
|
||||||
if (fs.existsSync(file)) {
|
function createExclusive(file, text, label = 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;
|
let fd;
|
||||||
try {
|
try {
|
||||||
// 'wx': creation is exclusive; an existing file is never overwritten.
|
|
||||||
fd = fs.openSync(file, "wx", 0o644);
|
fd = fs.openSync(file, "wx", 0o644);
|
||||||
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
|
fs.writeFileSync(fd, text);
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "EEXIST") {
|
if (error.code === "EEXIST") return false;
|
||||||
load(file);
|
fail(1, `unable to create ${label}: ${error.message}`);
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
fail(1, `unable to create configuration: ${error.message}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (fd !== undefined) fs.closeSync(fd);
|
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);
|
load(file);
|
||||||
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
|
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 {
|
||||||
|
resolved = readRaw(file) === null ? null : load(file);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Failure)) throw error;
|
||||||
|
}
|
||||||
|
if (resolved !== null) {
|
||||||
|
dataRoot = resolved.dataRoot;
|
||||||
|
checkClaim(dataRoot, instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const name = isNamed(instance) ? instance : DEFAULT_INSTANCE;
|
||||||
|
return [
|
||||||
|
`MOSAIC_INSTANCE=${shellQuote(name)}`,
|
||||||
|
`MOSAIC_INSTANCE_CONFIG=${shellQuote(path.resolve(file))}`,
|
||||||
|
`MOSAIC_INSTANCE_CONFIG_DIR=${shellQuote(path.dirname(path.resolve(file)))}`,
|
||||||
|
`MOSAIC_INSTANCE_DATA_ROOT=${shellQuote(dataRoot)}`,
|
||||||
|
`MOSAIC_UNIT_SUFFIX=${shellQuote(isNamed(instance) ? `-${instance}` : "")}`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "validate": {
|
case "validate": {
|
||||||
const resolved = load(file);
|
const resolved = loadSelected(instance);
|
||||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
case "env": {
|
case "env": {
|
||||||
const resolved = load(file);
|
const resolved = loadSelected(instance);
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
[
|
[
|
||||||
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
|
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
|
||||||
@@ -238,6 +456,13 @@ switch (operation) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
case "instance": {
|
||||||
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env)`);
|
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);
|
||||||
}
|
}
|
||||||
+34
-9
@@ -1,20 +1,22 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# One immutable pushed tree for the bus, Console and human CLI (#1579).
|
# One immutable pushed tree for the bus, Console and human CLI (#1579).
|
||||||
# copy creates files only; wrapper is a separately Mos-gated live step.
|
# 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
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
||||||
BIN="$HOME/.npm-global/bin"
|
BIN="$HOME/.npm-global/bin"
|
||||||
BUSINESS=mosaic-stack
|
BUSINESS=mosaic-stack BUSINESS_SET=0
|
||||||
ALLOW_WORKTREE=0
|
ALLOW_WORKTREE=0
|
||||||
MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
|
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; }
|
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; }
|
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
|
parse() { while [ $# -gt 0 ]; do case "$1" in
|
||||||
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
|
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
|
||||||
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$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;;
|
--allow-worktree) [ "$CMD" = copy ] || usage; ALLOW_WORKTREE=1;shift;;
|
||||||
*) usage;; esac; done; plain_path "$COPIES" 'copies directory'; }
|
*) 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"; }
|
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"
|
echo "copied: $target"
|
||||||
}
|
}
|
||||||
wrapper() {
|
wrapper() {
|
||||||
|
[ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; }
|
||||||
plain_path "$BIN" 'bin directory'
|
plain_path "$BIN" 'bin directory'
|
||||||
local root file lease tmp
|
local root file lease tmp
|
||||||
root="$(copy_root)";file="$BIN/mosaic"
|
root="$(copy_root)";file="$BIN/mosaic"
|
||||||
@@ -77,6 +80,22 @@ wrapper() {
|
|||||||
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
|
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
|
||||||
echo "wrapper: $root"
|
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() {
|
unit_root() {
|
||||||
local out load root exec_start
|
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; }
|
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")"
|
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")"
|
||||||
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
|
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
|
||||||
case "$1" in
|
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; };;
|
mosaic-webui*) [[ "$exec_start" == *"$root/packages/webui/src/cli.mjs serve"* ]] || { echo "$root (ExecStart mismatch)";return; };;
|
||||||
esac
|
esac
|
||||||
echo "$root"
|
echo "$root"
|
||||||
}
|
}
|
||||||
check() {
|
check() {
|
||||||
plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id'
|
plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id'
|
||||||
local bus console cli=absent line path
|
local bus console cli=absent line path marker="$MARKER" wfile="$BIN/$WRAPPER_NAME"
|
||||||
bus="$(unit_root "mosaic-bus@$BUSINESS.service")";console="$(unit_root mosaic-webui.service)"
|
[ -z "$MOSAIC_UNIT_SUFFIX" ] || marker="$INSTANCE_MARKER"
|
||||||
if [ -f "$BIN/mosaic" ] && [ ! -L "$BIN/mosaic" ] && [ "$(sed -n '2p' "$BIN/mosaic")" = "$MARKER" ]; then
|
bus="$(unit_root "${BUS_TEMPLATE%.service}$BUSINESS.service")";console="$(unit_root "$CONSOLE_UNIT")"
|
||||||
line="$(tail -n 1 "$BIN/mosaic")"
|
if [ -f "$wfile" ] && [ ! -L "$wfile" ] && [ "$(sed -n '2p' "$wfile")" = "$marker" ]; then
|
||||||
|
line="$(tail -n 1 "$wfile")"
|
||||||
path="${line#exec \"}";path="${path%\" \"\$@\"}"
|
path="${line#exec \"}";path="${path%\" \"\$@\"}"
|
||||||
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
|
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
|
||||||
fi
|
fi
|
||||||
@@ -103,10 +123,15 @@ check() {
|
|||||||
}
|
}
|
||||||
# Sourced (stack-release.sh): define the functions, run nothing.
|
# Sourced (stack-release.sh): define the functions, run nothing.
|
||||||
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
|
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
|
||||||
|
. "$REPO/scripts/instance.sh"
|
||||||
|
instance_args "$@"
|
||||||
|
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
|
||||||
[ $# -gt 0 ] || usage
|
[ $# -gt 0 ] || usage
|
||||||
CMD="$1";shift
|
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
|
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;;
|
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;;
|
-h|--help) usage 0;;
|
||||||
*) usage;;esac
|
*) usage;;esac
|
||||||
+67
-31
@@ -24,11 +24,11 @@ set -euo pipefail
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
REPO="$(pwd)"
|
REPO="$(pwd)"
|
||||||
source scripts/common.sh
|
source scripts/common.sh
|
||||||
|
source scripts/instance.sh
|
||||||
|
|
||||||
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
|
||||||
BIN="$HOME/.npm-global/bin"
|
BIN="$HOME/.npm-global/bin"
|
||||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
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=""
|
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
|
||||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
|
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
|
||||||
|
|
||||||
@@ -41,7 +41,9 @@ usage: stack-release.sh adopt BUSINESS
|
|||||||
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
|
||||||
stack-release.sh status BUSINESS
|
stack-release.sh status BUSINESS
|
||||||
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
|
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
|
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
|
||||||
every table in COUNTED.
|
every table in COUNTED.
|
||||||
USAGE
|
USAGE
|
||||||
@@ -69,7 +71,10 @@ refuse() {
|
|||||||
# Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage).
|
# Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage).
|
||||||
valid_tester_port() {
|
valid_tester_port() {
|
||||||
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1024 ] && [ "$((10#$1))" -le 65535 ] &&
|
[[ "$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() {
|
parse_opts() {
|
||||||
@@ -416,16 +421,17 @@ capture() {
|
|||||||
mkdir -m 0700 "$RS" 2>/dev/null || return 1
|
mkdir -m 0700 "$RS" 2>/dev/null || return 1
|
||||||
STEPS="$RS/steps.log"
|
STEPS="$RS/steps.log"
|
||||||
local u d
|
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; }
|
[ -f "$UNIT_DIR/$u" ] || { say "capture: no $UNIT_DIR/$u"; return 1; }
|
||||||
cp -p "$UNIT_DIR/$u" "$RS/$u" || return 1
|
cp -p "$UNIT_DIR/$u" "$RS/$u" || return 1
|
||||||
done
|
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
|
[ ! -d "$UNIT_DIR/$d" ] || cp -pr "$UNIT_DIR/$d" "$RS/$d" || return 1
|
||||||
done
|
done
|
||||||
[ -f "$BIN/mosaic" ] || { say "capture: no wrapper $BIN/mosaic"; return 1; }
|
[ -f "$BIN/$WRAPPER_NAME" ] || { say "capture: no wrapper $BIN/$WRAPPER_NAME"; return 1; }
|
||||||
cp -p "$BIN/mosaic" "$RS/wrapper" || return 1
|
cp -p "$BIN/$WRAPPER_NAME" "$RS/wrapper" || return 1
|
||||||
stat -c %a "$BIN/mosaic" > "$RS/WRAPPER_MODE" || 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
|
for u in "$BUS_UNIT" "$CONSOLE_UNIT"; do show "$u" > "$RS/show-$u.txt" || return 1; done
|
||||||
sums "$RS" > "$RS/SHA256SUMS" || return 1
|
sums "$RS" > "$RS/SHA256SUMS" || return 1
|
||||||
say "captured the rollback set: $RS"
|
say "captured the rollback set: $RS"
|
||||||
@@ -458,9 +464,9 @@ install_stack() {
|
|||||||
local id="$1" pm="$2" claude="$3"; shift 3
|
local id="$1" pm="$2" claude="$3"; shift 3
|
||||||
local -a pmflag=()
|
local -a pmflag=()
|
||||||
[ "$pm" = 0 ] || pmflag=(--pm)
|
[ "$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/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 --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; 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" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
|
scripts/stack-copy.sh wrapper "$id" "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
|
||||||
say "installed $id"
|
say "installed $id"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +492,7 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$main" "$START_MS" 2>&1)" ||
|
|||||||
node -e '
|
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
|
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
|
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).
|
# 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="$(sed -n 's/^bus: //p' <<< "$out")"
|
||||||
[ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; }
|
[ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; }
|
||||||
@@ -533,10 +539,10 @@ gate() {
|
|||||||
# Puts back the captured unit files, drop-ins and wrapper, checked against SHA256SUMS.
|
# Puts back the captured unit files, drop-ins and wrapper, checked against SHA256SUMS.
|
||||||
put_back() {
|
put_back() {
|
||||||
local u f rel tmp
|
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
|
tmp="$(mktemp "$UNIT_DIR/.$u.XXXXXX")" && cp -p "$RS/$u" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u" || return 1
|
||||||
done
|
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
|
[ -d "$RS/$u" ] || continue
|
||||||
while IFS= read -r -d '' f; do
|
while IFS= read -r -d '' f; do
|
||||||
rel="${f#"$RS/$u/"}"
|
rel="${f#"$RS/$u/"}"
|
||||||
@@ -545,12 +551,12 @@ put_back() {
|
|||||||
tmp="$(mktemp "$UNIT_DIR/$u/.restore.XXXXXX")" && cp -p "$f" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u/$rel" || return 1
|
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 < <(find "$RS/$u" -type f -print0)
|
||||||
done
|
done
|
||||||
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/mosaic" || return 1
|
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 [email protected] "$CONSOLE_UNIT"; do
|
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
|
||||||
[ "$(sha256sum < "$UNIT_DIR/$u" | cut -d' ' -f1)" = "$(sum_of "$RS" "$u")" ] || return 1
|
[ "$(sha256sum < "$UNIT_DIR/$u" | cut -d' ' -f1)" = "$(sum_of "$RS" "$u")" ] || return 1
|
||||||
done
|
done
|
||||||
[ "$(sha256sum < "$BIN/mosaic" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
|
[ "$(sha256sum < "$BIN/$WRAPPER_NAME" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
|
||||||
[ "$(stat -c %a "$BIN/mosaic")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
|
[ "$(stat -c %a "$BIN/$WRAPPER_NAME")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
|
||||||
systemctl --user daemon-reload || return 1
|
systemctl --user daemon-reload || return 1
|
||||||
say "put back the unit files and the wrapper from $RS"
|
say "put back the unit files and the wrapper from $RS"
|
||||||
}
|
}
|
||||||
@@ -607,9 +613,9 @@ lock() {
|
|||||||
|
|
||||||
instance_busy() {
|
instance_busy() {
|
||||||
local out unit load state rest
|
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
|
while read -r unit load state rest; do
|
||||||
[[ "$unit" == mosaic-bus@*.service ]] || continue
|
[[ "$unit" == $BUS_GLOB ]] || continue
|
||||||
if [ "$unit" = "$BUS_UNIT" ]; then
|
if [ "$unit" = "$BUS_UNIT" ]; then
|
||||||
case "$state" in activating|deactivating|reloading) refuse instance-busy "$unit is $state" ;; esac
|
case "$state" in activating|deactivating|reloading) refuse instance-busy "$unit is $state" ;; esac
|
||||||
else
|
else
|
||||||
@@ -621,14 +627,17 @@ instance_busy() {
|
|||||||
# install_guard's drop-in scan (bus-service.sh), done before anything stops. A path it can't read
|
# 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).
|
# refuses here, as install_guard would refuse it after the stop (row 166 item 1).
|
||||||
drop_in_override() {
|
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"
|
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
|
while IFS= read -r directory; do
|
||||||
[ -n "$directory" ] || continue
|
[ -n "$directory" ] || continue
|
||||||
case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac
|
case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac
|
||||||
[ -e "$directory" ] || [ -L "$directory" ] || continue
|
[ -e "$directory" ] || [ -L "$directory" ] || continue
|
||||||
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || refuse drop-in-override "cannot inspect unit path $directory"
|
[ -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
|
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
|
||||||
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir"
|
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir"
|
||||||
for file in "$dropdir"/*.conf; do
|
for file in "$dropdir"/*.conf; do
|
||||||
@@ -684,6 +693,7 @@ console_for_target() {
|
|||||||
[ "$PLAN" = 1 ] || usage
|
[ "$PLAN" = 1 ] || usage
|
||||||
local other=""
|
local other=""
|
||||||
[ "$main" = 7332 ] || other=" and the Console's --port $main"
|
[ "$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"
|
check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other"
|
||||||
fi
|
fi
|
||||||
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
|
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
|
||||||
@@ -813,10 +823,30 @@ proof_file_problem() {
|
|||||||
[ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as"
|
[ "$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.
|
# The item 3 checks after the pointer, shared by activate and rollback, for target $TO.
|
||||||
precheck() {
|
precheck() {
|
||||||
check_target
|
check_target
|
||||||
console_for_target
|
console_for_target
|
||||||
|
ports_unclaimed
|
||||||
instance_busy
|
instance_busy
|
||||||
drop_in_override
|
drop_in_override
|
||||||
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
|
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
|
||||||
@@ -857,7 +887,7 @@ do_adopt() {
|
|||||||
lock
|
lock
|
||||||
[ ! -f "$POINTER" ] || { read_pointer; FROM="$P_COPY"; refuse already-adopted "$POINTER exists (copy $P_COPY)"; }
|
[ ! -f "$POINTER" ] || { read_pointer; FROM="$P_COPY"; refuse already-adopted "$POINTER exists (copy $P_COPY)"; }
|
||||||
local out
|
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="$(sed -n 's/^bus: //p' <<< "$out")"
|
||||||
TO="${TO#"$COPIES/"}"
|
TO="${TO#"$COPIES/"}"
|
||||||
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
|
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
|
||||||
@@ -970,12 +1000,13 @@ plan() {
|
|||||||
plan_commits
|
plan_commits
|
||||||
[ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM"
|
[ "$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"
|
[ "$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"
|
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
|
||||||
echo "$PROOF_PLAN"
|
echo "$PROOF_PLAN"
|
||||||
echo "$SEND_PLAN"
|
echo "$SEND_PLAN"
|
||||||
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
|
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
|
||||||
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
|
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
|
||||||
local r
|
local r
|
||||||
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
|
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
|
||||||
[ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3
|
[ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3
|
||||||
@@ -1047,7 +1078,7 @@ do_activate() {
|
|||||||
pointer_for_business
|
pointer_for_business
|
||||||
run_pm
|
run_pm
|
||||||
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
|
[ "$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
|
precheck
|
||||||
plugin_checks
|
plugin_checks
|
||||||
new_activation
|
new_activation
|
||||||
@@ -1059,7 +1090,7 @@ do_rollback() {
|
|||||||
pointer_for_business
|
pointer_for_business
|
||||||
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
|
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
|
||||||
run_pm
|
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
|
precheck
|
||||||
plugin_checks
|
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"
|
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
|
||||||
@@ -1153,7 +1184,7 @@ do_pm_on() {
|
|||||||
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
|
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
|
||||||
trap 'pm_on_fail signal' INT TERM HUP
|
trap 'pm_on_fail signal' INT TERM HUP
|
||||||
stop_bus || pm_on_fail stop-timeout
|
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"
|
say "installed $BUS_UNIT for $P_COPY with --pm"
|
||||||
start_bus || pm_on_fail start
|
start_bus || pm_on_fail start
|
||||||
gate "$P_COPY" 1 || pm_on_fail "$GATE"
|
gate "$P_COPY" 1 || pm_on_fail "$GATE"
|
||||||
@@ -1173,7 +1204,7 @@ do_status() {
|
|||||||
open="$(interrupted | paste -sd' ' -)"
|
open="$(interrupted | paste -sd' ' -)"
|
||||||
[ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)"
|
[ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)"
|
||||||
echo "check:"
|
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
|
[ -n "$P_COPY" ] || return 0
|
||||||
echo "bus status:"
|
echo "bus status:"
|
||||||
"$COPIES/$P_COPY/scripts/mosaic" bus status 2>&1 | sed 's/^/ /' || true
|
"$COPIES/$P_COPY/scripts/mosaic" bus status 2>&1 | sed 's/^/ /' || true
|
||||||
@@ -1437,6 +1468,7 @@ do_rehearse() {
|
|||||||
local -a reserved=(7332 7333 "$(console_port)")
|
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 = 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
|
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_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"
|
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
|
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
|
||||||
@@ -1489,6 +1521,9 @@ do_rehearse() {
|
|||||||
|
|
||||||
# --- Dispatch ---
|
# --- 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
|
[ $# -gt 0 ] || usage
|
||||||
OP="$1"; shift
|
OP="$1"; shift
|
||||||
ID=""
|
ID=""
|
||||||
@@ -1501,13 +1536,14 @@ esac
|
|||||||
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage
|
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage
|
||||||
parse_opts "$@"
|
parse_opts "$@"
|
||||||
COPIES_DIR="$COPIES"
|
COPIES_DIR="$COPIES"
|
||||||
|
instance_select
|
||||||
load_config
|
load_config
|
||||||
DATA="$MOSAIC_DATA_ROOT"
|
DATA="$MOSAIC_DATA_ROOT"
|
||||||
STATE="$DATA/state"
|
STATE="$DATA/state"
|
||||||
POINTER="$STATE/stack-active.json"
|
POINTER="$STATE/stack-active.json"
|
||||||
LOG="$STATE/stack-activation-log.jsonl"
|
LOG="$STATE/stack-activation-log.jsonl"
|
||||||
STORE="$DATA/bus/bus.sqlite"
|
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=""
|
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
|
||||||
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
|
||||||
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
|
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
|
||||||
|
|||||||
@@ -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 ---
|
# --- the service unit: rendered from the template, never touching systemd here ---
|
||||||
UNITS="$SANDBOX/units"
|
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
|
scripts/discord-service.sh >/dev/null 2>&1
|
||||||
[ $? -eq 4 ]
|
[ $? -eq 4 ]
|
||||||
check "scripts/discord-service.sh without a command exits 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" $?
|
check "status names the copy from fake systemctl effective cwd" $?
|
||||||
grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1"
|
grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1"
|
||||||
check "checkout install explicitly recommends --copy" $?
|
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.
|
# Offline wrapper entrypoint. Only a synthetic token/binding/config are used.
|
||||||
node - "$SANDBOX" <<'EOF_OFFLINE'
|
node - "$SANDBOX" <<'EOF_OFFLINE'
|
||||||
|
|||||||
+62
-17
@@ -62,12 +62,27 @@
|
|||||||
# --no-reload: a unit rewritten under a running Console would take the new
|
# --no-reload: a unit rewritten under a running Console would take the new
|
||||||
# ExecStart at its next Restart=on-failure, with nobody starting it.
|
# 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
|
set -euo pipefail
|
||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
|
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
|
||||||
UNIT_NAME="mosaic-webui.service"
|
# shellcheck source=scripts/instance.sh
|
||||||
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]
|
. "$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]
|
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.
|
--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."
|
--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
|
RELOAD=1
|
||||||
DIR_FLAGS=0
|
DIR_FLAGS=0
|
||||||
PORT=7332
|
PORT=7332
|
||||||
|
PORT_SET=0
|
||||||
TESTER_PORT=""
|
TESTER_PORT=""
|
||||||
TESTER_SET=0
|
TESTER_SET=0
|
||||||
BUSINESS=""
|
BUSINESS=""
|
||||||
@@ -104,7 +120,7 @@ parse_flags() {
|
|||||||
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
|
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
|
||||||
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
|
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
|
||||||
--no-reload) RELOAD=0; DIR_FLAGS=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 ;;
|
--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 ;;
|
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
|
||||||
--board) [ $# -ge 2 ] || usage; BOARD="$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
|
[ "$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() {
|
tester_port_usage() {
|
||||||
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
|
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
|
||||||
exit 4
|
exit 4
|
||||||
@@ -249,7 +290,7 @@ render() {
|
|||||||
local flags
|
local flags
|
||||||
flags="$(serve_flags)" || exit 1
|
flags="$(serve_flags)" || exit 1
|
||||||
[ -z "$COPY_ID" ] || flags="--checkout $REPO $flags"
|
[ -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
|
# 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
|
# 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
|
# it would call has an ExecStart path inside the copy. With --business the
|
||||||
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
|
# 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.
|
# refuses with one line.
|
||||||
bus_on_copy() {
|
bus_on_copy() {
|
||||||
local copy="$1" units unit out load paths p working
|
local copy="$1" units unit out load paths p working
|
||||||
if [ -n "$BUSINESS" ]; then
|
if [ -n "$BUSINESS" ]; then
|
||||||
units="mosaic-bus@$BUSINESS.service"
|
units="$BUS_UNIT_PREFIX$BUSINESS.service"
|
||||||
else
|
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"
|
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 '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
|
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 mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
|
[ -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
|
fi
|
||||||
for unit in $units; do
|
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"
|
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 "$@"
|
parse_flags "$@"
|
||||||
canonical
|
canonical
|
||||||
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
|
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
|
||||||
|
instance_ports_ok
|
||||||
local unit
|
local unit
|
||||||
unit="$(render)" || exit 1
|
unit="$(render)" || exit 1
|
||||||
|
ports_unclaimed
|
||||||
bus_on_copy "$COPIES/$COPY_ID"
|
bus_on_copy "$COPIES/$COPY_ID"
|
||||||
# N1: a running unit would take the new ExecStart at its next restart.
|
# N1: a running unit would take the new ExecStart at its next restart.
|
||||||
local state
|
local state
|
||||||
state="$(running)" || exit 1
|
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"
|
mkdir -p "$UNIT_DIR"
|
||||||
local target="$UNIT_DIR/$UNIT_NAME" tmp
|
local target="$UNIT_DIR/$UNIT_NAME" tmp
|
||||||
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
|
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
|
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
|
starts and restarts it, or Mos under lead decision 102 (sends then enter the
|
||||||
trail as Jason); no other agent does:
|
trail as Jason); no other agent does:
|
||||||
systemctl --user enable --now mosaic-webui start now and at login
|
systemctl --user enable --now ${UNIT_NAME%.service} start now and at login
|
||||||
systemctl --user status mosaic-webui
|
systemctl --user status ${UNIT_NAME%.service}
|
||||||
journalctl --user -u mosaic-webui -f the Console's log
|
journalctl --user -u ${UNIT_NAME%.service} -f the Console's log
|
||||||
systemctl --user stop mosaic-webui SIGTERM; restartable
|
systemctl --user stop ${UNIT_NAME%.service} SIGTERM; restartable
|
||||||
scripts/webui-service.sh status unit state and /healthz
|
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)}
|
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
||||||
MSG
|
MSG
|
||||||
}
|
}
|
||||||
@@ -342,7 +386,7 @@ uninstall_unit() {
|
|||||||
# N3: checked with --no-reload too, which skips only daemon-reload.
|
# N3: checked with --no-reload too, which skips only daemon-reload.
|
||||||
local state
|
local state
|
||||||
state="$(running)" || exit 1
|
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
|
if [ -f "$target" ]; then
|
||||||
rm -f "$target"
|
rm -f "$target"
|
||||||
echo "removed: $target"
|
echo "removed: $target"
|
||||||
@@ -426,6 +470,7 @@ case "$cmd" in
|
|||||||
parse_flags "$@"
|
parse_flags "$@"
|
||||||
[ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
|
[ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
|
||||||
{ [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
|
{ [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
|
||||||
|
instance_ports_ok
|
||||||
render ;;
|
render ;;
|
||||||
install) install_unit "$@" ;;
|
install) install_unit "$@" ;;
|
||||||
uninstall) uninstall_unit "$@" ;;
|
uninstall) uninstall_unit "$@" ;;
|
||||||
|
|||||||
Reference in new issue
Block a user