feat(homeassistant): a scheduled OS update reboots the machine and is done once the new version runs (#6755)
Home Assistant writes an OS update and leaves the reboot to someone, so a scheduled one never finished unattended. The schedule now asks for the reboot once, follows it through Home Assistant going away, and counts it done when the Supervisor reports a different version with nothing pending. A machine that doesn't come back or falls back to the old version fails after 20 minutes, which ends the slot and raises update_failed. Install on the page and through MCP still leave the reboot to the person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,7 @@ class FakeHomeAssistant:
|
||||
self.down = False
|
||||
self.admin = True
|
||||
self.reject_install = None
|
||||
self.reject_reboot = None
|
||||
self.cut_off = False
|
||||
self.entities = {
|
||||
ADDON: self._update("on", "1.0"),
|
||||
@@ -92,6 +93,8 @@ class FakeHomeAssistant:
|
||||
raise httpx.ReadTimeout("no answer yet")
|
||||
if self.reject_install and path.endswith("update/install"):
|
||||
return httpx.Response(500, json={"message": self.reject_install})
|
||||
if self.reject_reboot and path.endswith("hassio/host_reboot"):
|
||||
return httpx.Response(500, json={"message": self.reject_reboot})
|
||||
return httpx.Response(200, json=[])
|
||||
if path == "/api/states":
|
||||
return httpx.Response(200, json=[{"entity_id": k, **v} for k, v in self.entities.items()])
|
||||
@@ -701,3 +704,120 @@ def test_a_slot_past_its_window_starts_nothing_and_a_new_schedule_waits_for_its_
|
||||
|
||||
assert asyncio.run(go()) == []
|
||||
assert ha.calls == []
|
||||
|
||||
|
||||
REBOOT = ("hassio/host_reboot", {})
|
||||
|
||||
|
||||
def _ha_with_os_update_waiting():
|
||||
ha = FakeHomeAssistant()
|
||||
del ha.entities[ADDON]
|
||||
ha.entities[_ha("service").OS] = ha._update("on", "1.0")
|
||||
return ha
|
||||
|
||||
|
||||
async def _scheduled_os_update_written(app, ha):
|
||||
"""A slot's OS update, followed to where it has been written and needs its reboot."""
|
||||
sched, plan, updates = _ha("scheduler"), _ha("schedule"), _ha("updates")
|
||||
await _clean(app)
|
||||
await _a_slot_just_passed(app)
|
||||
await plan.step(app, ha.transport)
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await plan.step(app, ha.transport)
|
||||
ha.backup_lands()
|
||||
await updates.advance(app, ha.transport) # the backup landed
|
||||
await updates.advance(app, ha.transport) # the install is sent
|
||||
# Home Assistant reports the new version as installed before the reboot.
|
||||
ha.entities[_ha("service").OS] = ha._update("off", "1.1")
|
||||
await updates.advance(app, ha.transport)
|
||||
[run] = (await _state(app))[1]
|
||||
assert run.state == "rebooting" and REBOOT not in ha.calls
|
||||
|
||||
|
||||
def test_a_scheduled_os_update_reboots_once_and_is_done_when_the_new_version_runs(app):
|
||||
updates = _ha("updates")
|
||||
ha = _ha_with_os_update_waiting()
|
||||
|
||||
def down(url, **options):
|
||||
raise OSError("connection refused")
|
||||
|
||||
async def go():
|
||||
await _scheduled_os_update_written(app, ha)
|
||||
await updates.advance(app, ha.transport)
|
||||
assert ha.calls.count(REBOOT) == 1
|
||||
|
||||
await updates.advance(app, ha.transport, FakeSocket({"/os/info": {"version": "1.0", "version_pending": "1.1"}}))
|
||||
assert (await _state(app))[1][0].state == "rebooting" # not gone down yet
|
||||
web, _ = await _admin_client(app)
|
||||
page = await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
||||
assert "rebooting into 1.1" in page and 'hx-trigger="every 15s"' in page
|
||||
|
||||
await updates.advance(app, ha.transport, down)
|
||||
assert (await _state(app))[1][0].state == "rebooting" # down, and expected to be
|
||||
await updates.advance(app, ha.transport, FakeSocket({}))
|
||||
assert (await _state(app))[1][0].state == "rebooting" # back, but the Supervisor isn't answering yet
|
||||
await updates.advance(app, ha.transport, FakeSocket({"/os/info": {"version": "1.1", "version_pending": None}}))
|
||||
return (await _state(app))[1][0]
|
||||
|
||||
run = asyncio.run(go())
|
||||
assert run.state == "done" and run.finished_at is not None
|
||||
assert ha.calls.count(REBOOT) == 1
|
||||
|
||||
|
||||
def test_a_machine_that_falls_back_to_the_old_os_fails_at_the_deadline_and_ends_the_slot(app):
|
||||
from datetime import timedelta
|
||||
updates, plan, m = _ha("updates"), _ha("schedule"), _ha("models")
|
||||
ha = _ha_with_os_update_waiting()
|
||||
old_os = FakeSocket({"/os/info": {"version": "1.0", "version_pending": None}})
|
||||
|
||||
async def go():
|
||||
await _scheduled_os_update_written(app, ha)
|
||||
await updates.advance(app, ha.transport)
|
||||
await updates.advance(app, ha.transport, old_os)
|
||||
assert (await _state(app))[1][0].state == "rebooting"
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
run = (await s.execute(select(m.HomeAssistantUpdate))).scalars().first()
|
||||
run.reboot_sent_at = datetime.now(timezone.utc) - timedelta(minutes=updates.REBOOT_DEADLINE_S // 60 + 1)
|
||||
await updates.advance(app, ha.transport, FakeSocket({"/os/info": {"version": "1.0", "version_pending": None}}))
|
||||
await _ha("scheduler").poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await plan.step(app, ha.transport)
|
||||
return (await _state(app))[1][0], await _schedule_row(app)
|
||||
|
||||
run, schedule = asyncio.run(go())
|
||||
assert run.state == "failed" and "isn't running" in run.message
|
||||
assert "isn't running" in schedule.stopped
|
||||
|
||||
|
||||
def test_a_refused_reboot_fails_the_update_and_says_it_still_needs_one(app):
|
||||
updates = _ha("updates")
|
||||
ha = _ha_with_os_update_waiting()
|
||||
ha.reject_reboot = "Not now"
|
||||
|
||||
async def go():
|
||||
await _scheduled_os_update_written(app, ha)
|
||||
await updates.advance(app, ha.transport)
|
||||
return (await _state(app))[1][0]
|
||||
|
||||
run = asyncio.run(go())
|
||||
assert run.state == "failed" and "Not now" in run.message and "needs a reboot" in run.message
|
||||
|
||||
|
||||
def test_an_os_update_started_by_a_person_leaves_the_reboot_to_them(app):
|
||||
sched, updates = _ha("scheduler"), _ha("updates")
|
||||
ha = _ha_with_os_update_waiting()
|
||||
|
||||
async def go():
|
||||
await _clean(app)
|
||||
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
||||
await updates.start_update(app, _ha("service").OS, "someone", ha.transport)
|
||||
ha.backup_lands()
|
||||
await updates.advance(app, ha.transport)
|
||||
await updates.advance(app, ha.transport)
|
||||
ha.entities[_ha("service").OS] = ha._update("off", "1.1")
|
||||
await updates.advance(app, ha.transport)
|
||||
await updates.advance(app, ha.transport)
|
||||
return (await _state(app))[1][0]
|
||||
|
||||
assert asyncio.run(go()).state == "done"
|
||||
assert REBOOT not in ha.calls
|
||||
|
||||
Reference in New Issue
Block a user