Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions deploy/fleet/deploy_supervisor_step_test.py
Original file line number Diff line number Diff line change
@@ -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 <url> <dest>: 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")
140 changes: 139 additions & 1 deletion deploy/fleet/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions deploy/from-main/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading