diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 89c857152..e52dcf1ce 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,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//`, by default +`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console//`. 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 `, 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 `mosaic-bus@ID.service`; 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 diff --git a/packages/webui/README.md b/packages/webui/README.md index cf91674c9..6656f1ff9 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -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 diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 618a2cf13..de03a5ee4 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 aa09515e3..588db4997 100644 --- a/packages/webui/src/reads.mjs +++ b/packages/webui/src/reads.mjs @@ -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'); } }; diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in index 081bd963d..6049de0b7 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 000000000..4103f533a --- /dev/null +++ b/packages/webui/tests/pinned-copy.test.mjs @@ -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', 'user.email=fixture@example.invalid', '-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'); +}); diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index da440d6b8..d1226b19b 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,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 = 'mosaic-bus@acme.service 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 mosaic-bus@acme.service -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', 'mosaic-bus@x.service 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), `mosaic-bus@acme.service 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']), `mosaic-bus@acme.service 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 mosaic-bus@acme.service -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), `mosaic-bus@acme.service 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}mosaic-bus@other.service 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 mosaic-bus@acme.service 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), 'mosaic-bus@acme.service 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 mosaic-bus@acme.service 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', 'jason@example.com']) { + 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', 'jason@example.com', 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 944bb97a9..b330e5cf4 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -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//, 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 mosaic-bus@ID.service; 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 "$@" ;;