fix(hermes): ha-updates --rollback must really roll back (review 1771)
ci/woodpecker/pr/ci-scripts-test Pipeline was successful
ci/woodpecker/pr/ci-manifests-test Pipeline was successful

- install --rollback without --version is refused before any HA call, and
  --rollback to the entity's latest_version is refused as a forward install,
  so --rollback cannot bypass the 60-minute HACS limit.
- SKILL.md command list shows --rollback.
- Tests for both refusals; 16 pass locally and in the pinned CI image.
This commit is contained in:
2026-10-20 21:13:24 +02:00
parent 2ae6998bca
commit fdc45b0785
3 changed files with 20 additions and 5 deletions
@@ -30,7 +30,7 @@ python3 /opt/hermes-skills/ha-updates/scripts/ha.py window # in_
python3 /opt/hermes-skills/ha-updates/scripts/ha.py updates # pending update entities
python3 /opt/hermes-skills/ha-updates/scripts/ha.py snapshot <name> # health -> /opt/data/ha-updates/<name>.json
python3 /opt/hermes-skills/ha-updates/scripts/ha.py compare <before> <after> # exit 1 + problems on regression
python3 /opt/hermes-skills/ha-updates/scripts/ha.py install <entity_id> [--version V] # waits until settled
python3 /opt/hermes-skills/ha-updates/scripts/ha.py install <entity_id> [--version V] [--rollback] # waits until settled; --rollback needs --version <old>
python3 /opt/hermes-skills/ha-updates/scripts/ha.py restart # restart, wait for RUNNING
```
@@ -216,6 +216,8 @@ def entity(entity_id):
def cmd_install(a):
if a.rollback and not a.version:
sys.exit("ha: refused: --rollback needs --version <previous version>")
w = require_window("install") # before any HA call
if not a.rollback and domains().get(a.entity_id) == "hacs":
w = require_window("hacs")
@@ -229,6 +231,8 @@ def cmd_install(a):
body = {"entity_id": a.entity_id}
if a.version:
body["version"] = a.version
if a.rollback and a.version == attrs.get("latest_version"):
sys.exit("ha: refused: --rollback to the latest version is a forward install")
target = a.version or attrs.get("latest_version")
started = time.time()
try:
+15 -4
View File
@@ -53,12 +53,15 @@ class Window(unittest.TestCase):
with mock.patch.object(ha, "window_state", return_value=ha.window_state(berlin(4, 40))):
self.assertEqual(ha.require_window("restart")["minutes_left"], 20)
def install_at(self, now, domain, rollback=False):
args = mock.Mock(entity_id="update.x", version=None, timeout=3600, rollback=rollback)
state = {"attributes": {"supported_features": 1, "installed_version": "1", "latest_version": "2"}}
def install_at(self, now, domain, rollback=False, version=None):
args = mock.Mock(entity_id="update.x", version=version or ("1" if rollback else None),
timeout=3600, rollback=rollback)
state = {"attributes": {"supported_features": 1 | 2, "installed_version": "2" if rollback else "1",
"latest_version": "2"}}
done_state = {"attributes": {"installed_version": args.version or "2"}}
with mock.patch.object(ha, "window_state", return_value=ha.window_state(now)), \
mock.patch.object(ha, "domains", return_value={"update.x": domain}), \
mock.patch.object(ha, "entity", side_effect=[state, {"attributes": {"installed_version": "2"}}]), \
mock.patch.object(ha, "entity", side_effect=[state, done_state]), \
mock.patch.object(ha, "call") as call, mock.patch("builtins.print"):
with self.assertRaises(SystemExit) as done:
ha.cmd_install(args)
@@ -76,6 +79,14 @@ class Window(unittest.TestCase):
self.assertEqual(self.install_at(berlin(4, 0), "hacs"), 55 * 60)
self.assertEqual(self.install_at(berlin(4, 30), "hacs", rollback=True), 25 * 60)
def test_rollback_must_be_a_rollback(self):
with mock.patch.object(ha, "call") as call:
with self.assertRaisesRegex(SystemExit, "--rollback needs --version"):
ha.cmd_install(mock.Mock(entity_id="update.x", version=None, timeout=3600, rollback=True))
call.assert_not_called()
with self.assertRaisesRegex(AssertionError, "forward install"):
self.install_at(berlin(4, 30), "hacs", rollback=True, version="2")
def test_install_and_restart_check_window_before_any_call(self):
with mock.patch.object(ha, "window_state", return_value=ha.window_state(berlin(12, 0))), \
mock.patch.object(ha, "call") as call: