webui: Console pinned copy, update step and service hardening (#1571)

Row 77, round 3 (round 2 rebased onto row 95). Rocko built it. This
covers row 75's N1-N5 and Vikunja 146 and 147. `install --copy ID`
refuses unless every ExecStart path of the bus unit, read from
`systemctl --user show` so drop-ins count, lies inside that copy. It
also refuses when the unit isn't loaded, no unit is loaded, ExecStart
has no path, or systemctl fails. COPY_PATHS gains packages/tasks/src and
packages/tasks/fields.json. Every copy directory is 0555. TOOLS.md and
the webui README give the removal command and the rollback floor.

Candidate manifest d9880f4c92d1d976027562a03cc5dd07512954e86f49f1da0ebb34e642a955f5
(10 paths, 8 applied by the patch, 2 packet files already committed),
base 2e26fbe1, packet agents/rocko/work/queue-77/r2b/. Approvals on round
3: Darkwing 27979 and Filbert 27985 (Filbert round 2: 27898). Sage's
gate rerun 22:32:51Z-22:35:12Z on 459c4fc6 plus the patch (row 92
included), manifest 10/10: webui 101, control-board 124, and every
scripts/test-*.sh exited 0 (test-release 4, Docker cases skipped at a
dead socket).

Live at the next Console install and restart (decision 109). Mos makes
that call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 17:35:26 -05:00
co-authored by Claude Opus 5.5
parent cca5f23eaf
commit 0d10cf0798
8 changed files with 921 additions and 94 deletions
+65 -12
View File
@@ -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,71 @@ 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 and directory read-only
(so a writer that replaces by rename can't change it either), 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. The bus
child is the human transport, `packages/bus/src/human-cli.mjs`, and the bus
checks that path against its own, so the bus must run from the same copy
(below). `install` refuses without `--copy`, or when the copy is missing.
`render` without `--copy` prints the unit for the checkout, as before.
`install --copy ID` also refuses unless the bus runs from the same copy
(round 2, #225). The bus accepts a human proof only from its own
`packages/bus/src/human-cli.mjs`, so a Console on a copy whose bus runs
from the checkout would fail every bus call with `human-required`. With
`--business ID`, install reads `[email protected]`; without it, every
loaded `mosaic-bus@` unit. It reads the effective ExecStart from
`systemctl --user show -p LoadState -p ExecStart`, so a drop-in counts,
and every ExecStart path must lie inside the copy. It refuses, naming both
paths, when one doesn't; when the unit is not installed or none is loaded;
when ExecStart has no path; and when the systemctl call fails. The check
runs before the unit state read and changes nothing.
`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, which must be at or after row 77's landing: an older `cli.mjs` refuses
serve's `--checkout` ("unknown option"). The queue view isn't pinned: it
reads the checkout, so it shows the queue as it is now. Copies are kept
until someone removes them, with `chmod -R u+w DIR && rm -rf DIR` since
they are read-only; nothing prunes them. The step can't be used yet: the
bus still runs from the checkout (`copy` doesn't include
`scripts/mosaic`), so `install --copy ID` refuses until a later row puts
the bus on the copy.
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
+19
View File
@@ -110,6 +110,25 @@ 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, so the queue view isn't pinned and shows the queue as it is
now. The update step is `copy`, stop, `install --copy ID` with the same
flags, start; rollback is the same with the earlier id (one at or after
row 77's landing, since an older `cli.mjs` refuses `--checkout`), and
copies are kept (they are read-only, files and directories; remove one
with `chmod -R u+w DIR && rm -rf DIR`). `install --copy ID` refuses unless
the bus unit runs from the same copy, because the bus accepts a human
proof only from its own `human-cli.mjs`. The bus still runs from the
checkout, so the update step can't be used until a later row puts the bus
on the copy. 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
+14 -3
View File
@@ -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}`);
+5 -3
View File
@@ -168,14 +168,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'); }
};
@@ -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
+256
View File
@@ -0,0 +1,256 @@
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, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fixture } 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 copy's directories are read-only, so removal gives write back first,
// as the docs' removal command does.
function tmp(t, prefix) {
const root = mkdtempSync(join(tmpdir(), prefix));
t.after(() => { execFileSync('chmod', ['-R', 'u+w', root]); rmSync(root, { recursive: true, force: true }); });
return root;
}
// 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);
// The commit leaves thousands of loose objects; a detached gc --auto
// would still be deleting them when the cleanup's chmod walks .git.
git(repo, 'config', 'gc.auto', '0');
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', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
assert.ok(existsSync(join(target, 'packages', 'tasks', 'fields.json')), 'the task field table');
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`);
// Directories too, the top one included, so a writer that replaces by
// rename can neither change a file nor add one.
const dirs = [target, ...readdirSync(target, { withFileTypes: true, recursive: true }).filter((e) => e.isDirectory()).map((e) => join(e.parentPath, e.name))];
assert.ok(dirs.length > 10, String(dirs.length));
for (const d of dirs) assert.equal(statSync(d).mode & 0o777, 0o555, `${d} mode`);
const config = join(target, 'scripts', 'mosaic-config.mjs');
const before = readFileSync(config, 'utf8');
const sed = await run(dir, '/usr/bin/sed', ['-i', 's/^/x/', config]);
assert.notEqual(sed.status, 0, 'sed -i replaced a file in the copy');
assert.equal(readFileSync(config, 'utf8'), before);
assert.throws(() => writeFileSync(join(target, 'scripts', 'added.mjs'), ''), { code: 'EACCES' });
// 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/], ['.refactor', /^webui-service: commit must be a commit id or branch name: \.refactor$/m]]) {
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');
});
test('a copy that loses the rename race leaves no read-only temp directory behind', async (t) => {
const { dir, script, head } = fixtureRepo(t);
const copies = join(dir, 'copies');
// A stand-in mv that makes the target appear first, as a concurrent copy
// would, then fails: copy says exists: and its trap removes the temp copy.
const bin = join(dir, 'bin');
mkdirSync(bin);
writeFileSync(join(bin, 'mv'), `#!/bin/sh\nfor a; do t="$a"; done\nmkdir "$t"\nexit 1\n`, { mode: 0o755 });
const env = { PATH: `${bin}:${NODE_DIR}:/usr/bin:/bin`, HOME: dir, XDG_CONFIG_HOME: join(dir, 'config') };
const child = spawn(script, ['copy', 'refactor', '--copies', copies], { env });
let stdout = '';
child.stdout.on('data', (d) => { stdout += d; });
const [status] = await once(child, 'close');
assert.equal(status, 0);
assert.equal(stdout, `exists: ${join(copies, head)}\n`);
assert.deepEqual(readdirSync(copies), [head], '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']);
// Row 61's field table loads from the copy's packages/tasks.
const fields = await get('/api/task-fields');
assert.equal(fields.status, 200, JSON.stringify(fields.body));
assert.deepEqual(fields.body, JSON.parse(readFileSync(join(REPO, 'packages', 'tasks', 'fields.json'), 'utf8')));
// 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');
});
+333 -49
View File
@@ -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,35 @@ 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 = '') {
// The bus unit as `systemctl show -p LoadState -p ExecStart` prints it,
// started from `root`.
const busShow = (root) => `LoadState=loaded\nExecStart={ path=${root}/scripts/mosaic ; argv[]=${root}/scripts/mosaic bus start acme ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\n`;
const BUS_UNITS = '[email protected] loaded active running Mosaic bus host (acme)\n';
// A stand-in systemctl that prints `lines` for the Console's list-units and
// logs every call. With `listFails`, that list-units exits 1 after printing
// to stderr. The bus calls install makes are answered apart: `busUnits` for
// the mosaic-bus@ list-units, and `bus` for show, by default a bus started
// from the run's own copy ($HOME, as run() sets it). `busFails` makes both
// bus calls fail.
function standIn(dir, lines = '', { listFails = false, bus = null, busUnits = BUS_UNITS, busFails = false } = {}) {
const bin = join(dir, 'bin');
mkdirSync(bin);
writeFileSync(join(dir, 'list-units.txt'), lines);
writeFileSync(join(dir, 'bus-units.txt'), busUnits);
if (bus !== null) writeFileSync(join(dir, 'bus-show.txt'), bus);
const fail = 'echo "Failed to connect to bus" >&2; exit 1';
const show = bus !== null ? `cat "${dir}/bus-show.txt"` : `r="$HOME/.local/share/mosaic-console/${COPY_ID}"; printf '%s' "${busShow('$r')}"`;
writeFileSync(join(bin, 'systemctl'), `#!/bin/sh
echo "$*" >> "${dir}/calls.txt"
case "$*" in
*list-units*) cat "${dir}/list-units.txt" ;;
*list-units*mosaic-bus@*) ${busFails ? fail : `cat "${dir}/bus-units.txt"`} ;;
*show*mosaic-bus@*) ${busFails ? fail : show} ;;
*list-units*) ${listFails ? fail : `cat "${dir}/list-units.txt"`} ;;
*is-enabled*) echo enabled ;;
*is-active*) echo active ;;
esac
@@ -65,9 +95,20 @@ 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');
}
// Every call but the reads: the Console's state and install's bus check.
const READS = /^--user (list-units --no-legend --plain mosaic-webui\.service|list-units --all --no-legend --plain mosaic-bus@\*\.service|show mosaic-bus@\S+ -p LoadState -p ExecStart)$/;
const otherCalls = (file) => readFileSync(file, 'utf8').split('\n').filter(Boolean).filter((c) => !READS.test(c));
// 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 +136,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 +194,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 +204,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 --all --no-legend --plain mosaic-bus@*.service\n--user show [email protected] -p LoadState -p ExecStart\n--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 +271,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 +289,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 +320,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 +342,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 +364,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 +379,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 +416,234 @@ 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 otherBus = join(dir, 'other-bus');
mkdirSync(otherBus);
const r = await run(dir, ['install', ...INSTALL, '--copies', other, '--dir', unitDir, '--no-reload'], standIn(otherBus, '', { bus: busShow(join(other, COPY_ID)) }));
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)));
});
// The bus accepts only its own human-cli.mjs (packages/bus/src/human.mjs),
// so a Console on a copy needs its bus on that same copy. install reads the
// bus unit's effective ExecStart (a drop-in counts) and refuses otherwise.
test('install refuses unless the bus unit runs from the same copy, naming both paths, and changes nothing', async (t) => {
const dir = tmp(t);
const unitDir = join(dir, 'units');
const copy = copyRoot(dir);
const install = (bin, extra = []) => run(dir, ['install', ...INSTALL, ...extra, '--dir', unitDir, '--no-reload'], bin);
let n = 0;
const sys = (opts, lines = '') => { const d = join(dir, `s${n++}`); mkdirSync(d); return { d, bin: standIn(d, lines, opts) }; };
const refused = (r, line) => {
assert.equal(r.status, 1);
assert.equal(r.stderr, `webui-service: refusing: ${line}\n`);
assert.ok(!existsSync(unitDir));
};
// The bus runs from the checkout, as mosaic-bus@ does today: refused,
// with the bus's path and the copy's.
let s = sys({ bus: busShow(REPO) });
refused(await install(s.bin), `[email protected] runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
// Refused before the Console state is read.
assert.ok(!readFileSync(join(s.d, 'calls.txt'), 'utf8').includes('mosaic-webui'));
// The same with --business, which names the one unit to read.
s = sys({ bus: busShow(REPO) });
refused(await install(s.bin, ['--business', 'acme']), `[email protected] runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
assert.equal(readFileSync(join(s.d, 'calls.txt'), 'utf8'), '--user show [email protected] -p LoadState -p ExecStart\n');
// A path that only starts with the copy's name is not inside it.
s = sys({ bus: busShow(`${copy}-old`) });
refused(await install(s.bin), `[email protected] runs from ${copy}-old/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
// Two loaded bus units: each must run from the copy.
s = sys({ busUnits: `${BUS_UNITS}[email protected] loaded active running Mosaic bus host (other)\n` });
assert.equal((await install(s.bin)).status, 0);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@other\.service -p LoadState -p ExecStart/);
rmSync(unitDir, { recursive: true });
// No bus unit installed for the business: refused.
s = sys({ bus: 'LoadState=not-found\nExecStart=\n' });
refused(await install(s.bin, ['--business', 'acme']), `no bus unit [email protected] is installed (LoadState not-found); the Console on the copy ${copy} needs its bus on that copy`);
// No --business and no mosaic-bus@ unit loaded: refused.
s = sys({ busUnits: '' });
refused(await install(s.bin), `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`);
// A loaded unit with no ExecStart path: refused.
s = sys({ bus: 'LoadState=loaded\nExecStart=\n' });
refused(await install(s.bin), '[email protected] has no ExecStart path, so where its bus runs from is unknown; nothing was changed');
// A failed systemctl call: one line, nothing changed.
s = sys({ busFails: true });
refused(await install(s.bin), 'systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed');
s = sys({ busFails: true });
refused(await install(s.bin, ['--business', 'acme']), 'systemctl --user show [email protected] failed, so the bus unit is unknown; nothing was changed');
// The bus runs from the same copy: accepted, with or without --business.
for (const extra of [[], ['--business', 'acme']]) {
s = sys({ bus: busShow(copy) });
const r = await install(s.bin, extra);
assert.equal(r.status, 0, r.stderr);
assert.ok(existsSync(join(unitDir, 'mosaic-webui.service')));
rmSync(unitDir, { recursive: true });
}
});
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)), []);
});
+221 -22
View File
@@ -1,18 +1,44 @@
#!/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. install
# refuses unless the bus unit runs from the same copy (bus_on_copy below),
# and nothing puts the bus on a copy yet, so the step can't be used until a
# later row does.
#
# 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 +55,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 +91,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 +139,69 @@ 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 nine src directories
# (packages/tasks/src since row 61: reads.mjs imports its field table
# loader, which reads packages/tasks/fields.json);
# 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 pinned-copy test serves from a copy: a missing import fails it at
# load, and a missing file read at request time (fields.json, RELEASE,
# roles/) fails the route that reads it, which the test also GETs.
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
packages/tasks/src
packages/tasks/fields.json
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 +212,68 @@ 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}'
}
# The bus host proves a human by checking that the calling process runs its
# own packages/bus/src/human-cli.mjs (packages/bus/src/human.mjs), and the
# Console spawns the one beside its own files. So a Console on a copy works
# only with a bus on that same copy: install refuses unless every bus unit
# it would call has an ExecStart path inside the copy. With --business the
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
# has loaded, and none loaded refuses. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p
if [ -n "$BUSINESS" ]; then
units="mosaic-bus@$BUSINESS.service"
else
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
[ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
fi
for unit in $units; do
out="$(systemctl --user show "$unit" -p LoadState -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
load="$(printf '%s\n' "$out" | sed -n 's/^LoadState=//p')"
[ "$load" = loaded ] || die "refusing: no bus unit $unit is installed (LoadState ${load:-unknown}); the Console on the copy $copy needs its bus on that copy"
paths="$(printf '%s\n' "$out" | sed -n 's/^ExecStart=//p' | grep -o 'path=[^ ;]*' | sed 's/^path=//' || true)"
[ -n "$paths" ] || die "refusing: $unit has no ExecStart path, so where its bus runs from is unknown; nothing was changed"
for p in $paths; do
case "$p" in "$copy"/*) ;; *) die "refusing: $unit runs from $p, not the copy $copy; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy" ;; esac
done
done
}
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
bus_on_copy "$COPIES/$COPY_ID"
# N1: a running unit would take the new ExecStart at its next restart.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it"
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
@@ -163,13 +306,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 +326,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 +336,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 +372,51 @@ 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 'chmod -R u+w "$COPY_TMP" 2>/dev/null; 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"
# Read-only files and directories: a writer that replaces by rename
# (git, editors, sed -i) can't change a copy either.
find "$COPY_TMP" -type f -exec chmod a-w {} +
find "$COPY_TMP" -type d -exec chmod 0555 {} +
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 "$@" ;;