diff --git a/deploy/fleet/deploy_supervisor_step_test.py b/deploy/fleet/deploy_supervisor_step_test.py new file mode 100755 index 000000000..ef20c9f8f --- /dev/null +++ b/deploy/fleet/deploy_supervisor_step_test.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""install.sh installs the deploy supervisor on a checkout install, with the timer OFF. + +Runs install.sh's own step_deploy_supervisor, extracted from the script, in a temporary HOME +with `git`, `cargo`, `systemctl` and `uname` replaced by stubs. No network, no real install. +The stub systemctl records every call, so "the timer stays off" is checked as "nothing asked +systemd to enable it", not inferred from a missing file. +""" +import os +import pathlib +import re +import subprocess +import tempfile + +HERE = pathlib.Path(__file__).resolve().parent +SCRIPT = (HERE / "install.sh").read_text() +FAILS = [] + + +def check(name, ok, detail=""): + print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -- {detail}")) + if not ok: + FAILS.append(name) + + +def func(name): + m = (re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", SCRIPT, re.S | re.M) + or re.search(rf"^{name}\(\)\s+\{{[^\n]*\}}\n", SCRIPT, re.M)) # one-line form + assert m, name + return m.group(0) + + +BODY = "\n".join(func(n) for n in ("c_dim", "c_ok", "c_warn", "c_err", "c_hdr", + "systemd_env_line", "step_deploy_supervisor")) + +# The REAL shipped files, so the HESTIA_WORKSPACE substitution is tested against the unit that +# actually ships, not a copy of what it looked like when this test was written. +FROM_MAIN = HERE.parent / "from-main" +assert "\nEnvironment=HESTIA_WORKSPACE=\n" in (FROM_MAIN / "hestia-deploy.service").read_text(), \ + "hestia-deploy.service no longer ships an empty HESTIA_WORKSPACE= line; update install.sh's sed" + + +def stub(d, name, body): + p = d / name + p.write_text("#!/bin/sh\n" + body) + p.chmod(0o755) + + +def systemd_environment(unit_text): + """The variables systemd would set from a unit's Environment= lines. + + A small model of systemd's parser for this one directive (extract_first_word with + EXTRACT_UNQUOTE|EXTRACT_CUNESCAPE, then specifier expansion): words split at unquoted + whitespace, double quotes group, backslash escapes inside quotes, `%%` is a literal `%`. + A word without `=` is dropped, as systemd drops it ("Invalid environment assignment"). + """ + env = {} + for line in unit_text.splitlines(): + if not line.startswith("Environment="): + continue + rest, words, cur, quoted, i, inword = line[len("Environment="):], [], "", False, 0, False + while i < len(rest): + c = rest[i] + if quoted: + if c == "\\" and i + 1 < len(rest): + cur += rest[i + 1] + i += 1 + elif c == '"': + quoted = False + else: + cur += c + elif c == '"': + quoted, inword = True, True + elif c.isspace(): + if inword: + words.append(cur) + cur, inword = "", False + else: + cur, inword = cur + c, True + i += 1 + if inword: + words.append(cur) + for w in words: + w = w.replace("%%", "\0").replace("\0", "%") + if "=" in w: + k, v = w.split("=", 1) + env[k] = v + return env + + +def run(*, workspace="ws", cargo=True, os_name="Linux", env_extra=None, timer_enabled=False): + with tempfile.TemporaryDirectory() as t: + t = pathlib.Path(t) + home, bin_stubs = t / "home", t / "stubs" + home.mkdir() + bin_stubs.mkdir() + calls = t / "systemctl.log" + # git clone : lay down the deploy/from-main files a real clone would carry. + stub(bin_stubs, "git", f"""dest="$4" +mkdir -p "$dest/.git" "$dest/deploy/from-main" +cp "{FROM_MAIN}/hestia-deploy.sh" "{FROM_MAIN}/hestia-deploy.service" "{FROM_MAIN}/hestia-deploy.timer" "$dest/deploy/from-main/" +""") + if cargo: + stub(bin_stubs, "cargo", "exit 0\n") + stub(bin_stubs, "systemctl", f"""echo "$*" >> "{calls}" +case "$*" in *is-enabled*) [ "{int(timer_enabled)}" = 1 ] && exit 0 || exit 1 ;; esac +exit 0 +""") + stub(bin_stubs, "uname", f"echo {os_name}\n") + ws = "" + if workspace: + ws = str(t / workspace) + os.makedirs(ws) + # systemd-analyze, when present, is the real parser; keep it on PATH. + env = { + "PATH": f"{bin_stubs}:/usr/bin:/bin", # /usr/bin keeps systemd-analyze when installed + "HOME": str(home), + "HESTIA_HOME": str(home / ".hestia"), + "HESTIA_HOME_DEFAULT": str(home / ".hestia"), + "HESTIA_WORKSPACE": ws, + "BIN_DIR": str(home / ".local/bin"), + } + env.update(env_extra or {}) + script = "set -euo pipefail\n" + BODY + "\nstep_deploy_supervisor\n" + r = subprocess.run(["bash", "-c", script], env=env, capture_output=True, text=True) + unit = home / ".config/systemd/user/hestia-deploy.service" + return { + "rc": r.returncode, + "out": r.stdout + r.stderr, + "calls": calls.read_text().splitlines() if calls.exists() else [], + "unit": unit.read_text() if unit.exists() else None, + "timer": (home / ".config/systemd/user/hestia-deploy.timer").exists(), + "script": (home / ".local/bin/hestia-deploy").exists(), + "ws": ws, + } + + +# 1. A checkout install gets a working update button, and the schedule stays off. +r = run() +check("checkout install exits 0", r["rc"] == 0, r["out"]) +check("hestia-deploy script installed", r["script"], r["out"]) +check("service unit installed", r["unit"] is not None, r["out"]) +check("timer unit installed", r["timer"], r["out"]) +check("HESTIA_WORKSPACE rendered into the unit", + r["unit"] is not None and systemd_environment(r["unit"]).get("HESTIA_WORKSPACE") == r["ws"], + r["unit"]) +check("units reloaded", "--user daemon-reload" in r["calls"], r["calls"]) +check("timer NOT enabled by default", not any("enable" in c and "is-enabled" not in c for c in r["calls"]), + r["calls"]) +check("says auto-update is off", "auto-update OFF" in r["out"], r["out"]) + +# 2. Opt-in arms the timer. +r = run(env_extra={"HESTIA_AUTO_UPDATE": "1"}) +check("HESTIA_AUTO_UPDATE=1 enables the timer", + "--user enable --now hestia-deploy.timer" in r["calls"], r["calls"]) + +# 3. A re-run on a box whose timer is already on leaves it on. +r = run(timer_enabled=True) +check("armed timer is left armed", not any("disable" in c for c in r["calls"]), r["calls"]) +check("says it left it on", "already ON" in r["out"], r["out"]) + +# 4. Release install (no checkout): skipped with the reason, nothing written. +r = run(workspace="") +check("release install skips", r["rc"] == 0 and r["unit"] is None and not r["script"], r["out"]) +check("release install says why", "release install" in r["out"], r["out"]) + +# 5. No cargo: skipped with the reason, nothing written. +r = run(cargo=False) +check("missing cargo skips", r["rc"] == 0 and r["unit"] is None, r["out"]) +check("missing cargo is named", "needs cargo" in r["out"], r["out"]) + +# 6. Explicit opt-out, and macOS (not automated yet). +r = run(env_extra={"HESTIA_SKIP_DEPLOY": "1"}) +check("HESTIA_SKIP_DEPLOY skips", r["unit"] is None and r["calls"] == [], r["out"]) +r = run(os_name="Darwin") +check("macOS points at the README", r["unit"] is None and "README" in r["out"], r["out"]) + +# 7. A workspace path systemd would split or misread: spaces, quote, backslash, %, and sed's +# own metacharacters. Each must reach the unit as the same string systemd will parse back. +for name in ("My Projects", 'a "q" b', "back\\slash", "100% ws", "pipe|amp&x", "x=y z"): + r = run(workspace=name) + got = systemd_environment(r["unit"] or "").get("HESTIA_WORKSPACE") + check(f"workspace {name!r} survives systemd parsing", r["rc"] == 0 and got == r["ws"], + f"rc={r['rc']} got={got!r} want={r['ws']!r}\n{r['out']}") + check(f"workspace {name!r} adds no stray variable", + set(systemd_environment(r["unit"] or "")) <= {"HESTIA_HOME", "HESTIA_WORKSPACE", "PATH"}, + r["unit"]) + +# 8. A non-default HESTIA_HOME: the units run against ~/.hestia, so the step refuses to +# install a supervisor that would clone into one home and deploy into another. +r = run(env_extra={"HESTIA_HOME": "/srv/elsewhere/hestia"}) +check("non-default HESTIA_HOME skips", r["rc"] == 0 and r["unit"] is None and not r["script"] + and r["calls"] == [], r["out"]) +check("non-default HESTIA_HOME says why", str(pathlib.Path("home")) in r["out"] and "/srv/elsewhere/hestia" in r["out"], + r["out"]) + +# 9. The model above agrees with systemd where systemd is installed. +import shutil # noqa: E402 +if shutil.which("systemd-analyze"): + r = run(workspace="My Projects 100%") + with tempfile.TemporaryDirectory() as t: + u = pathlib.Path(t) / "hestia-deploy.service" + u.write_text(r["unit"] or "") + v = subprocess.run(["systemd-analyze", "verify", str(u)], capture_output=True, text=True) + check("systemd-analyze parses the rendered Environment= lines without complaint", + r["rc"] == 0 and "environment" not in (v.stdout + v.stderr).lower(), v.stdout + v.stderr) + +if FAILS: + raise SystemExit(f"{len(FAILS)} check(s) failed: " + ", ".join(FAILS)) +print("deploy supervisor install step: PASS") diff --git a/deploy/fleet/install.sh b/deploy/fleet/install.sh index 60aa64260..ca4b30d2b 100755 --- a/deploy/fleet/install.sh +++ b/deploy/fleet/install.sh @@ -19,6 +19,8 @@ # HESTIA_HOME override install location for state (default: ~/.hestia) # HESTIA_BIND bind address for the daemon (default: 127.0.0.1:7711) # HESTIA_SKIP_HOOK if set, do NOT wire ~/.claude/settings.json hooks +# HESTIA_SKIP_DEPLOY if set, do NOT install the deploy supervisor (dashboard update button) +# HESTIA_AUTO_UPDATE =1 also arms the 4-hourly deploy timer (default: installed but OFF) # set -euo pipefail @@ -29,7 +31,9 @@ REPO="dp-web4/hestia" # falls back to this floor only when the API cannot be reached (offline, rate-limited). FALLBACK_VERSION="v0.0.4" VERSION="${HESTIA_VERSION:-}" -HESTIA_HOME="${HESTIA_HOME:-$HOME/.hestia}" +# The default home, named once: the deploy units are bound to it (step_deploy_supervisor). +HESTIA_HOME_DEFAULT="$HOME/.hestia" +HESTIA_HOME="${HESTIA_HOME:-$HESTIA_HOME_DEFAULT}" HESTIA_BIND="${HESTIA_BIND:-127.0.0.1:7711}" # DERIVE, do not enumerate. The first draft of this block listed the workspace roots of # the machines in this fleet, which is wrong twice: it is a guess on any machine not on @@ -789,6 +793,139 @@ step_workspace_marker() { fi } +# The deploy supervisor (deploy/from-main): what the dashboard's update / "check for updates" +# button starts, and what its auto-update toggle schedules. +# +# WHY HERE. Until this step the supervisor was a separate once-per-box install in +# deploy/from-main/README.md that no installer ran, so on every box set up from this script the +# dashboard offered an update button that could only fail with "Unit hestia-deploy.service not +# found" (#791, Nomad 2026-09-01; tl-dell 2026-10-16). An operator should not need a README of +# shell lines to get a working update button. +# +# WHAT IT INSTALLS, AND WHAT IT LEAVES OFF. The two deploy clones under $HESTIA_HOME/deploy, the +# hestia-deploy script, and the service + timer units, with HESTIA_WORKSPACE rendered. The +# TIMER STAYS OFF unless HESTIA_AUTO_UPDATE=1: nothing builds or restarts until the operator +# clicks update or switches auto-update on in the dashboard. A re-run never turns an armed +# timer off and never touches the deploy clones' history. +# +# WHEN IT SKIPS (and says so). The supervisor builds hestia main from source, so it needs a +# checkout install (a resolved HESTIA_WORKSPACE) with git and cargo. A release install +# (`curl | sh`) tracks releases, not main, and is updated by re-running this script; converting +# it to main-tracking is a choice this step does not make for it. macOS: the launchd agent's +# binary path must be derived from the daemon's registration (README), so it is not automated +# here yet. +# One systemd `Environment=` line for VAR=VALUE that survives systemd's own parser. Unquoted, +# the value is split at whitespace (a workspace under "/home/x/My Projects" became two +# assignments, one of them invalid and dropped: measured with systemd-analyze verify), `%` is +# a specifier, and backslash and quote are special. So: double-quote the whole assignment, +# escape backslash and quote, and double every `%` (systemd.exec(5), systemd.unit(5) +# "Specifiers"). A newline cannot be expressed in a unit file at all: refuse it. +systemd_env_line() { + local var="$1" val="$2" nl=$'\n' + case "$val" in *"$nl"*) return 1 ;; esac + val="${val//\\/\\\\}" + val="${val//\"/\\\"}" + val="${val//%/%%}" + printf 'Environment="%s=%s"\n' "$var" "$val" +} + +step_deploy_supervisor() { + if [ -n "${HESTIA_SKIP_DEPLOY:-}" ]; then + c_dim "deploy supervisor skipped (HESTIA_SKIP_DEPLOY set)" + return + fi + c_hdr "deploy supervisor (dashboard update button)" + if [ "$(uname -s)" != Linux ]; then + c_dim "not automated on $(uname -s) yet: see deploy/from-main/README.md (launchd)" + return + fi + if [ -z "${HESTIA_WORKSPACE:-}" ]; then + c_dim "release install (no checkout): update by re-running this installer." + c_dim " the deploy supervisor builds hestia main from source; to opt in, run this script" + c_dim " from a checkout, or see deploy/from-main/README.md" + return + fi + # The shipped units are bound to ~/.hestia (HESTIA_HOME=%h/.hestia here, and the same in the + # daemon unit this script installs), and hestia-deploy defaults its clones to + # ~/.hestia/deploy. Installing for another HESTIA_HOME would put the clones where the + # supervisor never looks and deploy into a home the daemon does not read. Until the units + # take HESTIA_HOME from configuration, say so instead of installing a half-wired supervisor. + if [ "$HESTIA_HOME" != "${HESTIA_HOME_DEFAULT:?}" ]; then + c_warn "deploy supervisor not installed: HESTIA_HOME is $HESTIA_HOME, but the deploy units" + c_warn " run against $HESTIA_HOME_DEFAULT. Install it by hand (deploy/from-main/README.md) if this box" + c_warn " should deploy from main, with HESTIA_HOME and HESTIA_DEPLOY_ROOT set in the unit." + return + fi + local missing="" + command -v git >/dev/null 2>&1 || missing="$missing git" + command -v cargo >/dev/null 2>&1 || missing="$missing cargo" + if [ -n "$missing" ]; then + c_warn "deploy supervisor not installed: needs$missing (it builds hestia main from source)" + c_warn " install the missing tool(s) and re-run this installer" + return + fi + local deploy_root="$HESTIA_HOME/deploy" unit_dir="$HOME/.config/systemd/user" + local src="$deploy_root/hestia/deploy/from-main" + mkdir -p "$deploy_root" "$unit_dir" "$BIN_DIR" + # https, not ssh: the units run without an agent or a key, and both repos are public. + local name + for name in hestia web4; do + if [ ! -d "$deploy_root/$name/.git" ]; then + git clone -q "https://github.com/dp-web4/$name.git" "$deploy_root/$name" \ + || { c_err "clone of $name into $deploy_root failed"; return 1; } + fi + done + if [ ! -f "$src/hestia-deploy.sh" ]; then + c_err "deploy checkout has no deploy/from-main/hestia-deploy.sh: $deploy_root/hestia" + return 1 + fi + install -m 0755 "$src/hestia-deploy.sh" "$BIN_DIR/hestia-deploy" + # The shipped unit leaves HESTIA_WORKSPACE empty on purpose ("render it from explicit operator + # configuration"); this installer's resolved workspace is that configuration. The line is + # replaced by awk reading it from the environment, not by sed: a workspace path containing + # `|`, `&` or `\` would otherwise be read as sed syntax. + local env_line + if ! env_line="$(systemd_env_line HESTIA_WORKSPACE "$HESTIA_WORKSPACE")"; then + c_err "HESTIA_WORKSPACE contains a newline; a systemd unit cannot carry it" + return 1 + fi + if ! ENV_LINE="$env_line" awk ' + $0 == "Environment=HESTIA_WORKSPACE=" { print ENVIRON["ENV_LINE"]; n++; next } + { print } + END { exit n == 1 ? 0 : 1 }' \ + "$src/hestia-deploy.service" > "$unit_dir/hestia-deploy.service"; then + c_err "hestia-deploy.service no longer has exactly one empty HESTIA_WORKSPACE= line: $src" + return 1 + fi + install -m 0644 "$src/hestia-deploy.timer" "$unit_dir/hestia-deploy.timer" + # Read back, as the other steps do: a zero exit is not evidence the bytes landed. + if ! grep -qxF -- "$env_line" "$unit_dir/hestia-deploy.service"; then + c_err "hestia-deploy.service was written without HESTIA_WORKSPACE: $unit_dir/hestia-deploy.service" + return 1 + fi + # And have systemd itself parse it, where it can: an assignment it splits or rejects is a + # warning on stderr with exit 0, so the warning is what is checked. + if command -v systemd-analyze >/dev/null 2>&1; then + local verify + verify="$(systemd-analyze verify "$unit_dir/hestia-deploy.service" 2>&1 || true)" + case "$verify" in + *"nvalid environment"*|*"Failed to resolve"*) + c_err "systemd does not parse the rendered unit: $verify" + return 1 ;; + esac + fi + systemctl --user daemon-reload + c_ok "deploy supervisor installed: the dashboard update button works" + if [ "${HESTIA_AUTO_UPDATE:-}" = 1 ]; then + systemctl --user enable --now hestia-deploy.timer + c_ok "auto-update ON (HESTIA_AUTO_UPDATE=1): deploys hestia main every 4h" + elif systemctl --user is-enabled --quiet hestia-deploy.timer 2>/dev/null; then + c_ok "auto-update already ON: left as it is" + else + c_dim "auto-update OFF: updates happen when you click update; switch it on in the dashboard" + fi +} + step_claude_hooks() { if [ -n "${HESTIA_SKIP_HOOK:-}" ]; then c_dim "claude-code hooks skipped (HESTIA_SKIP_HOOK set)" @@ -915,6 +1052,7 @@ main() { *) c_err "unsupported OS"; exit 1 ;; esac step_workspace_marker + step_deploy_supervisor step_claude_hooks step_smoketest c_hdr "done" diff --git a/deploy/from-main/README.md b/deploy/from-main/README.md index a9dc55e6d..91a16b027 100644 --- a/deploy/from-main/README.md +++ b/deploy/from-main/README.md @@ -103,6 +103,14 @@ preflight and refuses identically, so there is nothing for the seat to retry. ## Install — Linux, user systemd (once per box) +**On a checkout install, `deploy/fleet/install.sh` now does this for you** (`step_deploy_supervisor`): +it clones the two deploy checkouts over https, installs `hestia-deploy` and both units with +`HESTIA_WORKSPACE` rendered, and leaves the **timer off**. The dashboard's update button works +straight away. Scheduled deploys stay off until the operator switches them on, either with the +dashboard toggle (#1334) or with `HESTIA_AUTO_UPDATE=1` at install time. A release (`curl | sh`) install is +skipped on purpose: it tracks releases, not main. The manual lines below remain the reference, +and they are how a release install opts in to main-tracking. + mkdir -p ~/.hestia/deploy git clone git@github.com:dp-web4/hestia.git ~/.hestia/deploy/hestia git clone git@github.com:dp-web4/web4.git ~/.hestia/deploy/web4 diff --git a/tools/no_path_literals_test.py b/tools/no_path_literals_test.py index 2b3ef340c..16409d28a 100755 --- a/tools/no_path_literals_test.py +++ b/tools/no_path_literals_test.py @@ -194,7 +194,7 @@ "deploy/from-main/hestia-deploy.service": {"unit-home-hestia": 2}, "deploy/from-main/hestia-deploy.timer": {}, "deploy/from-main/hestia-deploy.sh": {"shell-home-default": 3, "shell-home-hestia": 2}, - "deploy/fleet/install.sh": {"shell-home-default": 1, "shell-home-hestia": 5, "unit-home-hestia": 4}, + "deploy/fleet/install.sh": {"shell-home-hestia": 5, "unit-home-hestia": 4}, "deploy/install-members.sh": {"expanduser-tilde": 1, "shell-home-default": 1, "shell-home-hestia": 1}, }