Committed by sage for rocko (no credential). Candidate manifest 7dc571db, packet manifest 0bc9a870, base 43d2328f.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
524 lines
25 KiB
Diff
524 lines
25 KiB
Diff
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
|
|
index 9a28a090..631e1814 100644
|
|
--- a/docs/TOOLS.md
|
|
+++ b/docs/TOOLS.md
|
|
@@ -238,6 +238,37 @@ stdin and stdout it exits 4 with the usage.
|
|
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
|
|
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
|
|
|
+## 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 uninstall [--dir DIR] [--no-reload]
|
|
+scripts/webui-service.sh status [--dir DIR]
|
|
+# FLAGS: [--port N] [--business ID] [--board URL]
|
|
+# [--public-host NAME --auth-header HEADER --allow-user ID]
|
|
+```
|
|
+
|
|
+The systemd user unit `mosaic-webui.service` runs the Console,
|
|
+`node packages/webui/src/cli.mjs serve`, with serve's flags as given:
|
|
+`--port` defaults to 7332 (7330 is the operator's own Console), and the
|
|
+preview set goes all three or none. 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 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.
|
|
+
|
|
+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; an agent starting it, even through systemd, works around the
|
|
+human proof (lead decisions 62 and 94). Jason starts it from his own shell:
|
|
+`systemctl --user enable --now mosaic-webui`.
|
|
+Exit codes: `0` ok · `1` failed or refused · `4` usage.
|
|
+
|
|
## Discord connector (`scripts/discord.sh`)
|
|
|
|
```bash
|
|
diff --git a/packages/webui/systemd/mosaic-webui.service.in b/packages/webui/systemd/mosaic-webui.service.in
|
|
new file mode 100644
|
|
index 00000000..081bd963
|
|
--- /dev/null
|
|
+++ b/packages/webui/systemd/mosaic-webui.service.in
|
|
@@ -0,0 +1,35 @@
|
|
+# 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.
|
|
+#
|
|
+# 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.
|
|
+[Unit]
|
|
+Description=Mosaic Console
|
|
+Documentation=file://@REPO@/packages/webui/README.md
|
|
+StartLimitIntervalSec=600
|
|
+StartLimitBurst=5
|
|
+
|
|
+[Service]
|
|
+Type=simple
|
|
+WorkingDirectory=@REPO@
|
|
+Environment=PATH=@PATH@
|
|
+# The server binds 127.0.0.1 only. Usage, config and listen refusals
|
|
+# (a port already in use included) exit 2 and are not retried; fix the
|
|
+# flags or free the port, then start it again.
|
|
+ExecStart=@NODE@ @REPO@/packages/webui/src/cli.mjs serve @FLAGS@
|
|
+Restart=on-failure
|
|
+RestartSec=15
|
|
+RestartPreventExitStatus=2
|
|
+# `systemctl --user stop` sends SIGTERM; the server stops accepting and
|
|
+# closes. Open connections that outlast TimeoutStopSec are killed.
|
|
+KillSignal=SIGTERM
|
|
+TimeoutStopSec=30
|
|
+NoNewPrivileges=yes
|
|
+
|
|
+[Install]
|
|
+WantedBy=default.target
|
|
diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs
|
|
new file mode 100644
|
|
index 00000000..d50b6652
|
|
--- /dev/null
|
|
+++ b/packages/webui/tests/service.test.mjs
|
|
@@ -0,0 +1,220 @@
|
|
+import { test } from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import { spawn } from 'node:child_process';
|
|
+import { createServer } from 'node:http';
|
|
+import { once } from 'node:events';
|
|
+import { chmodSync, 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';
|
|
+
|
|
+// 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.
|
|
+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'];
|
|
+
|
|
+function tmp(t) {
|
|
+ const dir = mkdtempSync(join(tmpdir(), 'webui-service-'));
|
|
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
+ return dir;
|
|
+}
|
|
+
|
|
+// A stand-in systemctl that prints `lines` for list-units and logs every call.
|
|
+function standIn(dir, lines = '') {
|
|
+ const bin = join(dir, 'bin');
|
|
+ mkdirSync(bin);
|
|
+ writeFileSync(join(dir, 'list-units.txt'), lines);
|
|
+ writeFileSync(join(bin, 'systemctl'), `#!/bin/sh
|
|
+echo "$*" >> "${dir}/calls.txt"
|
|
+case "$*" in
|
|
+ *list-units*) cat "${dir}/list-units.txt" ;;
|
|
+ *is-enabled*) echo enabled ;;
|
|
+ *is-active*) echo active ;;
|
|
+esac
|
|
+exit 0
|
|
+`);
|
|
+ chmodSync(join(bin, 'systemctl'), 0o755);
|
|
+ return bin;
|
|
+}
|
|
+
|
|
+// 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 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 };
|
|
+}
|
|
+
|
|
+const execStart = (unit) => unit.split('\n').find((l) => l.startsWith('ExecStart='));
|
|
+
|
|
+test('render prints the unit without the preview flags', async (t) => {
|
|
+ const dir = tmp(t);
|
|
+ const r = await run(dir, ['render']);
|
|
+ assert.equal(r.status, 0, r.stderr);
|
|
+ assert.match(r.stdout, new RegExp(`^WorkingDirectory=${REPO.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
|
|
+ assert.match(r.stdout, /^Environment=PATH=\S*\/usr\/local\/bin:\/usr\/bin:\/bin$/m);
|
|
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7332`);
|
|
+ for (const line of ['Restart=on-failure', 'RestartPreventExitStatus=2', 'NoNewPrivileges=yes', 'WantedBy=default.target']) {
|
|
+ assert.ok(r.stdout.split('\n').includes(line), line);
|
|
+ }
|
|
+ assert.doesNotMatch(r.stdout, /@[A-Z]+@/, 'every placeholder is filled');
|
|
+});
|
|
+
|
|
+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`);
|
|
+});
|
|
+
|
|
+test('a partial preview set is refused', async (t) => {
|
|
+ const dir = tmp(t);
|
|
+ for (const args of [PREVIEW.slice(0, 2), PREVIEW.slice(2, 6), [...PREVIEW.slice(0, 2), ...PREVIEW.slice(4, 6)]]) {
|
|
+ const r = await run(dir, ['render', ...args]);
|
|
+ assert.equal(r.status, 1, args.join(' '));
|
|
+ assert.equal(r.stdout, '');
|
|
+ assert.match(r.stderr, /all three or none/);
|
|
+ }
|
|
+});
|
|
+
|
|
+test('a value the unit cannot carry is refused, for every flag', async (t) => {
|
|
+ const dir = tmp(t);
|
|
+ const set = (flag, value) => {
|
|
+ const preview = PREVIEW.slice();
|
|
+ const i = preview.indexOf(flag);
|
|
+ if (i >= 0) { preview[i + 1] = value; return preview; }
|
|
+ return [flag, value];
|
|
+ };
|
|
+ for (const flag of ['--business', '--board', '--public-host', '--auth-header', '--allow-user']) {
|
|
+ for (const bad of [' x', '%n', '\n', '$HOME', '"', '\\', '|', '&']) {
|
|
+ const value = flag === '--board' ? `http://127.0.0.1:7331${bad}` : `a${bad}b`;
|
|
+ const r = await run(dir, ['render', ...set(flag, value)]);
|
|
+ assert.equal(r.status, 1, `${flag} ${JSON.stringify(value)}`);
|
|
+ assert.equal(r.stdout, '', `${flag} ${JSON.stringify(value)}`);
|
|
+ }
|
|
+ }
|
|
+ for (const port of ['0', '65536', '7a', '', '-1', '999999']) {
|
|
+ const r = await run(dir, ['render', '--port', port]);
|
|
+ assert.equal(r.status, 1, `port ${JSON.stringify(port)}`);
|
|
+ }
|
|
+ for (const args of [['--business', 'Mosaic'], ['--business', '-x'], ['--business', 'a'.repeat(65)], ['--board', 'https://127.0.0.1:7331'], ['--board', '127.0.0.1:7331'], set('--public-host', 'console.example.com:443'), set('--public-host', 'https://console.example.com'), set('--auth-header', 'X_uid')]) {
|
|
+ const r = await run(dir, ['render', ...args]);
|
|
+ assert.equal(r.status, 1, args.join(' '));
|
|
+ }
|
|
+});
|
|
+
|
|
+test('the rendered unit names none of the human proof agent markers and sets no agent variable', async (t) => {
|
|
+ const dir = tmp(t);
|
|
+ const source = readFileSync(join(REPO, 'packages', 'bus', 'src', 'human.mjs'), 'utf8');
|
|
+ const markers = [...source.match(/const MARKERS = \[([^\]]*)\]/)[1].matchAll(/'([A-Z_]+)'/g)].map((m) => m[1]);
|
|
+ assert.ok(markers.length >= 7 && markers.includes('MOSAIC_BUS_CAP') && markers.includes('CLAUDECODE'), markers.join(' '));
|
|
+ for (const args of [[], [...PREVIEW, '--business', 'mosaic']]) {
|
|
+ const r = await run(dir, ['render', ...args]);
|
|
+ assert.equal(r.status, 0, r.stderr);
|
|
+ for (const marker of markers) assert.ok(!r.stdout.includes(marker), marker);
|
|
+ const envLines = r.stdout.split('\n').filter((l) => /^\s*(Environment|EnvironmentFile|PassEnvironment|UnsetEnvironment)\s*=/.test(l));
|
|
+ assert.deepEqual(envLines.map((l) => l.split('=')[1]), ['PATH']);
|
|
+ assert.doesNotMatch(r.stdout, /^[^#]*\b(MOSAIC_|CLAUDE|CODEX|PI_)/m);
|
|
+ }
|
|
+});
|
|
+
|
|
+test('install into --dir with --no-reload writes the file whole, then reports unchanged', async (t) => {
|
|
+ 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);
|
|
+ assert.equal(r.status, 0, r.stderr);
|
|
+ const target = join(unitDir, 'mosaic-webui.service');
|
|
+ assert.match(r.stdout, new RegExp(`^written: ${target}$`, 'm'));
|
|
+ assert.match(r.stdout, /systemctl --user enable --now mosaic-webui/);
|
|
+ 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);
|
|
+ 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');
|
|
+ // 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);
|
|
+ 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.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);
|
|
+ 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');
|
|
+});
|
|
+
|
|
+test('uninstall refuses while the unit is active, 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']) {
|
|
+ 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}`);
|
|
+ }
|
|
+ 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);
|
|
+ assert.match(r.stdout, new RegExp(`^removed: ${target}$`, 'm'));
|
|
+ assert.match(r.stdout, /^daemon-reload done$/m);
|
|
+ assert.ok(!existsSync(target));
|
|
+ const absent = await run(dir, ['uninstall', '--dir', unitDir, '--no-reload'], bin);
|
|
+ assert.equal(absent.status, 0);
|
|
+ assert.match(absent.stdout, /^absent: /m);
|
|
+});
|
|
+
|
|
+test('status prints the unit state and GET /healthz on the installed port', async (t) => {
|
|
+ const dir = tmp(t);
|
|
+ const unitDir = join(dir, 'units');
|
|
+ const bin = standIn(dir);
|
|
+ const server = createServer((req, res) => {
|
|
+ if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}\n'); return; }
|
|
+ res.writeHead(404); res.end();
|
|
+ });
|
|
+ server.listen(0, '127.0.0.1');
|
|
+ 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);
|
|
+ const r = await run(dir, ['status', '--dir', unitDir], bin);
|
|
+ assert.equal(r.status, 0, r.stderr);
|
|
+ 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'));
|
|
+ server.close();
|
|
+ await once(server, 'close');
|
|
+ const down = await run(dir, ['status', '--dir', unitDir], bin);
|
|
+ assert.equal(down.status, 0, down.stderr);
|
|
+ assert.match(down.stdout, /^healthz: \S+ unreachable \(ECONNREFUSED\)$/m);
|
|
+ const none = await run(dir, ['status', '--dir', join(dir, 'empty')], bin);
|
|
+ assert.match(none.stdout, /^healthz: not checked; no unit at /m);
|
|
+});
|
|
+
|
|
+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']]) {
|
|
+ const r = await run(dir, args);
|
|
+ assert.equal(r.status, 4, args.join(' '));
|
|
+ assert.match(r.stderr, /^usage: scripts\/webui-service\.sh/);
|
|
+ }
|
|
+});
|
|
diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh
|
|
new file mode 100755
|
|
index 00000000..4ff06833
|
|
--- /dev/null
|
|
+++ b/scripts/webui-service.sh
|
|
@@ -0,0 +1,208 @@
|
|
+#!/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
|
|
+# 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]
|
|
+# write the unit (write to a temp file, then
|
|
+# rename) and run `systemctl --user daemon-reload`
|
|
+# uninstall [--dir DIR] [--no-reload]
|
|
+# remove the unit; refuses while it is active
|
|
+# status [--dir DIR] unit state, then GET /healthz on its port
|
|
+#
|
|
+# 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
|
|
+# NAME --auth-header HEADER --allow-user ID, all three or none. systemd
|
|
+# expands % and $ and splits on spaces, so each value is held to a plain
|
|
+# character set and refused otherwise. None is a secret.
|
|
+#
|
|
+# 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. An agent starting it, even detached through systemd, works
|
|
+# around the human proof (lead decisions 62 and 94). Jason starts it from
|
|
+# his own shell: `systemctl --user enable --now mosaic-webui`.
|
|
+#
|
|
+# 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]"
|
|
+
|
|
+die() { echo "webui-service: $*" >&2; exit 1; }
|
|
+usage() { echo "$USAGE" >&2; exit 4; }
|
|
+
|
|
+UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
+RELOAD=1
|
|
+DIR_FLAGS=0
|
|
+PORT=7332
|
|
+BUSINESS=""
|
|
+BOARD=""
|
|
+PUBLIC_HOST=""
|
|
+AUTH_HEADER=""
|
|
+ALLOW_USER=""
|
|
+SERVE_SET=0
|
|
+
|
|
+parse_flags() {
|
|
+ while [ $# -gt 0 ]; do
|
|
+ case "$1" in
|
|
+ --dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; DIR_FLAGS=1; shift 2 ;;
|
|
+ --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 ;;
|
|
+ --board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
|
|
+ --public-host) [ $# -ge 2 ] || usage; PUBLIC_HOST="$2"; SERVE_SET=1; shift 2 ;;
|
|
+ --auth-header) [ $# -ge 2 ] || usage; AUTH_HEADER="$2"; SERVE_SET=1; shift 2 ;;
|
|
+ --allow-user) [ $# -ge 2 ] || usage; ALLOW_USER="$2"; SERVE_SET=1; shift 2 ;;
|
|
+ *) usage ;;
|
|
+ esac
|
|
+ done
|
|
+}
|
|
+
|
|
+# The serve flags as one ExecStart fragment. Each character set excludes
|
|
+# space, %, $, quotes, backslash, & and |, so neither systemd nor the sed
|
|
+# below can read a value as anything but itself.
|
|
+serve_flags() {
|
|
+ case "$PORT" in ''|*[!0-9]*) die "port must be 1..65535: $PORT" ;; esac
|
|
+ [ "${#PORT}" -le 5 ] && [ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || die "port must be 1..65535: $PORT"
|
|
+ local flags="--port $PORT"
|
|
+ if [ -n "$BUSINESS" ]; then
|
|
+ case "$BUSINESS" in [a-z0-9]*) ;; *) die "business must be a business id: $BUSINESS" ;; esac
|
|
+ case "$BUSINESS" in *[!a-z0-9._-]*) die "business must be a business id: $BUSINESS" ;; esac
|
|
+ [ "${#BUSINESS}" -le 64 ] || die "business must be a business id: $BUSINESS"
|
|
+ flags="$flags --business $BUSINESS"
|
|
+ fi
|
|
+ if [ -n "$BOARD" ]; then
|
|
+ # serve checks the rest (a loopback origin, no path or credentials).
|
|
+ case "$BOARD" in http://?*) ;; *) die "board must be an http:// URL: $BOARD" ;; esac
|
|
+ case "$BOARD" in *[!A-Za-z0-9.:/_-]*) die "board contains a character the unit cannot carry: $BOARD" ;; esac
|
|
+ flags="$flags --board $BOARD"
|
|
+ fi
|
|
+ local preview=0
|
|
+ [ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
|
|
+ [ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
|
|
+ [ -z "$ALLOW_USER" ] || preview=$((preview + 1))
|
|
+ if [ "$preview" -ne 0 ]; then
|
|
+ [ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
|
|
+ case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
|
|
+ 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 contains a character the unit cannot carry: $ALLOW_USER" ;; esac
|
|
+ flags="$flags --public-host $PUBLIC_HOST --auth-header $AUTH_HEADER --allow-user $ALLOW_USER"
|
|
+ fi
|
|
+ echo "$flags"
|
|
+}
|
|
+
|
|
+render() {
|
|
+ [ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
|
|
+ local node
|
|
+ node="$(command -v node || true)"
|
|
+ [ -n "$node" ] || die "node not found on PATH"
|
|
+ # systemd does not look an ExecStart command up in Environment=PATH,
|
|
+ # so the unit runs node by the path found here, kept as given.
|
|
+ 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 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"
|
|
+}
|
|
+
|
|
+install_unit() {
|
|
+ parse_flags "$@"
|
|
+ local unit
|
|
+ unit="$(render)" || exit 1
|
|
+ mkdir -p "$UNIT_DIR"
|
|
+ local target="$UNIT_DIR/$UNIT_NAME" tmp
|
|
+ tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
|
|
+ printf '%s\n' "$unit" > "$tmp"
|
|
+ chmod 0644 "$tmp"
|
|
+ if [ -f "$target" ] && cmp -s "$tmp" "$target"; then
|
|
+ rm -f "$tmp"
|
|
+ echo "unchanged: $target"
|
|
+ else
|
|
+ mv -f "$tmp" "$target"
|
|
+ echo "written: $target"
|
|
+ fi
|
|
+ echo "serve: $(sed -n 's/^ExecStart=//p' "$target")"
|
|
+ if [ "$RELOAD" = 1 ]; then
|
|
+ systemctl --user daemon-reload || die "daemon-reload failed"
|
|
+ echo "daemon-reload done"
|
|
+ fi
|
|
+ cat <<MSG
|
|
+this script starts nothing: the Console holds the human's capability, so the
|
|
+human starts it from their own shell:
|
|
+ systemctl --user enable --now mosaic-webui start now and at login
|
|
+ systemctl --user status mosaic-webui
|
|
+ journalctl --user -u mosaic-webui -f the Console's log
|
|
+ systemctl --user stop mosaic-webui SIGTERM; restartable
|
|
+ scripts/webui-service.sh status unit state and /healthz
|
|
+survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
|
|
+MSG
|
|
+}
|
|
+
|
|
+uninstall_unit() {
|
|
+ parse_flags "$@"
|
|
+ [ "$SERVE_SET" = 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
|
|
+ if [ -f "$target" ]; then
|
|
+ rm -f "$target"
|
|
+ echo "removed: $target"
|
|
+ else
|
|
+ echo "absent: $target"
|
|
+ fi
|
|
+ if [ "$RELOAD" = 1 ]; then
|
|
+ systemctl --user daemon-reload || die "daemon-reload failed"
|
|
+ echo "daemon-reload done"
|
|
+ fi
|
|
+}
|
|
+
|
|
+status_unit() {
|
|
+ parse_flags "$@"
|
|
+ [ "$SERVE_SET" = 0 ] && [ "$RELOAD" = 1 ] || usage
|
|
+ local target="$UNIT_DIR/$UNIT_NAME"
|
|
+ echo "unit: $UNIT_NAME"
|
|
+ echo " enabled: $(systemctl --user is-enabled "$UNIT_NAME" 2>&1 || true)"
|
|
+ echo " active: $(systemctl --user is-active "$UNIT_NAME" 2>&1 || true)"
|
|
+ systemctl --user show "$UNIT_NAME" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result 2>/dev/null | sed 's/^/ /' || true
|
|
+ if [ ! -f "$target" ]; then
|
|
+ echo "healthz: not checked; no unit at $target"
|
|
+ return 0
|
|
+ fi
|
|
+ local port
|
|
+ port="$(sed -n 's/^ExecStart=.* --port \([0-9][0-9]*\).*$/\1/p' "$target")"
|
|
+ [ -n "$port" ] || { echo "healthz: not checked; no --port in $target"; return 0; }
|
|
+ local url="http://127.0.0.1:$port/healthz"
|
|
+ echo "healthz: $url $(node -e '
|
|
+fetch(process.argv[1], { signal: AbortSignal.timeout(5000) })
|
|
+ .then(async (res) => console.log(`${res.status} ${(await res.text()).trim()}`))
|
|
+ .catch((err) => console.log(`unreachable (${err.cause?.code ?? err.name})`));
|
|
+' "$url")"
|
|
+}
|
|
+
|
|
+[ $# -ge 1 ] || usage
|
|
+cmd="$1"; shift
|
|
+case "$cmd" in
|
|
+ render)
|
|
+ parse_flags "$@"
|
|
+ [ "$DIR_FLAGS" = 0 ] || usage
|
|
+ render ;;
|
|
+ install) install_unit "$@" ;;
|
|
+ uninstall) uninstall_unit "$@" ;;
|
|
+ status) status_unit "$@" ;;
|
|
+ -h|--help) echo "$USAGE" ;;
|
|
+ *) usage ;;
|
|
+esac
|