From 41193e6b5d4c2e7e7503f2e113e9df857924702c Mon Sep 17 00:00:00 2001 From: Tristan Date: Fri, 16 Oct 2026 14:52:47 -0700 Subject: [PATCH 1/3] install.sh: install the deploy supervisor on checkout installs, timer off Until now the deploy supervisor (deploy/from-main) was a separate once-per-box install from a README that no installer ran. Every box set up with deploy/fleet/install.sh therefore had a dashboard update button that could only fail with "Unit hestia-deploy.service not found" (#791: Nomad 2026-09-01, tl-dell 2026-10-16). The operator on tl-dell asked whether a brand-new install would "just work from the getgo"; it would not. New step_deploy_supervisor, run after the workspace marker: - checkout install (HESTIA_WORKSPACE resolved) with git + cargo on Linux: clones hestia + web4 into $HESTIA_HOME/deploy over https (the units run with no ssh agent), installs ~/.local/bin/hestia-deploy and both units with HESTIA_WORKSPACE rendered, reads the unit back, daemon-reload. - the TIMER STAYS OFF by default. HESTIA_AUTO_UPDATE=1 arms it. A re-run never disarms a timer the operator turned on. - skips, saying why: release (curl | sh) installs track releases, not main; missing git/cargo; macOS (launchd binary path derivation is not automated yet); HESTIA_SKIP_DEPLOY. deploy/fleet/deploy_supervisor_step_test.py runs the step itself in a temp HOME with stub git/cargo/systemctl/uname against the REAL shipped unit files: 17 checks, including "nothing asked systemd to enable the timer" by default. surface: deploy/fleet/install.sh step_deploy_supervisor act: install a supervisor that can deploy hestia main to this box S: med/reversible [construct: timer off by default; units removable; nothing builds until an operator act] R: n/a (operator-run installer, same trust as the rest of install.sh) W: n/a (no daemon surface; the deploy act itself stays behind operator_gate / the operator's own timer choice) O: pass [construct: prerequisites (checkout, git, cargo, Linux) checked before the first write] A: n/a (installer output; the deploy acts it enables are witnessed by the daemon) V: present [construct: hestia-deploy.sh preflight_gate + rollback, unchanged] verdict: PASS Co-Authored-By: Claude Opus 5.5 --- deploy/fleet/deploy_supervisor_step_test.py | 135 ++++++++++++++++++++ deploy/fleet/install.sh | 86 +++++++++++++ deploy/from-main/README.md | 8 ++ 3 files changed, 229 insertions(+) create mode 100755 deploy/fleet/deploy_supervisor_step_test.py diff --git a/deploy/fleet/deploy_supervisor_step_test.py b/deploy/fleet/deploy_supervisor_step_test.py new file mode 100755 index 000000000..d7c450489 --- /dev/null +++ b/deploy/fleet/deploy_supervisor_step_test.py @@ -0,0 +1,135 @@ +#!/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", + "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 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) + env = { + "PATH": f"{bin_stubs}:/usr/bin:/bin", + "HOME": str(home), + "HESTIA_HOME": 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 f"Environment=HESTIA_WORKSPACE={r['ws']}\n" in r["unit"], 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"]) + +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..dbcde9c15 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 @@ -789,6 +791,89 @@ 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. +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 + 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. + sed "s|^Environment=HESTIA_WORKSPACE=\$|Environment=HESTIA_WORKSPACE=$HESTIA_WORKSPACE|" \ + "$src/hestia-deploy.service" > "$unit_dir/hestia-deploy.service" + 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 -qx "Environment=HESTIA_WORKSPACE=$HESTIA_WORKSPACE" "$unit_dir/hestia-deploy.service"; then + c_err "hestia-deploy.service was written without HESTIA_WORKSPACE: $unit_dir/hestia-deploy.service" + return 1 + 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 +1000,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 From 5e5c552f6d7bda8814416388531892a639f39740 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Mon, 19 Oct 2026 00:59:47 -0700 Subject: [PATCH 2/3] install.sh: systemd-safe HESTIA_WORKSPACE rendering; skip a non-default HESTIA_HOME Nova's HOLD on #1335 (2026-10-17): - The workspace went into the unit unquoted, through sed. systemd splits an unquoted Environment= value at whitespace, treats % as a specifier, and backslash and quote as syntax; sed reads | & \ in the path as its own. Measured on the old code with the new test: "My Projects" reached systemd as ".../My", "pipe|amp&x" broke sed, and in the space cases the step still printed "the dashboard update button works". Now `systemd_env_line` writes Environment="VAR=value" with \ " escaped and % doubled, awk substitutes it from the environment, the read-back is grep -F, and systemd-analyze verify (where present) must not complain about the environment. - A custom HESTIA_HOME was not followed: the step cloned into $HESTIA_HOME/deploy, but the shipped units run with HESTIA_HOME=%h/.hestia and hestia-deploy defaults its clones to ~/.hestia/deploy, as does the daemon unit this script installs. Rather than install a supervisor that clones into one home and deploys into another, the step now skips and names the manual route. deploy_supervisor_step_test.py: a model of systemd's Environment= parser checks six awkward workspace names round-trip with no stray variable, plus the HESTIA_HOME skip and a direct systemd-analyze verify of the rendered unit. 8 of the new checks fail on the previous commit. -- cbp-claude Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- deploy/fleet/deploy_supervisor_step_test.py | 80 ++++++++++++++++++++- deploy/fleet/install.sh | 58 +++++++++++++-- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/deploy/fleet/deploy_supervisor_step_test.py b/deploy/fleet/deploy_supervisor_step_test.py index d7c450489..89bf2a2f3 100755 --- a/deploy/fleet/deploy_supervisor_step_test.py +++ b/deploy/fleet/deploy_supervisor_step_test.py @@ -31,7 +31,7 @@ def func(name): BODY = "\n".join(func(n) for n in ("c_dim", "c_ok", "c_warn", "c_err", "c_hdr", - "step_deploy_supervisor")) + "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. @@ -46,6 +46,48 @@ def stub(d, name, 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) @@ -69,8 +111,9 @@ def run(*, workspace="ws", cargo=True, os_name="Linux", env_extra=None, timer_en 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", + "PATH": f"{bin_stubs}:/usr/bin:/bin", # /usr/bin keeps systemd-analyze when installed "HOME": str(home), "HESTIA_HOME": str(home / ".hestia"), "HESTIA_WORKSPACE": ws, @@ -98,7 +141,8 @@ def run(*, workspace="ws", cargo=True, os_name="Linux", env_extra=None, timer_en 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 f"Environment=HESTIA_WORKSPACE={r['ws']}\n" in r["unit"], r["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"]) @@ -130,6 +174,36 @@ def run(*, workspace="ws", cargo=True, os_name="Linux", env_extra=None, timer_en 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", "~/.hestia" 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 dbcde9c15..bff27d089 100755 --- a/deploy/fleet/install.sh +++ b/deploy/fleet/install.sh @@ -812,6 +812,21 @@ step_workspace_marker() { # 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)" @@ -828,6 +843,17 @@ step_deploy_supervisor() { 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" != "$HOME/.hestia" ]; then + c_warn "deploy supervisor not installed: HESTIA_HOME is $HESTIA_HOME, but the deploy units" + c_warn " run against ~/.hestia. 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" @@ -853,15 +879,39 @@ step_deploy_supervisor() { 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. - sed "s|^Environment=HESTIA_WORKSPACE=\$|Environment=HESTIA_WORKSPACE=$HESTIA_WORKSPACE|" \ - "$src/hestia-deploy.service" > "$unit_dir/hestia-deploy.service" + # 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 -qx "Environment=HESTIA_WORKSPACE=$HESTIA_WORKSPACE" "$unit_dir/hestia-deploy.service"; then + 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 From ebe0e4390cf4992c40fd1a1d39b67ed2441c2bf4 Mon Sep 17 00:00:00 2001 From: Dennis Palatov Date: Mon, 19 Oct 2026 10:15:38 -0700 Subject: [PATCH 3/3] install.sh: name the default HESTIA_HOME once; no new path literal 5e5c552f's HESTIA_HOME guard spelled the default home out twice more, and the no-path-literals ratchet (CI, plugin tests) failed on it: shell-home-hestia 5 -> 6, tilde-hestia 0 -> 1. The default is now named once, as HESTIA_HOME_DEFAULT, on the line that already resolved it. The guard and its message use the variable. The pin moves by one reclassification (that line's shell-home-default becomes the existing shell-home-hestia count): 10 -> 9. -- cbp-claude Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014LYVQN8U1xqy6U6pbyUDvH --- deploy/fleet/deploy_supervisor_step_test.py | 3 ++- deploy/fleet/install.sh | 8 +++++--- tools/no_path_literals_test.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/deploy/fleet/deploy_supervisor_step_test.py b/deploy/fleet/deploy_supervisor_step_test.py index 89bf2a2f3..ef20c9f8f 100755 --- a/deploy/fleet/deploy_supervisor_step_test.py +++ b/deploy/fleet/deploy_supervisor_step_test.py @@ -116,6 +116,7 @@ def run(*, workspace="ws", cargo=True, os_name="Linux", env_extra=None, timer_en "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"), } @@ -190,7 +191,7 @@ def run(*, workspace="ws", cargo=True, os_name="Linux", env_extra=None, timer_en 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", "~/.hestia" in r["out"] and "/srv/elsewhere/hestia" in 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. diff --git a/deploy/fleet/install.sh b/deploy/fleet/install.sh index bff27d089..ca4b30d2b 100755 --- a/deploy/fleet/install.sh +++ b/deploy/fleet/install.sh @@ -31,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 @@ -848,9 +850,9 @@ step_deploy_supervisor() { # ~/.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" != "$HOME/.hestia" ]; then + 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. Install it by hand (deploy/from-main/README.md) if this box" + 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 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}, }