Files
stack/scripts/webui-service.sh
T
jason.woltje 7247d80c7a row 196: human boundary on peer credentials, interpreter receipt and the Console tester socket (#1694)
Candidate 527b9a1e6 (gander/row196-r6-prd19, base f48725cfc), the 39 manifest paths of CANDIDATE.sha256 537d30aef70b6cd1 at ec1e7fad8. Duckworth approved round 1 outside B1/B2 (rev 2973, comment 30145) and round 2 merge-scoped (rev 2986, comment 30156). Gate gander-0066 on grant sage-2145: 2334 pass, 0 fail, 1 skip; mutants 38/38 killed. BUILD-LOG lines and Duckworth's C1 DEFERRED entry follow as a doc-only append.
2026-10-21 15:55:39 -05:00

513 lines
28 KiB
Bash
Executable File

#!/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/<full id>/, read-only;
# DIR defaults to
# ${XDG_DATA_HOME:-~/.local/share}/mosaic-stack/copies
# 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,
# the tester listener, 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),
# row 85 supplies the shared copy and bus unit flags; Mos owns switching.
#
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
# Console), --tester-socket (private Unix tester), or legacy --tester-port N
# (1024..65535, neither --port nor the tunnel's 7332; loopback on old copies),
# --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. --proof-header NAME --proof-file PATH
# go together under preview; only the path is carried here, never the secret.
# render checks ownership, mode and location without opening the proof file.
# --sso (Q175 phase 1, #1690): the Console signs people in itself from
# console.sso in config.json. It refuses the preview set and the proof pair,
# and --send takes it in place of the preview set. Nothing secret is carried.
#
# There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback
# listener binds the tester. 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.
#
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is mosaic-webui-NAME.service and sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, its bus units are mosaic-bus-NAME@,
# and render and install need --port, which may not be 7330, 7332 or 7333
# (exit 4, port-reserved). For every instance, install refuses a --port or
# --tester-port that another installed Console unit in the unit directory
# already takes (port-claimed). copy refuses --instance (exit 4), as
# stack-copy.sh copy does: every instance shares the copies. See
# scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then
echo "webui-service: copy takes no --instance; every instance shares the copies" >&2
exit 4
fi
instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] 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] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-socket | --tester-port N (legacy copies)] [--proof-header NAME --proof-file PATH]
--tester-port: legacy copies only; 1024..65535, different from --port and never 7332; loopback tester listener.
--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-stack/copies"
COPY_ID=""
COPY_FLAGS=0
ALLOW_WORKTREE=0
RELOAD=1
DIR_FLAGS=0
PORT=7332
PORT_SET=0
TESTER_PORT=""
TESTER_SET=0
TESTER_SOCKET=0
BUSINESS=""
BOARD=""
PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
SSO=0
PROOF_HEADER=""
PROOF_FILE=""
PROOF_SET=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"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--tester-socket) TESTER_SOCKET=1; SERVE_SET=1; shift ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; 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 ;;
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
--sso) SSO=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
done
# Validate here so a bad tester value exits 4 before render's existing
# operation-failure wrappers, and before install reads or changes units.
[ "$TESTER_SOCKET" = 0 ] || [ "$TESTER_SET" = 0 ] || usage
[ "$TESTER_SET" = 0 ] || tester_port_ok
}
# Row 190: a named instance's Console names its own port, outside the
# default's ports. Called by render and install only.
instance_ports_ok() {
[ "${#INSTANCE_RESERVED[@]}" -gt 0 ] || return 0
[ "$PORT_SET" = 1 ] || { echo "webui-service: stack instance $MOSAIC_INSTANCE needs --port; it has no default port" >&2; exit 4; }
local p r
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
for r in "${INSTANCE_RESERVED[@]}"; do
[ "$((10#$p))" != "$r" ] || { echo "webui-service: port-reserved: $p is reserved for the default instance (${INSTANCE_RESERVED[*]})" >&2; exit 4; }
done
done
}
# Row 190: refuse a port another installed Console unit already takes.
ports_unclaimed() {
local p holder
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$p")"; then
die "refusing port-claimed: port $p is taken by $UNIT_DIR/$holder; nothing was changed"
fi
done
}
tester_port_usage() {
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
exit 4
}
tester_port_ok() {
case "$TESTER_PORT" in ''|*[!0-9]*) tester_port_usage ;; esac
# At most five digits as written, before the zeros go, as serve and stack-release.sh count them.
[ "${#TESTER_PORT}" -le 5 ] || tester_port_usage
local tester_port="$TESTER_PORT"
# Compare decimal values, including values written with leading zeros.
while [ "${tester_port#0}" != "$tester_port" ]; do tester_port="${tester_port#0}"; done
[ -n "$tester_port" ] && [ "${#tester_port}" -le 5 ] || tester_port_usage
[ "$tester_port" -ge 1024 ] && [ "$tester_port" -le 65535 ] || tester_port_usage
[ "$tester_port" -ne 7332 ] || tester_port_usage
# Invalid main ports retain their existing operation-failure behavior.
if [[ "$PORT" =~ ^[0-9]+$ ]] && [ "${#PORT}" -le 5 ]; then
[ "$tester_port" -ne "$PORT" ] || tester_port_usage
fi
}
# 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 --sso with the preview set or the proof pair, and --send
# without --sso or the preview set; refuse them here first, so no unit is
# written that would only exit 2 at start.
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
fi
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso"
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
if [ "$PROOF_SET" = 1 ]; then
[ -n "$PROOF_HEADER" ] && [ -n "$PROOF_FILE" ] && [ "$preview" -eq 3 ] || die "proxy proof needs both flags and preview mode"
[[ "$PROOF_HEADER" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,63}$ ]] || die "invalid proxy proof header"
case "${PROOF_HEADER,,}" in host|origin|x-mosaic-tester|"${AUTH_HEADER,,}") die "proxy proof header must differ from Host, Origin, tester and auth headers" ;; esac
case "$PROOF_FILE" in /*) ;; *) die "proxy proof file must be an absolute path" ;; esac
case "$PROOF_FILE" in *[!A-Za-z0-9/._+-]*) die "proxy proof path cannot be carried by the unit" ;; esac
# Metadata only: render never opens the proof, even to validate its contents.
[ ! -L "$PROOF_FILE" ] && [ -f "$PROOF_FILE" ] || die "invalid proxy proof file metadata"
local proof_path copies_path proof_mode proof_uid
proof_path="$(realpath -e -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
copies_path="$(realpath -m -- "$COPIES")" || die "cannot resolve copies directory"
case "$proof_path" in "$REPO"|"$REPO"/*|"$copies_path"|"$copies_path"/*) die "proxy proof file must be outside the repository and copies" ;; esac
proof_mode="$(stat -c '%a' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
proof_uid="$(stat -c '%u' -- "$PROOF_FILE")" || die "cannot stat proxy proof file"
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
fi
[ "$SSO" = 0 ] || flags="$flags --sso"
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
[ "$TESTER_SOCKET" = 0 ] || flags="$flags --tester-socket"
echo "$flags"
}
# Row 85: stack-copy.sh owns the pushed runtime closure and npm install.
# This script delegates copy and shares its copies directory.
# 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
if [ "$TESTER_SOCKET" = 1 ]; then
grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs" || die "tester socket target lacks support"
fi
if [ "$TESTER_SET" = 1 ] && [ -n "$COPY_ID" ] && grep -q 'MOSAIC_TESTER_SOCKET_V1' "$root/packages/webui/src/cli.mjs"; then
die "tester port retired for this target; use --tester-socket"
fi
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" "${INSTANCE_SED[@]}" "$TEMPLATE"
}
# The unit's active state from list-units, printed when it is anything but
# inactive or failed (active, activating, deactivating, reloading...). A
# unit systemd has not loaded is not listed and prints nothing. N4: a
# failed call refuses with one line naming it; the state is unknown, so
# nothing is written or removed.
running() {
local out
out="$(systemctl --user list-units --no-legend --plain "$UNIT_NAME" 2>/dev/null)" || die "refusing: systemctl --user list-units --no-legend --plain $UNIT_NAME failed, so the unit's state is unknown; nothing was changed"
printf '%s\n' "$out" | awk -v unit="$UNIT_NAME" '$1 == unit && $3 != "inactive" && $3 != "failed" {print $3}'
}
# The bus host proves a human by checking that the calling process runs its
# own packages/bus/src/human-cli.mjs (packages/bus/src/human.mjs), and the
# Console spawns the one beside its own files. So a Console on a copy works
# only with a bus on that same copy: install refuses unless every bus unit
# it would call has an ExecStart path inside the copy. With --business the
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
# has loaded, and none loaded refuses. A named instance reads its own
# mosaic-bus-NAME@ units only. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p working
if [ -n "$BUSINESS" ]; then
units="$BUS_UNIT_PREFIX$BUSINESS.service"
else
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain $BUS_GLOB failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk -v p="$BUS_UNIT_PREFIX" 'index($1, p) == 1 && length($1) > length(p) + 8 && substr($1, length($1) - 7) == ".service" {print $1}')"
[ -n "$units" ] || die "refusing: no $BUS_UNIT_PREFIX 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 WorkingDirectory -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
working="$(printf '%s\n' "$out" | sed -n 's/^WorkingDirectory=//p')"
[ "$working" = "$copy" ] || die "refusing: $unit WorkingDirectory is ${working:-unknown}, not the copy $copy; nothing was changed"
done
}
install_unit() {
parse_flags "$@"
canonical
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
instance_ports_ok
local unit
unit="$(render)" || exit 1
ports_unclaimed
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 ${UNIT_NAME%.service}), 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 <<MSG
this script starts nothing: the Console holds the human's capability. Jason
starts and restarts it, or Mos under lead decision 102 (sends then enter the
trail as Jason); no other agent does:
systemctl --user enable --now ${UNIT_NAME%.service} start now and at login
systemctl --user status ${UNIT_NAME%.service}
journalctl --user -u ${UNIT_NAME%.service} -f the Console's log
systemctl --user stop ${UNIT_NAME%.service} SIGTERM; restartable
scripts/webui-service.sh ${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }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 ] && [ "$COPY_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
# 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 ${UNIT_NAME%.service})"
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 ] && [ "$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)"
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 after the allowed user and optional proof pair, or
# after --sso, before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+| --tester-socket)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"
fi
if grep -Eq '^ExecStart=.* --sso( --send)?( --tester-port [0-9]+)?$' "$target"; then
echo "sign-in: the Console's own (--sso)"
fi
local tester_port
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
if grep -q -- " --tester-socket" "$target"; then
echo "tester: private Unix socket (console/tester.sock under instance dataRoot)"
elif [ -n "$tester_port" ]; then
echo "tester: http://127.0.0.1:$tester_port (binds the tester)"
else
echo "tester: off"
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() {
exec "$REPO/scripts/stack-copy.sh" copy "$@"
}
[ $# -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
instance_ports_ok
render ;;
install) install_unit "$@" ;;
uninstall) uninstall_unit "$@" ;;
status) status_unit "$@" ;;
-h|--help) echo "$USAGE" ;;
*) usage ;;
esac