#!/usr/bin/env bash # `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. # # 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`; # refuses while the unit is running # uninstall [--dir DIR] [--no-reload] # 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 # NAME --auth-header HEADER --allow-user ID, all three or none, and --send # (row 83), taken only with the preview set: the unit's Console then sends # human messages to roles and needs the system config at start. 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. Jason starts 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 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 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 BUSINESS="" BOARD="" PUBLIC_HOST="" AUTH_HEADER="" ALLOW_USER="" SEND=0 SERVE_SET=0 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 ;; --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 ;; --send) SEND=1; SERVE_SET=1; shift ;; *) 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)) # serve refuses --send without the preview set; refuse it here first, # so no unit is written that would only exit 2 at start. [ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)" 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 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 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 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 [ -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")" 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 <&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 # 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 echo "send: off (read-only)" 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")" } # 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 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage { [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage render ;; install) install_unit "$@" ;; uninstall) uninstall_unit "$@" ;; status) status_unit "$@" ;; -h|--help) echo "$USAGE" ;; *) usage ;; esac