Files
stack/agents/rocko/work/queue-77/build.patch
T
jason.woltjeandClaude Opus 5.5 436d581118 rocko: row 77 (#1571) packet, round 1; BUILD-LOG and SESSIONS appends
Rocko's work committed by Sage (Rocko has no git authority). Candidate
manifest sha256 4927de66, base 27a1a2bb.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:39:36 -05:00

1185 lines
73 KiB
Diff

diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 89c85715..7e39d3a8 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -241,8 +241,9 @@ Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
## Console service (`scripts/webui-service.sh`)
```bash
-scripts/webui-service.sh render [FLAGS] # print the unit
-scripts/webui-service.sh install [--dir DIR] [--no-reload] [FLAGS]
+scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
+scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit
+scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
scripts/webui-service.sh status [--dir DIR]
# FLAGS: [--port N] [--business ID] [--board URL]
@@ -257,19 +258,49 @@ preview set and is refused otherwise (exit 1, nothing written); ExecStart
then ends with `--send`, and the Console needs the system config at start
(`--send needs the bus configured`, exit 2). Without `--send` the unit is
read-only. The template is
-`packages/webui/systemd/mosaic-webui.service.in`. `install` writes a temp
-file, renames it and runs `systemctl --user daemon-reload`; `uninstall`
-refuses while the unit is active; `status` prints the unit state, whether
-the installed unit sends, and `GET /healthz` on the installed port. Each
-value is held to a plain
-character set the unit can carry (no space, `%`, `$`, quote, backslash,
-`&` or `|`) and refused otherwise. The unit sets no `MOSAIC_*`, `CLAUDE*`,
-`CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a port
-already in use) is not restarted.
+`packages/webui/systemd/mosaic-webui.service.in`.
+
+The unit runs a pinned copy, not the working checkout (row 77, #1571).
+`copy COMMIT` resolves COMMIT to its full id, refuses one not reachable
+from `refs/heads/refactor`, and writes `git archive` of the paths the
+Console loads into `DIR/<id>/`, by default
+`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console/<id>/`. It writes a
+temp directory beside the target, makes every file read-only, then renames
+it, so the copy's path is always whole. A copy that exists is left alone
+(`exists:`). `install --copy ID` and `render --copy ID` point
+`WorkingDirectory` and ExecStart at the copy and pass serve
+`--checkout <this checkout>`, where the queue read still runs; `RELEASE`,
+the roles, the config check and the bus child come from the copy. `install`
+refuses without `--copy`, or when the copy is missing. `render` without
+`--copy` prints the unit for the checkout, as before.
+
+`install` writes a temp file, renames it and runs
+`systemctl --user daemon-reload`. `install` and `uninstall` refuse while the
+unit is in any state but inactive or failed (`systemctl --user list-units`),
+and say Jason or Mos stops it first; `--no-reload` skips only the
+daemon-reload, never that check. If the state read itself fails they refuse
+with one line naming the call, and change nothing. `copy` and `install`
+refuse outside the canonical checkout (a `.git` directory, not a worktree's
+`.git` file); `--allow-worktree` overrides that for the tests only. `status`
+prints the unit state, the copy id it runs, whether `refactor` points at
+that commit or another, whether the installed unit sends, and
+`GET /healthz` on the installed port. It changes nothing.
+
+Each value is held to a plain character set the unit can carry (no space,
+`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
+with a letter or digit; anything else is refused. With `--auth-header
+X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik
+uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`,
+`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
+port already in use) is not restarted.
+
+The update step: `copy` the commit, stop the unit, `install --copy ID` with
+the same flags, start it. Rollback is the same steps with the earlier id.
+Copies are kept until someone removes them; nothing prunes them.
There is no start, stop, enable or restart verb. The Console reads the bus
through the human transport, so whoever starts it holds the human's
-capability. Jason starts and restarts it
+capability. Jason starts, stops and restarts it
(`systemctl --user enable --now mosaic-webui`), and so may Mos under lead
decision 102, which accepts that sends from a Console Mos started enter the
trail as Jason (`human-cli`). Any other agent starting it, even through
diff --git a/packages/webui/README.md b/packages/webui/README.md
index 4a7cf535..74b3b5e7 100644
--- a/packages/webui/README.md
+++ b/packages/webui/README.md
@@ -110,6 +110,17 @@ system config at start: without it serve refuses with `--send needs the bus
configured` and exits 2, which the unit doesn't restart. `status` says
whether the installed unit sends.
+The unit runs a read-only pinned copy of the Console's paths, not the
+working checkout (row 77). `scripts/webui-service.sh copy COMMIT` makes one
+from a commit on `refactor`, and `install --copy ID` points the unit at it,
+with serve's `--checkout` naming the checkout, where the queue read still
+runs. The update step is `copy`, stop, `install --copy ID` with the same
+flags, start; rollback is the same with the earlier id, and copies are kept.
+The stop and start are Jason's or Mos's: `install` and `uninstall` refuse
+while the unit runs. With `--auth-header X-authentik-uid` the allowed user
+must be the 64-character Authentik uid (lead decision 101). `status` names
+the copy and whether `refactor` has moved past it.
+
## Message a role (row 76, #1570)
```sh
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
index 618a2cf1..de03a5ee 100644
--- a/packages/webui/src/cli.mjs
+++ b/packages/webui/src/cli.mjs
@@ -5,9 +5,11 @@ import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
+import { statSync } from 'node:fs';
+import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
-const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]';
+const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
@@ -23,6 +25,14 @@ try {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
options.port = Number(value);
} else if (flag === '--board') options.board = value;
+ // Row 77 (#1571): the canonical checkout the queue read runs from, when
+ // serve runs from a pinned copy; everything else loads from the copy.
+ else if (flag === '--checkout') {
+ let dir = false;
+ try { dir = isAbsolute(value) && statSync(value).isDirectory(); } catch { /* refused below */ }
+ if (!dir) throw new Error('--checkout must be the absolute path of a directory');
+ options.checkout = value;
+ }
else if (flag === '--business') {
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
options.business = value;
@@ -54,10 +64,11 @@ try {
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
- const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
+ const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send });
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
- console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${previewLine}\nCtrl-C stops this server.`);
+ const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
+ console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
} catch (err) {
console.error(`refused: ${err.message}`);
diff --git a/packages/webui/src/reads.mjs b/packages/webui/src/reads.mjs
index 01224d15..3d745232 100644
--- a/packages/webui/src/reads.mjs
+++ b/packages/webui/src/reads.mjs
@@ -158,14 +158,16 @@ export function businessView(b, { system = null, now = Date.now() } = {}) {
}
// --------------------------------------------------------------- all three
-// `system` is loadSystem() output or null. Returns { queue, row, business,
+// `system` is loadSystem() output or null. `queueRoot` is where the queue
+// read runs, `root` otherwise (RELEASE); serve --checkout sets it apart
+// when serve runs from a pinned copy (row 77, #1571). Returns { queue, row, business,
// settings }; each resolves to a redacted body or throws a ReadError whose
// message is redacted.
-export function consoleReads({ root, system = null, configDir = null, rolesDir, business = null, env = process.env, queueTimeoutMs = 30000, now = () => Date.now() }) {
+export function consoleReads({ root, queueRoot = root, system = null, configDir = null, rolesDir, business = null, env = process.env, queueTimeoutMs = 30000, now = () => Date.now() }) {
const dataRoot = system?.dataRoot ?? null;
const redact = redactor({ configDir, dataRoot });
const fail = (code, message) => new ReadError(code, redact(message));
- const queue = queueReader({ root, env, timeoutMs: queueTimeoutMs });
+ const queue = queueReader({ root: queueRoot, env, timeoutMs: queueTimeoutMs });
const readQueue = async () => {
try { return await queue(); } catch (err) { throw err instanceof ReadError ? fail(err.code, err.message) : fail('read-failed', 'the queue read failed'); }
};
diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in
index 081bd963..6049de0b 100644
--- a/packages/webui/systemd/mosaic-webui.service.in
+++ b/packages/webui/systemd/mosaic-webui.service.in
@@ -1,13 +1,16 @@
# Mosaic Console: `node packages/webui/src/cli.mjs serve`, one instance.
# Rendered by scripts/webui-service.sh from packages/webui/systemd/; @REPO@,
# @PATH@, @NODE@ and the serve flags below are filled in at install time.
-# Edit the template and reinstall; do not edit the installed copy.
+# @REPO@ is the read-only copy `install --copy ID` names, and the flags
+# pass the canonical checkout as --checkout for the queue read (row 77).
+# Edit the template and reinstall; do not edit the installed unit.
#
# The Console reads the bus through the human transport, so whoever starts
-# this unit holds the human's capability. Jason starts it from his own
-# shell (`systemctl --user enable --now mosaic-webui`); an agent starting
-# it works around the human proof (lead decisions 62 and 94). The unit sets
-# no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.
+# this unit holds the human's capability. Jason starts and restarts it
+# (`systemctl --user enable --now mosaic-webui`), and so may Mos under lead
+# decision 102, which accepts that sends then enter the trail as Jason. Any
+# other agent starting it works around the human proof (lead decisions 62
+# and 94). The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.
[Unit]
Description=Mosaic Console
Documentation=file://@REPO@/packages/webui/README.md
diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs
new file mode 100644
index 00000000..cab7f52b
--- /dev/null
+++ b/packages/webui/tests/pinned-copy.test.mjs
@@ -0,0 +1,208 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { execFileSync, spawn } from 'node:child_process';
+import { once } from 'node:events';
+import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { fixture, tmp } from '../../cli/tests/helpers.mjs';
+import { consoleReads } from '../src/reads.mjs';
+
+// Row 77 (#1571, lead decision 112): `scripts/webui-service.sh copy` and
+// serve from the copy it writes. The copy comes from a fixture repository
+// holding this tree's packages, scripts and roles, so a path the Console
+// loads that the script's list leaves out fails the serve test here.
+// Nothing here reaches systemd: copy makes no systemctl call.
+const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, '');
+const NODE_DIR = dirname(process.execPath);
+const GIT = ['-c', 'user.name=fixture', '-c', '[email protected]', '-c', 'commit.gpgsign=false', '-c', 'core.hooksPath=/dev/null'];
+
+const git = (cwd, ...args) => execFileSync('git', [...GIT, '-C', cwd, ...args], { encoding: 'utf8' }).trim();
+
+// A canonical checkout (a .git directory) on branch refactor, with this
+// tree's files, the uncommitted ones included, and a RELEASE of its own.
+function fixtureRepo(t) {
+ const dir = tmp(t, 'webui-copy-');
+ const repo = join(dir, 'repo');
+ const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
+ for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
+ writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
+ git(dir, 'init', '-q', '-b', 'refactor', repo);
+ git(repo, 'add', '-A');
+ git(repo, 'commit', '-q', '-m', 'fixture');
+ return { dir, repo, script: join(repo, 'scripts', 'webui-service.sh'), head: git(repo, 'rev-parse', 'HEAD') };
+}
+
+async function run(dir, script, args) {
+ const env = { PATH: `${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
+ const child = spawn(script, args, { env });
+ let stdout = '', stderr = '';
+ child.stdout.on('data', (d) => { stdout += d; });
+ child.stderr.on('data', (d) => { stderr += d; });
+ const [status] = await once(child, 'close');
+ return { status, stdout, stderr };
+}
+
+function filesUnder(root) {
+ const out = [];
+ for (const entry of readdirSync(root, { withFileTypes: true, recursive: true })) if (entry.isFile()) out.push(join(entry.parentPath, entry.name));
+ return out;
+}
+
+test('copy writes the Console paths at the full id, read-only, and leaves an existing copy alone', async (t) => {
+ const { dir, repo, script, head } = fixtureRepo(t);
+ const copies = join(dir, 'copies');
+ const target = join(copies, head);
+ const r = await run(dir, script, ['copy', head.slice(0, 12), '--copies', copies]);
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
+ assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
+ assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'packages', 'roles', 'scripts']);
+ assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic-config.mjs']);
+ assert.ok(!existsSync(join(target, 'packages', 'queue')), 'the queue read runs from the checkout');
+ assert.equal(readFileSync(join(target, 'RELEASE'), 'utf8'), '0.0.0-fixture\n');
+ const files = filesUnder(target);
+ assert.ok(files.length > 50, String(files.length));
+ for (const f of files) assert.equal(statSync(f).mode & 0o222, 0, `${f} is writable`);
+ // Already there: left as it is, by id or by branch name.
+ for (const commit of [head, 'refactor']) {
+ const again = await run(dir, script, ['copy', commit, '--copies', copies]);
+ assert.equal(again.status, 0, again.stderr);
+ assert.equal(again.stdout, `exists: ${target}\n`);
+ }
+ // The default directory is under XDG_DATA_HOME, else ~/.local/share.
+ const byDefault = await run(dir, script, ['copy', 'refactor']);
+ assert.equal(byDefault.status, 0, byDefault.stderr);
+ assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-console', head, 'packages', 'webui', 'src', 'cli.mjs')));
+ assert.equal(git(repo, 'status', '--porcelain'), '', 'the checkout is untouched');
+});
+
+test('copy refuses a commit off refactor, an unknown one, a worktree and a bad copies directory', async (t) => {
+ const { dir, repo, script } = fixtureRepo(t);
+ const copies = join(dir, 'copies');
+ git(repo, 'checkout', '-q', '-b', 'side');
+ writeFileSync(join(repo, 'side.txt'), 'side\n');
+ git(repo, 'add', 'side.txt');
+ git(repo, 'commit', '-q', '-m', 'side');
+ const side = git(repo, 'rev-parse', 'HEAD');
+ git(repo, 'checkout', '-q', 'refactor');
+ for (const [commit, why] of [[side, new RegExp(`^webui-service: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^webui-service: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/]]) {
+ const r = await run(dir, script, ['copy', commit, '--copies', copies]);
+ assert.equal(r.status, 1, commit);
+ assert.match(r.stderr, why, commit);
+ assert.equal(r.stdout, '', commit);
+ }
+ for (const args of [['copy'], ['copy', '-x'], ['copy', 'refactor', '--copy', side], ['copy', 'refactor', '--port', '7332'], ['copy', 'refactor', '--dir', dir]]) {
+ assert.equal((await run(dir, script, args)).status, 4, args.join(' '));
+ }
+ for (const bad of ['copies', `${dir}/a b`, `${dir}/a%b`]) {
+ const r = await run(dir, script, ['copy', 'refactor', '--copies', bad]);
+ assert.equal(r.status, 1, bad);
+ assert.match(r.stderr, /copies directory (must be an absolute path|contains a character the unit cannot carry)/, bad);
+ }
+ assert.ok(!existsSync(copies), 'no refusal writes anything');
+ // N2: a linked worktree has a .git file; --allow-worktree is for tests.
+ const tree = join(dir, 'tree');
+ git(repo, 'worktree', 'add', '-q', '--detach', tree, 'refactor');
+ const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]);
+ assert.equal(fromTree.status, 1);
+ assert.match(fromTree.stderr, /^webui-service: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m);
+ assert.ok(!existsSync(copies));
+ const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']);
+ assert.equal(allowed.status, 0, allowed.stderr);
+ assert.match(allowed.stdout, /^copied: /m);
+ assert.match((await run(dir, script, ['--help'])).stdout, /--allow-worktree is for the tests only/);
+});
+
+test('copy writes nothing when an archived path is missing at the commit', async (t) => {
+ const { dir, repo, script } = fixtureRepo(t);
+ const copies = join(dir, 'copies');
+ git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
+ git(repo, 'commit', '-q', '-m', 'drop the config check');
+ const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
+ assert.equal(r.status, 1);
+ assert.match(r.stderr, /^webui-service: git archive of [0-9a-f]{40} failed; no copy written$/m);
+ assert.deepEqual(readdirSync(copies), [], 'the temp directory is removed');
+});
+
+// serve from a copy: /healthz, the config check, roles and RELEASE from the
+// copy, and the queue read from --checkout or, without it, from the copy.
+async function serve(t, cli, args, env) {
+ const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] });
+ t.after(() => child.kill());
+ let out = '', err = '';
+ child.stderr.on('data', (c) => { err += c; });
+ const port = await new Promise((resolve, reject) => {
+ child.stdout.on('data', (c) => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
+ child.on('exit', (code) => reject(new Error(`exited ${code}: ${out}${err}`)));
+ });
+ const get = async (path) => { const res = await fetch(`http://127.0.0.1:${port}${path}`); return { status: res.status, body: await res.json() }; };
+ return { out, get };
+}
+
+test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE from the copy, the queue from --checkout', async (t) => {
+ const { dir, script, head } = fixtureRepo(t);
+ const copies = join(dir, 'copies');
+ assert.equal((await run(dir, script, ['copy', 'refactor', '--copies', copies])).status, 0);
+ const cli = join(copies, head, 'packages', 'webui', 'src', 'cli.mjs');
+ // A checkout whose queue read says where it ran.
+ const checkout = join(dir, 'checkout');
+ mkdirSync(join(checkout, 'packages', 'webui', 'src'), { recursive: true });
+ writeFileSync(join(checkout, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: [process.cwd() === ${JSON.stringify(checkout)} ? 'read in the checkout' : 'read elsewhere'] }));\n`);
+ const { env } = fixture(join(dir, 'home'));
+ delete env.MOSAIC_ROLES_DIR;
+ const { out, get } = await serve(t, cli, ['--business', 'acme', '--checkout', checkout], env);
+ assert.match(out, /^Bus: acme$/m, 'the config check ran from the copy');
+ assert.match(out, /^Queue: read from the --checkout directory$/m);
+ assert.deepEqual(await get('/healthz'), { status: 200, body: { ok: true } });
+ const settings = await get('/api/settings');
+ assert.equal(settings.status, 200);
+ assert.equal(settings.body.release, '0.0.0-fixture', 'RELEASE from the copy');
+ const business = await get('/api/business');
+ assert.equal(business.status, 200, JSON.stringify(business.body));
+ assert.equal(business.body.business.id, 'acme', 'roles from the copy');
+ const queue = await get('/api/queue');
+ assert.equal(queue.status, 200, JSON.stringify(queue.body));
+ assert.deepEqual(queue.body.notes, ['read in the checkout']);
+ // Without --checkout the copy's own queue read runs, and the copy holds
+ // no packages/queue, so it fails closed.
+ const plain = await serve(t, cli, ['--business', 'acme'], env);
+ assert.doesNotMatch(plain.out, /^Queue:/m);
+ assert.equal((await plain.get('/healthz')).status, 200);
+ const none = await plain.get('/api/queue');
+ assert.equal(none.status, 503);
+ assert.equal(none.body.error, 'read-failed');
+});
+
+test('serve refuses a --checkout that is not an absolute directory path', async (t) => {
+ const dir = tmp(t, 'webui-checkout-');
+ writeFileSync(join(dir, 'file'), '');
+ // 'sub' is a directory under the child's cwd, so only its being relative refuses it.
+ mkdirSync(join(dir, 'sub'));
+ for (const value of ['sub', join(dir, 'missing'), join(dir, 'file')]) {
+ const child = spawn(process.execPath, [join(REPO, 'packages', 'webui', 'src', 'cli.mjs'), 'serve', '--port', '0', '--checkout', value], { cwd: dir, timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] });
+ let out = '', err = '';
+ child.stdout.on('data', (c) => { out += c; });
+ child.stderr.on('data', (c) => { err += c; });
+ const [code] = await once(child, 'close');
+ assert.equal(code, 2, value);
+ assert.equal(err, 'refused: --checkout must be the absolute path of a directory\n', value);
+ assert.equal(out, '');
+ }
+});
+
+test('consoleReads runs the queue read under queueRoot and reads RELEASE under root', async (t) => {
+ const dir = tmp(t, 'webui-roots-');
+ const [root, queueRoot] = [join(dir, 'copy'), join(dir, 'checkout')];
+ for (const [base, said] of [[root, 'copy'], [queueRoot, 'checkout']]) {
+ mkdirSync(join(base, 'packages', 'webui', 'src'), { recursive: true });
+ writeFileSync(join(base, 'packages', 'webui', 'src', 'queue-read.mjs'), `process.stdout.write(JSON.stringify({ rows: [], notes: ['${said}', process.cwd() === ${JSON.stringify(base)} ? 'cwd there' : 'cwd elsewhere'] }));\n`);
+ writeFileSync(join(base, 'RELEASE'), `${said}-release\n`);
+ }
+ const apart = consoleReads({ root, queueRoot, rolesDir: join(dir, 'roles') });
+ assert.deepEqual((await apart.queue()).notes, ['checkout', 'cwd there'], 'the checkout ran it, with its cwd there');
+ assert.equal((await apart.settings()).release, 'copy-release');
+ const together = consoleReads({ root, rolesDir: join(dir, 'roles') });
+ assert.deepEqual((await together.queue()).notes, ['copy', 'cwd there'], 'queueRoot defaults to root');
+ assert.equal((await together.settings()).release, 'copy-release');
+});
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
index da440d6b..2f670c70 100644
--- a/packages/webui/tests/service.test.mjs
+++ b/packages/webui/tests/service.test.mjs
@@ -11,22 +11,35 @@ import { fileURLToPath } from 'node:url';
// scripts/webui-service.sh (row 75, #1569). Nothing here starts a unit or
// reaches the real systemd: install and uninstall run with --no-reload or a
// stand-in systemctl on PATH, and XDG_CONFIG_HOME points into a temp dir.
-// Row 83 (#1576) adds --send, taken only with the preview set.
+// Row 83 (#1576) adds --send, taken only with the preview set. Row 77
+// (#1571) adds --copy, the running-unit refusals and the uid rule; every run
+// gets a stand-in systemctl, since install now reads the unit's state, and
+// install runs with --allow-worktree, since the suite runs in worktrees.
const REPO = fileURLToPath(new URL('../../..', import.meta.url)).replace(/\/$/, '');
const SCRIPT = join(REPO, 'scripts', 'webui-service.sh');
const NODE_DIR = dirname(process.execPath);
-const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', 'jason'];
-const PREVIEW_FLAGS = '--public-host console.example.com --auth-header X-authentik-uid --allow-user jason';
+const UID = '6f1c0e2a9b8d7c4e5f3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e';
+const PREVIEW = ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID];
+const PREVIEW_FLAGS = `--public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`;
+// A copy as `copy` leaves it, reduced to the file install looks for.
+const COPY_ID = '0123456789abcdef0123456789abcdef01234567';
+const INSTALL = ['--copy', COPY_ID, '--allow-worktree'];
+const copyRoot = (dir) => join(dir, '.local', 'share', 'mosaic-console', COPY_ID);
+function fakeCopy(dir) {
+ mkdirSync(join(copyRoot(dir), 'packages', 'webui', 'src'), { recursive: true });
+ writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
+ return copyRoot(dir);
+}
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
// The unit as the base script renders it (row 75): the template with its four
// placeholders filled in, nothing else. Row 83 adds --send and must leave
// every unit rendered without it byte for byte as this.
-function filled(flags) {
+function filled(flags, root = REPO) {
const base = '/usr/local/bin:/usr/bin:/bin';
const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`;
- return TEMPLATE.replaceAll('@REPO@', REPO).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
+ return TEMPLATE.replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
}
// A loopback port with nothing listening, so status's /healthz request
@@ -44,18 +57,20 @@ async function closedPort() {
function tmp(t) {
const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
+ fakeCopy(dir);
return dir;
}
-// A stand-in systemctl that prints `lines` for list-units and logs every call.
-function standIn(dir, lines = '') {
+// A stand-in systemctl that prints `lines` for list-units and logs every
+// call. With `listFails`, list-units exits 1 after printing to stderr.
+function standIn(dir, lines = '', { listFails = false } = {}) {
const bin = join(dir, 'bin');
mkdirSync(bin);
writeFileSync(join(dir, 'list-units.txt'), lines);
writeFileSync(join(bin, 'systemctl'), `#!/bin/sh
echo "$*" >> "${dir}/calls.txt"
case "$*" in
- *list-units*) cat "${dir}/list-units.txt" ;;
+ *list-units*) ${listFails ? 'echo "Failed to connect to bus" >&2; exit 1' : `cat "${dir}/list-units.txt"`} ;;
*is-enabled*) echo enabled ;;
*is-active*) echo active ;;
esac
@@ -65,9 +80,18 @@ exit 0
return bin;
}
+// Without a stand-in of its own, a run gets one that lists nothing (the
+// unit not loaded), so no run reaches the real systemd.
+function quiet(dir) {
+ const home = join(dir, '.quiet');
+ if (!existsSync(home)) { mkdirSync(home); standIn(home); }
+ return join(home, 'bin');
+}
+const otherCalls = (file) => readFileSync(file, 'utf8').split('\n').filter(Boolean).filter((c) => c !== '--user list-units --no-legend --plain mosaic-webui.service');
+
// Async, so an in-process server can answer the script's /healthz request.
async function run(dir, args, bin = null) {
- const env = { PATH: `${bin ? `${bin}:` : ''}${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
+ const env = { PATH: `${bin ?? quiet(dir)}:${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
const child = spawn(SCRIPT, args, { env });
let stdout = '', stderr = '';
child.stdout.on('data', (d) => { stdout += d; });
@@ -95,7 +119,7 @@ test('render carries every serve flag, the preview set included', async (t) => {
const dir = tmp(t);
const r = await run(dir, ['render', '--port', '7340', '--business', 'mosaic.dev_1', '--board', 'http://127.0.0.1:7331', ...PREVIEW]);
assert.equal(r.status, 0, r.stderr);
- assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user jason`);
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
});
test('a partial preview set is refused', async (t) => {
@@ -153,8 +177,9 @@ test('install into --dir with --no-reload writes the file whole, then reports un
const dir = tmp(t);
const unitDir = join(dir, 'units');
const bin = standIn(dir);
- const rendered = await run(dir, ['render', '--business', 'mosaic']);
- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin);
+ const rendered = await run(dir, ['render', '--copy', COPY_ID, '--business', 'mosaic']);
+ assert.equal(rendered.stdout, filled(`--checkout ${REPO} --port 7332 --business mosaic`, copyRoot(dir)));
+ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin);
assert.equal(r.status, 0, r.stderr);
const target = join(unitDir, 'mosaic-webui.service');
assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm'));
@@ -162,41 +187,50 @@ test('install into --dir with --no-reload writes the file whole, then reports un
assert.equal(readFileSync(target, 'utf8'), rendered.stdout);
assert.equal(statSync(target).mode & 0o777, 0o644);
assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service'], 'no temp file left behind');
- const again = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin);
+ const again = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--business', 'mosaic'], bin);
assert.equal(again.status, 0, again.stderr);
assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm'));
- assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call with --no-reload');
+ assert.deepEqual(otherCalls(join(dir, 'calls.txt')), [], 'with --no-reload, only the state read');
// A refused value writes nothing and leaves the installed unit alone.
- const bad = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--business', 'a b'], bin);
+ const bad = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--business', 'a b'], bin);
assert.equal(bad.status, 1);
assert.equal(readFileSync(target, 'utf8'), rendered.stdout);
assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
const fresh = join(dir, 'never');
- assert.equal((await run(dir, ['install', '--dir', fresh, '--no-reload', '--port', '0'], bin)).status, 1);
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', fresh, '--no-reload', '--port', '0'], bin)).status, 1);
assert.ok(!existsSync(fresh), 'a refusal creates no directory');
});
test('install without --no-reload runs daemon-reload and nothing that starts the unit', async (t) => {
const dir = tmp(t);
const bin = standIn(dir);
- const r = await run(dir, ['install', '--dir', join(dir, 'units')], bin);
+ const r = await run(dir, ['install', ...INSTALL, '--dir', join(dir, 'units')], bin);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^daemon-reload done$/m);
- assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user daemon-reload\n');
+ assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user list-units --no-legend --plain mosaic-webui.service\n--user daemon-reload\n');
});
-test('uninstall refuses while the unit is active, and removes it once it is not', async (t) => {
+test('uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
const target = join(unitDir, 'mosaic-webui.service');
- for (const state of ['active', 'activating', 'deactivating']) {
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0);
+ for (const state of ['active', 'activating', 'deactivating', 'reloading']) {
const bin = standIn(mkdtempSync(join(dir, 'si-')), `mosaic-webui.service loaded ${state} running Mosaic Console\n`);
- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload'])).status, 0);
- const r = await run(dir, ['uninstall', '--dir', unitDir], bin);
- assert.equal(r.status, 1, state);
- assert.match(r.stderr, /refusing: mosaic-webui\.service is still running/);
- assert.ok(existsSync(target), `still installed while ${state}`);
+ // N3: --no-reload skips only daemon-reload, never the state read.
+ for (const args of [['uninstall', '--dir', unitDir], ['uninstall', '--dir', unitDir, '--no-reload']]) {
+ const r = await run(dir, args, bin);
+ assert.equal(r.status, 1, `${state} ${args.join(' ')}`);
+ assert.equal(r.stderr, `webui-service: refusing: mosaic-webui.service is still running (${state}); Jason or Mos stops it first (systemctl --user stop mosaic-webui)\n`);
+ assert.equal(r.stdout, '');
+ assert.ok(existsSync(target), `still installed while ${state}`);
+ }
}
+ // A failed unit is not running; uninstall goes ahead.
+ const failed = standIn(mkdtempSync(join(dir, 'si-')), 'mosaic-webui.service loaded failed failed Mosaic Console\n');
+ assert.equal((await run(dir, ['uninstall', '--dir', unitDir, '--no-reload'], failed)).status, 0);
+ assert.ok(!existsSync(target));
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0);
const bin = standIn(mkdtempSync(join(dir, 'si-')), 'mosaic-webui.service loaded inactive dead Mosaic Console\n');
const r = await run(dir, ['uninstall', '--dir', unitDir], bin);
assert.equal(r.status, 0, r.stderr);
@@ -220,9 +254,10 @@ test('status prints the unit state and GET /healthz on the installed port', asyn
await once(server, 'listening');
t.after(() => server.close());
const { port } = server.address();
- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port)])).status, 0);
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port)])).status, 0);
const r = await run(dir, ['status', '--dir', unitDir], bin);
assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, new RegExp(`^copy: ${COPY_ID}$`, 'm'));
assert.match(r.stdout, /^ {2}enabled: enabled$/m);
assert.match(r.stdout, /^ {2}active: {2}active$/m);
assert.match(r.stdout, new RegExp(`^healthz: http://127\\.0\\.0\\.1:${port}/healthz 200 \\{"ok":true\\}$`, 'm'));
@@ -237,7 +272,7 @@ test('status prints the unit state and GET /healthz on the installed port', asyn
test('usage: an unknown verb or flag, and start or enable, exit 4', async (t) => {
const dir = tmp(t);
- for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', dir], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload'], ['render', '--send', 'yes', ...PREVIEW]]) {
+ for (const args of [[], ['start'], ['stop'], ['enable'], ['restart'], ['render', '--bogus'], ['render', '--port'], ['render', '--dir', dir], ['render', '--no-reload'], ['uninstall', '--port', '7332'], ['status', '--business', 'mosaic'], ['status', '--no-reload'], ['render', '--send', 'yes', ...PREVIEW], ['render', '--allow-worktree'], ['render', '--copies', dir], ['uninstall', '--copy', COPY_ID], ['uninstall', '--allow-worktree'], ['status', '--copy', COPY_ID], ['status', '--allow-worktree'], ['install', '--copy']]) {
const r = await run(dir, args);
assert.equal(r.status, 4, args.join(' '));
assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/);
@@ -268,7 +303,7 @@ test('render --send with the preview set ends ExecStart with --send and changes
const r = await run(dir, ['render', ...args]);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, filled(`${flags} --send`), args.join(' '));
- assert.ok(execStart(r.stdout).endsWith(' --allow-user jason --send'), execStart(r.stdout));
+ assert.ok(execStart(r.stdout).endsWith(` --allow-user ${UID} --send`), execStart(r.stdout));
const without = await run(dir, ['render', ...plain]);
assert.equal(without.status, 0, without.stderr);
const a = r.stdout.split('\n'), b = without.stdout.split('\n');
@@ -290,20 +325,20 @@ test('--send without the whole preview set is refused and writes nothing', async
const bin = standIn(dir);
const fresh = join(dir, 'never');
for (const args of partial) {
- const r = await run(dir, ['install', '--dir', fresh, '--no-reload', ...args, '--send'], bin);
+ const r = await run(dir, ['install', ...INSTALL, '--dir', fresh, '--no-reload', ...args, '--send'], bin);
assert.equal(r.status, 1, args.join(' '));
assert.match(r.stderr, SEND_REFUSAL, args.join(' '));
}
assert.ok(!existsSync(fresh), 'a refusal creates no directory');
const unitDir = join(dir, 'units');
const target = join(unitDir, 'mosaic-webui.service');
- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', ...PREVIEW], bin)).status, 0);
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', ...PREVIEW], bin)).status, 0);
const before = readFileSync(target, 'utf8');
- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--send', ...PREVIEW.slice(0, 4)], bin);
+ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--send', ...PREVIEW.slice(0, 4)], bin);
assert.equal(r.status, 1);
assert.equal(readFileSync(target, 'utf8'), before, 'the installed unit is left alone');
assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
- assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call');
+ assert.deepEqual(otherCalls(join(dir, 'calls.txt')), [], 'no systemctl call but the one state read');
});
test('install --send into --dir with --no-reload writes the sending unit, and status says whether it sends', async (t) => {
@@ -312,13 +347,14 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
const target = join(unitDir, 'mosaic-webui.service');
const port = await closedPort();
const flags = `--port ${port} ${PREVIEW_FLAGS}`;
- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send']);
+ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW, '--send']);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm'));
// Lead decision 102: Jason or Mos starts it, no other agent.
assert.match(r.stdout, /^this script starts nothing: .* Jason\nstarts and restarts it, or Mos under lead decision 102 .*\ntrail as Jason\); no other agent does:$/m);
- assert.match(r.stdout, new RegExp(`^serve: \\S+ ${REPO.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/packages/webui/src/cli\\.mjs serve ${flags} --send$`, 'm'));
- assert.equal(readFileSync(target, 'utf8'), filled(`${flags} --send`));
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ assert.match(r.stdout, new RegExp(`^serve: \\S+ ${esc(copyRoot(dir))}/packages/webui/src/cli\\.mjs serve --checkout ${esc(REPO)} ${flags} --send$`, 'm'));
+ assert.equal(readFileSync(target, 'utf8'), filled(`--checkout ${REPO} ${flags} --send`, copyRoot(dir)));
assert.equal(statSync(target).mode & 0o777, 0o644);
assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
const bin = standIn(dir);
@@ -326,34 +362,34 @@ test('install --send into --dir with --no-reload writes the sending unit, and st
assert.equal(sending.status, 0, sending.stderr);
assert.match(sending.stdout, /^send: on \(--send; human messages to roles\)$/m);
assert.match(sending.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m);
- const again = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), '--send', ...PREVIEW], bin);
+ const again = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), '--send', ...PREVIEW], bin);
assert.match(again.stdout, new RegExp(`^unchanged: ${target}$`, 'm'));
- const readOnly = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW], bin);
+ const readOnly = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...PREVIEW], bin);
assert.match(readOnly.stdout, new RegExp(`^written: ${target}$`, 'm'));
- assert.equal(readFileSync(target, 'utf8'), filled(flags));
+ assert.equal(readFileSync(target, 'utf8'), filled(`--checkout ${REPO} ${flags}`, copyRoot(dir)));
const off = await run(dir, ['status', '--dir', unitDir], bin);
assert.match(off.stdout, /^send: off \(read-only\)$/m);
assert.doesNotMatch(off.stdout, /^send: on/m);
- // The only systemctl calls are status's reads; install ran none.
- assert.deepEqual(readFileSync(join(dir, 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service/.test(c)), []);
+ // The only systemctl calls are status's reads and install's state read.
+ assert.deepEqual(otherCalls(join(dir, 'calls.txt')).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service/.test(c)), []);
const none = await run(dir, ['status', '--dir', join(dir, 'empty')], bin);
assert.doesNotMatch(none.stdout, /^send:/m, 'no unit, no send line');
- // serve takes --send as an allowed user, so ExecStart can end with
- // `--allow-user --send` on a unit that doesn't send.
- const oddUser = [...PREVIEW.slice(0, 4), '--allow-user', '--send'];
- for (const [args, ending, state] of [[oddUser, '--allow-user --send', 'off'], [[...oddUser, '--send'], '--allow-user --send --send', 'on']]) {
- const r = await run(dir, ['install', '--dir', unitDir, '--no-reload', '--port', String(port), ...args]);
- assert.equal(r.status, 0, r.stderr);
- assert.ok(execStart(readFileSync(target, 'utf8')).endsWith(` ${ending}`), args.join(' '));
- assert.match((await run(dir, ['status', '--dir', unitDir], bin)).stdout, new RegExp(`^send: ${state} `, 'm'), args.join(' '));
+ // serve would take --send as an allowed user; N5 refuses it, so no unit
+ // ends with `--allow-user --send` without sending.
+ const before = readFileSync(target, 'utf8');
+ for (const args of [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--send'], ['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--send', '--send']]) {
+ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', String(port), ...args]);
+ assert.equal(r.status, 1, args.join(' '));
+ assert.match(r.stderr, /^webui-service: allowed user must start with a letter or digit: --send$/m);
}
+ assert.equal(readFileSync(target, 'utf8'), before);
});
test('uninstall and status take no --send', async (t) => {
const dir = tmp(t);
const bin = standIn(dir);
const unitDir = join(dir, 'units');
- assert.equal((await run(dir, ['install', '--dir', unitDir, '--no-reload', ...PREVIEW, '--send'])).status, 0);
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', ...PREVIEW, '--send'])).status, 0);
for (const args of [['uninstall', '--dir', unitDir, '--send'], ['uninstall', '--dir', unitDir, '--no-reload', '--send'], ['status', '--dir', unitDir, '--send']]) {
const r = await run(dir, args, bin);
assert.equal(r.status, 4, args.join(' '));
@@ -363,3 +399,172 @@ test('uninstall and status take no --send', async (t) => {
assert.ok(existsSync(join(unitDir, 'mosaic-webui.service')), 'still installed');
assert.ok(!existsSync(join(dir, 'calls.txt')), 'no systemctl call');
});
+
+// Row 77 (#1571) from here on.
+const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+test('install refuses while the unit is running, in any state but inactive or failed, and changes nothing', async (t) => {
+ const dir = tmp(t);
+ const unitDir = join(dir, 'units');
+ const target = join(unitDir, 'mosaic-webui.service');
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0);
+ const before = readFileSync(target, 'utf8');
+ for (const state of ['active', 'activating', 'deactivating', 'reloading']) {
+ const home = mkdtempSync(join(dir, 'si-'));
+ const bin = standIn(home, `mosaic-webui.service loaded ${state} running Mosaic Console\n`);
+ for (const reload of [[], ['--no-reload']]) {
+ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, ...reload, '--port', '7444'], bin);
+ assert.equal(r.status, 1, `${state} ${reload}`);
+ assert.equal(r.stderr, `webui-service: refusing: mosaic-webui.service is running (${state}); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it\n`);
+ assert.equal(r.stdout, '');
+ }
+ assert.deepEqual(otherCalls(join(home, 'calls.txt')), [], 'no daemon-reload');
+ assert.equal(readFileSync(target, 'utf8'), before, `unit left alone while ${state}`);
+ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service'], 'no temp file left');
+ }
+ // inactive, failed, another unit's line, or nothing listed: install goes ahead.
+ for (const lines of ['mosaic-webui.service loaded inactive dead Mosaic Console\n', 'mosaic-webui.service loaded failed failed Mosaic Console\n', '[email protected] loaded active running Bus\n', '']) {
+ const bin = standIn(mkdtempSync(join(dir, 'si-')), lines);
+ const r = await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', '7444'], bin);
+ assert.equal(r.status, 0, `${JSON.stringify(lines)}: ${r.stderr}`);
+ }
+});
+
+test('when the state read fails, install and uninstall refuse with one line and change nothing', async (t) => {
+ const dir = tmp(t);
+ const unitDir = join(dir, 'units');
+ const target = join(unitDir, 'mosaic-webui.service');
+ assert.equal((await run(dir, ['install', ...INSTALL, '--dir', unitDir, '--no-reload'])).status, 0);
+ const before = readFileSync(target, 'utf8');
+ const home = mkdtempSync(join(dir, 'si-'));
+ const bin = standIn(home, '', { listFails: true });
+ const refusal = 'webui-service: refusing: systemctl --user list-units --no-legend --plain mosaic-webui.service failed, so the unit\'s state is unknown; nothing was changed\n';
+ for (const args of [['install', ...INSTALL, '--dir', unitDir, '--port', '7444'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload', '--port', '7444'], ['uninstall', '--dir', unitDir], ['uninstall', '--dir', unitDir, '--no-reload']]) {
+ const r = await run(dir, args, bin);
+ assert.equal(r.status, 1, args.join(' '));
+ assert.equal(r.stderr, refusal, args.join(' '));
+ assert.equal(r.stdout, '');
+ }
+ assert.deepEqual(otherCalls(join(home, 'calls.txt')), [], 'no daemon-reload');
+ assert.equal(readFileSync(target, 'utf8'), before);
+ assert.deepEqual(readdirSync(unitDir), ['mosaic-webui.service']);
+});
+
+test('install needs --copy naming a copy that exists, and refuses outside the canonical checkout without --allow-worktree', async (t) => {
+ const dir = tmp(t);
+ const unitDir = join(dir, 'units');
+ const cases = [
+ [[], /^webui-service: install needs --copy ID, a copy made with: scripts\/webui-service\.sh copy COMMIT$/m],
+ [['--copy', COPY_ID.slice(0, 12)], /^webui-service: --copy must be a full 40-character commit id: 0123456789ab$/m],
+ [['--copy', COPY_ID.toUpperCase()], /^webui-service: --copy must be a full 40-character commit id: /m],
+ [['--copy', `${COPY_ID}0`], /^webui-service: --copy must be a full 40-character commit id: /m],
+ [['--copy', 'f'.repeat(40)], new RegExp(`^webui-service: no copy ${'f'.repeat(40)} in ${esc(join(dir, '.local', 'share', 'mosaic-console'))}; make it with: scripts/webui-service\\.sh copy ${'f'.repeat(40)}$`, 'm')],
+ [['--copy', COPY_ID, '--copies', 'relative'], /^webui-service: copies directory must be an absolute path: relative$/m],
+ [['--copy', COPY_ID, '--copies', join(dir, 'no copies')], /^webui-service: copies directory contains a character the unit cannot carry: /m],
+ ];
+ for (const [copy, refusal] of cases) {
+ const r = await run(dir, ['install', '--allow-worktree', ...copy, '--dir', unitDir, '--no-reload']);
+ assert.equal(r.status, 1, copy.join(' '));
+ assert.match(r.stderr, refusal, copy.join(' '));
+ assert.ok(!existsSync(unitDir), copy.join(' '));
+ }
+ // This suite runs in a git worktree, whose .git is a file: without
+ // --allow-worktree, install refuses before it reads anything else.
+ if (!statSync(join(REPO, '.git')).isDirectory()) {
+ const r = await run(dir, ['install', '--copy', COPY_ID, '--dir', unitDir, '--no-reload']);
+ assert.equal(r.status, 1);
+ assert.equal(r.stderr, `webui-service: refusing: ${REPO} is not the canonical checkout (its .git is not a directory); run copy and install from the canonical checkout\n`);
+ assert.ok(!existsSync(unitDir));
+ }
+ // --copies moves where the copy is looked for.
+ const other = join(dir, 'other');
+ mkdirSync(join(other, COPY_ID, 'packages', 'webui', 'src'), { recursive: true });
+ writeFileSync(join(other, COPY_ID, 'packages', 'webui', 'src', 'cli.mjs'), '');
+ const r = await run(dir, ['install', ...INSTALL, '--copies', other, '--dir', unitDir, '--no-reload']);
+ assert.equal(r.status, 0, r.stderr);
+ assert.equal(readFileSync(join(unitDir, 'mosaic-webui.service'), 'utf8'), filled(`--checkout ${REPO} --port 7332`, join(other, COPY_ID)));
+});
+
+test('render --copy runs the copy and reads the queue in the checkout; without --copy the unit is unchanged', async (t) => {
+ const dir = tmp(t);
+ const r = await run(dir, ['render', '--copy', COPY_ID, ...PREVIEW, '--send']);
+ assert.equal(r.status, 0, r.stderr);
+ assert.equal(r.stdout, filled(`--checkout ${REPO} --port 7332 ${PREVIEW_FLAGS} --send`, copyRoot(dir)));
+ assert.match(r.stdout, new RegExp(`^WorkingDirectory=${esc(copyRoot(dir))}$`, 'm'));
+ assert.match(r.stdout, new RegExp(`^ExecStart=\\S+ ${esc(copyRoot(dir))}/packages/webui/src/cli\\.mjs serve --checkout ${esc(REPO)} `, 'm'));
+ const plain = await run(dir, ['render', ...PREVIEW]);
+ assert.equal(plain.stdout, filled(`--port 7332 ${PREVIEW_FLAGS}`));
+ assert.ok(!execStart(plain.stdout).includes('--checkout'));
+ // render only prints: it takes no --allow-worktree and needs no canonical checkout.
+ assert.equal((await run(dir, ['render', '--copy', 'f'.repeat(40)])).status, 1);
+});
+
+test('the uid header takes only an Authentik uid; the username header still takes a username', async (t) => {
+ const dir = tmp(t);
+ const unitDir = join(dir, 'units');
+ const head = ['--public-host', 'console.example.com', '--auth-header'];
+ for (const header of ['X-authentik-uid', 'x-authentik-uid', 'X-AUTHENTIK-UID', 'x-AUTHENTIK-uid']) {
+ const ok = await run(dir, ['render', ...head, header, '--allow-user', UID]);
+ assert.equal(ok.status, 0, `${header}: ${ok.stderr}`);
+ for (const user of [UID.slice(1), `${UID}0`, UID.toUpperCase(), `${UID.slice(0, 63)}g`, 'jason', '[email protected]']) {
+ for (const verb of [['render'], ['install', ...INSTALL, '--dir', unitDir, '--no-reload']]) {
+ const r = await run(dir, [...verb, ...head, header, '--allow-user', user]);
+ assert.equal(r.status, 1, `${verb[0]} ${header} ${user}`);
+ assert.equal(r.stderr, `webui-service: with --auth-header ${header} the allowed user must be an Authentik uid, 64 lower-case hex characters (^[0-9a-f]{64}$, lead decision 101): ${user}\n`);
+ }
+ }
+ }
+ assert.ok(!existsSync(unitDir), 'no refused install wrote a unit');
+ for (const user of ['jason', '[email protected]', UID]) {
+ const r = await run(dir, ['render', ...head, 'X-authentik-username', '--allow-user', user]);
+ assert.equal(r.status, 0, `${user}: ${r.stderr}`);
+ assert.ok(execStart(r.stdout).endsWith(` --allow-user ${user}`));
+ }
+});
+
+test('a preview value cannot start with - or .', async (t) => {
+ const dir = tmp(t);
+ const cases = [
+ [['--public-host', '-x.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID], 'public host must be a hostname, no port or scheme: -x.example.com'],
+ [['--public-host', '.example.com', '--auth-header', 'X-authentik-uid', '--allow-user', UID], 'public host must be a hostname, no port or scheme: .example.com'],
+ [['--public-host', 'console.example.com', '--auth-header', '-X-authentik-uid', '--allow-user', UID], 'auth header must be a header name: -X-authentik-uid'],
+ [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '-jason'], 'allowed user must start with a letter or digit: -jason'],
+ [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '.jason'], 'allowed user must start with a letter or digit: .jason'],
+ [['--public-host', 'console.example.com', '--auth-header', 'X-authentik-username', '--allow-user', '--port'], 'allowed user must start with a letter or digit: --port'],
+ ];
+ for (const [args, refusal] of cases) {
+ const r = await run(dir, ['render', ...args]);
+ assert.equal(r.status, 1, args.join(' '));
+ assert.equal(r.stderr, `webui-service: ${refusal}\n`);
+ }
+});
+
+test('status names the copy and whether refactor has moved past it, and says when the unit runs from no copy', async (t) => {
+ const dir = tmp(t);
+ const unitDir = join(dir, 'units');
+ const target = join(unitDir, 'mosaic-webui.service');
+ const bin = standIn(mkdtempSync(join(dir, 'si-')));
+ const port = await closedPort();
+ const head = await new Promise((resolve, reject) => {
+ const git = spawn('git', ['-C', REPO, 'rev-parse', '--verify', 'refs/heads/refactor']);
+ let out = '';
+ git.stdout.on('data', (d) => { out += d; });
+ git.on('close', (code) => (code === 0 ? resolve(out.trim()) : reject(new Error('no refs/heads/refactor'))));
+ });
+ // status only reads the unit file, so a hand-written one stands in.
+ mkdirSync(unitDir);
+ const unit = (wd) => writeFileSync(target, `[Service]\nWorkingDirectory=${wd}\nExecStart=/usr/bin/node ${wd}/packages/webui/src/cli.mjs serve --port ${port}\n`);
+ unit(join(dir, 'copies', head));
+ let r = await run(dir, ['status', '--dir', unitDir], bin);
+ assert.equal(r.status, 0, r.stderr);
+ assert.match(r.stdout, new RegExp(`^copy: ${head}\nrefactor: ${head}, the copy's commit$`, 'm'));
+ unit(join(dir, 'copies', COPY_ID));
+ r = await run(dir, ['status', '--dir', unitDir], bin);
+ assert.match(r.stdout, new RegExp(`^copy: ${COPY_ID}\nrefactor: ${head}, another commit; the copy is ${COPY_ID}$`, 'm'));
+ unit(REPO);
+ r = await run(dir, ['status', '--dir', unitDir], bin);
+ assert.match(r.stdout, new RegExp(`^copy: none; the unit runs from ${esc(REPO)}$`, 'm'));
+ assert.ok(!/^refactor:/m.test(r.stdout));
+ // status reads; it calls nothing that changes the unit.
+ assert.deepEqual(readFileSync(join(dir, readdirSync(dir).find((d) => d.startsWith('si-')), 'calls.txt'), 'utf8').split('\n').filter(Boolean).filter((c) => !/^--user (is-enabled|is-active|show) mosaic-webui\.service( |$)/.test(c)), []);
+});
diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh
index 944bb97a..e643cc6a 100755
--- a/scripts/webui-service.sh
+++ b/scripts/webui-service.sh
@@ -1,18 +1,41 @@
#!/usr/bin/env bash
-# `scripts/webui-service.sh render|install|uninstall|status`: the systemd
-# user unit that runs the Console, `node packages/webui/src/cli.mjs serve`
-# (lead decision 94). The template is
+# `scripts/webui-service.sh copy|render|install|uninstall|status`: the
+# systemd user unit that runs the Console, `node packages/webui/src/cli.mjs
+# serve` (lead decision 94), from a read-only copy of one landed commit
+# (row 77, #1571, lead decision 112). The template is
# packages/webui/systemd/mosaic-webui.service.in; the rendered unit is
# mosaic-webui.service in the systemd user directory, one instance.
#
-# render [FLAGS] print the rendered unit on stdout
-# install [--dir DIR] [--no-reload] [FLAGS]
+# copy COMMIT [--copies DIR] [--allow-worktree]
+# write `git archive` of the paths the Console
+# loads at COMMIT into DIR/<full id>/, read-only;
+# DIR defaults to
+# ${XDG_DATA_HOME:-~/.local/share}/mosaic-console
+# render [--copy ID [--copies DIR]] [FLAGS]
+# print the rendered unit on stdout; without
+# --copy it runs from this checkout
+# install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS]
# write the unit (write to a temp file, then
-# rename) and run `systemctl --user daemon-reload`
+# rename) and run `systemctl --user daemon-reload`;
+# refuses while the unit is running
# uninstall [--dir DIR] [--no-reload]
-# remove the unit; refuses while it is active
-# status [--dir DIR] unit state, whether it sends, then GET
-# /healthz on its port
+# remove the unit; refuses while it is running
+# status [--dir DIR] unit state, the copy it runs and whether
+# refactor has moved past it, whether it sends,
+# then GET /healthz on its port
+#
+# With --copy the unit's WorkingDirectory and ExecStart point at the copy,
+# and ExecStart passes this checkout as serve's --checkout: the queue read
+# runs the checkout's queue-read.mjs, since the queue store refuses code
+# outside the canonical checkout; everything else comes from the copy.
+#
+# copy and install run only from the canonical checkout, whose root holds a
+# .git directory (a linked worktree has a .git file). --allow-worktree lifts
+# that refusal and exists for the tests only, which run in worktrees.
+#
+# The update step: `copy COMMIT`; Jason or Mos stops the unit; `install
+# --copy ID` with the same flags; Jason or Mos starts it. Rollback is the
+# same with the earlier id. Copies stay until someone removes them.
#
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
# Console), --business ID, --board URL, and the preview set --public-host
@@ -29,19 +52,27 @@
# decision 102, which accepts that sends from a Console Mos started enter
# the trail as Jason (human-cli). Any other agent starting it, even detached
# through systemd, works around the human proof (lead decisions 62 and 94).
+# install and uninstall refuse while the unit is running, with or without
+# --no-reload: a unit rewritten under a running Console would take the new
+# ExecStart at its next Restart=on-failure, with nobody starting it.
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
-USAGE="usage: scripts/webui-service.sh render [FLAGS] | install [--dir DIR] [--no-reload] [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]]"
+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]
+ FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]
+ --allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
die() { echo "webui-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
+COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console"
+COPY_ID=""
+COPY_FLAGS=0
+ALLOW_WORKTREE=0
RELOAD=1
DIR_FLAGS=0
PORT=7332
@@ -57,6 +88,9 @@ parse_flags() {
while [ $# -gt 0 ]; do
case "$1" in
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;;
+ --copy) [ $# -ge 2 ] || usage; COPY_ID="$2"; COPY_FLAGS=1; shift 2 ;;
+ --copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
+ --allow-worktree) ALLOW_WORKTREE=1; shift ;;
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
@@ -102,13 +136,63 @@ serve_flags() {
case "$PUBLIC_HOST" in *[!A-Za-z0-9.-]*) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
case "$AUTH_HEADER" in [A-Za-z0-9]*) ;; *) die "auth header must be a header name: $AUTH_HEADER" ;; esac
case "$AUTH_HEADER" in *[!A-Za-z0-9-]*) die "auth header must be a header name: $AUTH_HEADER" ;; esac
+ case "$ALLOW_USER" in [A-Za-z0-9]*) ;; *) die "allowed user must start with a letter or digit: $ALLOW_USER" ;; esac
case "$ALLOW_USER" in *[!A-Za-z0-9._@+-]*) die "allowed user contains a character the unit cannot carry: $ALLOW_USER" ;; esac
+ # Lead decision 101: the uid header carries the Authentik uid, 64
+ # lower-case hex characters, and nothing else is let through on it.
+ if [ "${AUTH_HEADER,,}" = x-authentik-uid ] && ! [[ "$ALLOW_USER" =~ ^[0-9a-f]{64}$ ]]; then
+ die "with --auth-header $AUTH_HEADER the allowed user must be an Authentik uid, 64 lower-case hex characters (^[0-9a-f]{64}\$, lead decision 101): $ALLOW_USER"
+ fi
flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
fi
[ "$SEND" = 0 ] || flags="$flags --send"
echo "$flags"
}
+# The paths the Console loads, the only ones a copy holds: serve's import
+# closure (packages/webui/src/cli.mjs and the human transport child,
+# packages/bus/src/human-cli.mjs) reaches these eight src directories;
+# serve runs scripts/mosaic-config.mjs as its config check and reads roles/
+# and RELEASE; the unit's Documentation= names the README. The queue read
+# runs from the checkout (serve --checkout), so packages/queue is not here.
+# The service test serves from a copy, so a path missing here fails it.
+COPY_PATHS=(
+ packages/bus/src
+ packages/business/src
+ packages/cli/src
+ packages/conversation/src
+ packages/discord/src
+ packages/harness/src
+ packages/seat/src
+ packages/webui/src
+ packages/webui/README.md
+ scripts/mosaic-config.mjs
+ roles
+ RELEASE
+)
+
+# N2 (row 75 review): copy and install read and point at this checkout, so
+# they run from the canonical one, never a worktree someone may remove.
+canonical() {
+ [ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy and install from the canonical checkout"
+}
+
+# The copies directory goes into the unit as part of the copy's path.
+copies_ok() {
+ case "$COPIES" in /*) ;; *) die "copies directory must be an absolute path: $COPIES" ;; esac
+ case "$COPIES" in *[!A-Za-z0-9/._+-]*) die "copies directory contains a character the unit cannot carry: $COPIES" ;; esac
+}
+
+# The root the unit runs from: the copy named by --copy, else this checkout.
+unit_root() {
+ if [ -z "$COPY_ID" ]; then echo "$REPO"; return 0; fi
+ case "$COPY_ID" in *[!0-9a-f]*) die "--copy must be a full 40-character commit id: $COPY_ID" ;; esac
+ [ "${#COPY_ID}" -eq 40 ] || die "--copy must be a full 40-character commit id: $COPY_ID"
+ copies_ok
+ [ -f "$COPIES/$COPY_ID/packages/webui/src/cli.mjs" ] || die "no copy $COPY_ID in $COPIES; make it with: scripts/webui-service.sh copy $COPY_ID"
+ echo "$COPIES/$COPY_ID"
+}
+
render() {
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
local node
@@ -119,18 +203,38 @@ render() {
case "$node" in /*) ;; *) die "node path must be absolute: $node" ;; esac
case "$node" in *[!A-Za-z0-9/._+-]*) die "node path contains a character the unit cannot carry: $node" ;; esac
case "$REPO" in *[!A-Za-z0-9/._+-]*) die "repository path contains a character the unit cannot carry: $REPO" ;; esac
+ local root
+ root="$(unit_root)" || exit 1
local node_dir path="/usr/local/bin:/usr/bin:/bin"
node_dir="$(dirname "$node")"
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
local flags
flags="$(serve_flags)" || exit 1
- sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE"
+ [ -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"
+}
+
+# The unit's active state from list-units, printed when it is anything but
+# inactive or failed (active, activating, deactivating, reloading...). A
+# unit systemd has not loaded is not listed and prints nothing. N4: a
+# failed call refuses with one line naming it; the state is unknown, so
+# nothing is written or removed.
+running() {
+ local out
+ out="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/dev/null)" || die "refusing: systemctl --user list-units --no-legend --plain $UNIT_NAME failed, so the unit's state is unknown; nothing was changed"
+ printf '%s\n' "$out" | awk -v unit="$UNIT_NAME" '$1 == unit && $3 != "inactive" && $3 != "failed" {print $3}'
}
install_unit() {
parse_flags "$@"
+ canonical
+ [ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
local unit
unit="$(render)" || exit 1
+ # N1: a running unit would take the new ExecStart at its next restart.
+ local state
+ state="$(running)" || exit 1
+ [ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it"
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
@@ -163,13 +267,12 @@ MSG
uninstall_unit() {
parse_flags "$@"
- [ "$SERVE_SET" = 0 ] || usage
+ [ "$SERVE_SET" = 0 ] && [ "$COPY_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
- if [ "$RELOAD" = 1 ]; then
- local active
- active="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
- [ -z "$active" ] || die "refusing: $UNIT_NAME is still running; the human stops it first (systemctl --user stop mosaic-webui)"
- fi
+ # N3: checked with --no-reload too, which skips only daemon-reload.
+ local state
+ state="$(running)" || exit 1
+ [ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)"
if [ -f "$target" ]; then
rm -f "$target"
echo "removed: $target"
@@ -184,7 +287,7 @@ uninstall_unit() {
status_unit() {
parse_flags "$@"
- [ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] || usage
+ [ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] && [ "$COPY_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
echo "unit: $UNIT_NAME"
echo " enabled: $(systemctl --user is-enabled "$UNIT_NAME" 2>&1 || true)"
@@ -194,8 +297,26 @@ status_unit() {
echo "healthz: not checked; no unit at $target"
return 0
fi
- # render puts --send right after the allowed user, which may itself be
- # the value --send; a unit that sends ends with both.
+ # The copy is the last part of WorkingDirectory; status only reads.
+ local wd copy head
+ wd="$(sed -n 's/^WorkingDirectory=//p' "$target")"
+ copy="${wd##*/}"
+ if [[ "$copy" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "copy: $copy"
+ if head="$(git -C "$REPO" rev-parse --verify --quiet refs/heads/refactor 2>/dev/null)"; then
+ if [ "$head" = "$copy" ]; then
+ echo "refactor: $head, the copy's commit"
+ else
+ echo "refactor: $head, another commit; the copy is $copy"
+ fi
+ else
+ echo "refactor: unknown; git rev-parse refs/heads/refactor failed in $REPO"
+ fi
+ else
+ echo "copy: none; the unit runs from $wd"
+ fi
+ # render puts --send right after the allowed user. The allowed user
+ # starts with a letter or digit (N5), so it is never the value --send.
if grep -q '^ExecStart=.* --allow-user [^ ][^ ]* --send$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
@@ -212,12 +333,49 @@ fetch(process.argv[1], { signal: AbortSignal.timeout(5000) })
' "$url")"
}
+# Writes the copy into a temp directory beside the target, takes write
+# permission off every file, then renames it into place, so a copy at its
+# final path is always whole and read-only. An existing copy is left alone.
+copy_commit() {
+ [ $# -ge 1 ] || usage
+ local commit="$1"; shift
+ case "$commit" in -*) usage ;; esac
+ parse_flags "$@"
+ [ "$SERVE_SET" = 0 ] && [ "$DIR_FLAGS" = 0 ] && [ -z "$COPY_ID" ] || usage
+ canonical
+ copies_ok
+ case "$commit" in [A-Za-z0-9]*) ;; *) die "commit must be a commit id or branch name: $commit" ;; esac
+ case "$commit" in *[!A-Za-z0-9._/-]*) die "commit must be a commit id or branch name: $commit" ;; esac
+ git -C "$REPO" rev-parse --verify --quiet refs/heads/refactor >/dev/null || die "no refs/heads/refactor in $REPO"
+ local id
+ id="$(git -C "$REPO" rev-parse --verify --quiet "$commit^{commit}")" || die "not a commit: $commit"
+ git -C "$REPO" merge-base --is-ancestor "$id" refs/heads/refactor || die "refusing: $id is not reachable from refs/heads/refactor"
+ local target="$COPIES/$id"
+ if [ -e "$target" ]; then echo "exists: $target"; return 0; fi
+ mkdir -p "$COPIES"
+ COPY_TMP="$(mktemp -d "$COPIES/.$id.XXXXXX")"
+ trap 'rm -rf "$COPY_TMP"' EXIT
+ git -C "$REPO" archive --format=tar "$id" -- "${COPY_PATHS[@]}" | tar -x -f - -C "$COPY_TMP" || die "git archive of $id failed; no copy written"
+ find "$COPY_TMP" -type f -exec chmod a-w {} +
+ chmod 0755 "$COPY_TMP"
+ if ! mv -T "$COPY_TMP" "$target" 2>/dev/null; then
+ [ -e "$target" ] || die "could not rename the copy into $target"
+ echo "exists: $target"
+ return 0
+ fi
+ trap - EXIT
+ echo "copied: $target"
+ echo "install it with: scripts/webui-service.sh install --copy $id, with the unit's flags"
+}
+
[ $# -ge 1 ] || usage
cmd="$1"; shift
case "$cmd" in
+ copy) copy_commit "$@" ;;
render)
parse_flags "$@"
- [ "$DIR_FLAGS" = 0 ] || usage
+ [ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
+ { [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
render ;;
install) install_unit "$@" ;;
uninstall) uninstall_unit "$@" ;;