From 8a4a01c42fcf31a7de01d074fb1df67c751edb06 Mon Sep 17 00:00:00 2001 From: Johannes Rest Date: Wed, 21 Oct 2026 16:27:16 +0200 Subject: [PATCH] Fix successful Hermes stable updates without commit changes --- CHANGELOG.md | 8 +++ libexec/hermes-stack-hermes-update | 9 ++- tests/smoke.sh | 1 + tests/test_hermes_update.py | 101 +++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 tests/test_hermes_update.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea8316..0fdf8ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# 2026-10-21 — Erfolgreiche Hermes-Prüfung ohne Versionswechsel + +- Ein unveränderter Commit nach erfolgreichem `hermes update` gilt als Erfolg: + Der konfigurierte Stable-Kanal kann aktuell sein, obwohl `origin/main` weiter ist. +- Abschließende Meldungen erscheinen direkt im Journal des Update-Dienstes. +- Isolierte Regressionstests prüfen unveränderte/aktualisierte Commits, + Updater-Fehler und Gateway-Rollback ohne Netzwerk oder echte Dienstaufrufe. + # 2026-10-12 — VRAM-Reserve und portable CPU-Kompression - `desktop-64k` mit 4096 MiB Fit-Zielmarge und 64K Kontext als Standard neuer diff --git a/libexec/hermes-stack-hermes-update b/libexec/hermes-stack-hermes-update index c53ee9d..75c0e54 100755 --- a/libexec/hermes-stack-hermes-update +++ b/libexec/hermes-stack-hermes-update @@ -15,6 +15,8 @@ log() { notify_user() { local text="$1" + # Keep the outcome in this service's journal as well as the notifier's journal. + printf '%s\n' "$text" if [[ -x "$NOTIFIER" ]]; then "$NOTIFIER" "$text" || true else @@ -226,7 +228,12 @@ mapfile -t old_backups < <( ((${#old_backups[@]} == 0)) || rm -f -- "${old_backups[@]}" new_commit="$(git -C "$HERMES_REPO" rev-parse HEAD)" -if [[ "$new_commit" == "$old_commit" ]]; then +# The updater selects the configured channel (e.g. stable), whereas the fetch +# above only checks remote connectivity. Stable may legitimately stay on the +# same commit even when origin/main has advanced. +if [[ "$new_commit" == "$old_commit" ]] && ((update_exit == 0)); then + notify_user "Hermes ist im konfigurierten Update-Kanal bereits aktuell: $new_commit. Konfiguration und frisch gestarteter Gateway sind gesund." +elif [[ "$new_commit" == "$old_commit" ]]; then notify_user "Hermes-Update wurde nicht installiert (Updater-Exitcode $update_exit); der bisherige Commit $new_commit und der frisch gestartete Gateway sind gesund." exit 1 elif ((update_exit != 0)); then diff --git a/tests/smoke.sh b/tests/smoke.sh index 3d188ba..667a69e 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -21,6 +21,7 @@ else fi python3 "$ROOT_DIR/tests/test_llama.py" || failures=$((failures + 1)) +python3 "$ROOT_DIR/tests/test_hermes_update.py" || failures=$((failures + 1)) if (cd -- "$ROOT_DIR" && rg -n --hidden --glob '!tests/smoke.sh' --glob '!tests/test_llama.py' --glob '!.git/**' \ '(TELEGRAM_BOT_TOKEN|SIGNAL_ACCOUNT|API_KEY)=("|'"'"')?[A-Za-z0-9+]' .); then diff --git a/tests/test_hermes_update.py b/tests/test_hermes_update.py new file mode 100644 index 0000000..daecf8c --- /dev/null +++ b/tests/test_hermes_update.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Run the actual maintenance script with isolated state and fake external tools.""" +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +RUNNER = ROOT / "libexec/hermes-stack-hermes-update" + + +class HermesUpdateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="hermes-update-", dir=ROOT.parent) + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + config = self.root / "config/hermes-stack-maintenance" + config.mkdir(parents=True) + (self.root / "hermes-agent/.git").mkdir(parents=True) + self.env = dict(os.environ, XDG_CONFIG_HOME=str(config.parent), + XDG_STATE_HOME=str(self.root / "state"), + XDG_RUNTIME_DIR=str(self.root), TEST_ROOT=str(self.root), + PATH=str(self.bin) + ":" + os.environ["PATH"], + TEST_UPDATE_EXIT="0", TEST_CHANGED="0", TEST_HEALTHY="1") + (config / "config.env").write_text( + f'HERMES_REPO={shlex.quote(str(self.root / "hermes-agent"))}\n' + f'NOTIFIER={shlex.quote(str(self.bin / "notify-test"))}\n' + 'HERMES_REMOTE=origin\nHERMES_BRANCH=main\n' + 'GATEWAY_SERVICE=test-gateway.service\n' + 'GATEWAY_HEALTH_ATTEMPTS=1\nGATEWAY_HEALTH_DELAY=0\n' + 'LLAMA_URL=http://unused.invalid\n') + self.program("git", '''case "$*" in + *"status --porcelain"*|*"fetch --no-tags"*) exit 0 ;; + *"rev-parse HEAD"*) + if [[ -f "$TEST_ROOT/changed" ]]; then printf 'c%.0s' {1..40}; + else printf 'a%.0s' {1..40}; fi ;; + *"rev-parse refs/remotes/"*) printf 'b%.0s' {1..40} ;; + *) exit 99 ;; + esac + ''') + self.program("hermes", '''case "$*" in + update) + [[ "$TEST_CHANGED" == 0 ]] || touch "$TEST_ROOT/changed" + echo "Simulated updater completion" + exit "$TEST_UPDATE_EXIT" ;; + "gateway status") [[ "$TEST_HEALTHY" == 1 ]] ;; + "config check"|"config migrate") exit 0 ;; + *) exit 99 ;; + esac + ''') + self.program("systemctl", 'printf "%s\\n" "$*" >>"$TEST_ROOT/systemctl-calls"\n') + self.program("tar", 'printf "%s\\n" "$*" >>"$TEST_ROOT/tar-calls"\n') + self.program("curl", "echo '{\"status\":\"ok\"}'\n") + self.program("notify-test", 'printf "%s\\n" "$*" >>"$TEST_ROOT/notices"\n') + self.program("systemd-cat", "cat >/dev/null\n") + self.program("sleep", "exit 0\n") + + def program(self, name, body): + path = self.bin / name + path.write_text("#!/usr/bin/env bash\nset -eu\n" + body) + path.chmod(0o755) + + def run_update(self, **settings): + return subprocess.run(["bash", str(RUNNER)], env=dict(self.env, **settings), + capture_output=True, text=True, timeout=15) + + def test_stable_unchanged_with_newer_main_succeeds(self): + result = self.run_update() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("bereits aktuell", result.stdout) + self.assertIn("restart test-gateway.service", (self.root / "systemctl-calls").read_text()) + self.assertNotIn("zurückgerollt", (self.root / "notices").read_text()) + + def test_unchanged_failed_updater_still_fails(self): + result = self.run_update(TEST_UPDATE_EXIT="7") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("Updater-Exitcode 7", result.stdout) + + def test_changed_commit_succeeds(self): + result = self.run_update(TEST_CHANGED="1") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("erfolgreich aktualisiert", result.stdout) + + def test_changed_commit_with_restart_warning(self): + result = self.run_update(TEST_CHANGED="1", TEST_UPDATE_EXIT="7") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Updater-Restartwarnung", result.stdout) + + def test_unhealthy_gateway_still_rolls_back(self): + result = self.run_update(TEST_HEALTHY="0") + self.assertEqual(result.returncode, 1, result.stderr) + self.assertIn("zurückgerollt", result.stdout) + self.assertIn("-xf", (self.root / "tar-calls").read_text()) + + +if __name__ == "__main__": + unittest.main()