#!/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-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. --send # (row 83) is taken only with --sso: 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 requires it. Header preview is read-only. 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" # shellcheck source=scripts/selector.sh . "$REPO/scripts/selector.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] [--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; } # --org and --business are one selector in these flags (rename design r2). A missing value is a # usage error and a flag-shaped or invalid one the business-id refusal serve_flags makes, worded # as before; a repeated or conflicting selector is a usage error that says why. selector_refused() { case "$1" in missing) usage ;; flag|invalid) die "business must be a business id: $BUSINESS" ;; *) echo "webui-service: $2" >&2; exit 4 ;; esac } business_id_ok() { [[ "$1" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]]; } 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|--org) BUSINESS="${2-}"; selector_take flags id selector_refused business_id_ok "$1" "${@:2:1}"; 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" = 0 ] || die "--send given twice"; 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; 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 ] || [ "$SSO" = 1 ] || die "--send needs --sso; header preview is read-only" 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 # Accounts instances use dedicated system services, never a human/seat's # systemd user manager. The check runs before rendering or installing. node --input-type=module -e ' import { lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; const [root, dir, file] = process.argv.slice(1); const present = p => { try { lstatSync(p); return true; } catch(e) { if(e.code === "ENOENT") return false; throw e; } }; let accounts = present(join(root, ".stack-build.json")) || present(join(dir, "accounts.json")); if (present(file)) { const s = JSON.parse(readFileSync(file, "utf8")); accounts ||= Boolean(s.console?.sso) || present(join(s.dataRoot, ".deployed")); } if (accounts) { console.error("webui-service: accounts user-unit path refused; use system units from case2-unit.sh"); process.exit(1); } ' "$root" "$MOSAIC_INSTANCE_CONFIG_DIR" "$MOSAIC_INSTANCE_CONFIG" || 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 mosaic-bus@ID.service; 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 <&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