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>
824 lines
34 KiB
Python
824 lines
34 KiB
Python
"""Integration: Steward signs in to Home Assistant, reads it, and installs one
|
|
update only after a newer backup, following it to its end. A fake Home Assistant
|
|
answers over httpx's mock transport. Requires STEWARD_DATABASE_URL."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import importlib
|
|
import json
|
|
import os
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import delete, select
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
ADDON = "update.example_update"
|
|
BEFORE, AFTER = "2026-10-30T03:00:00+00:00", "2026-10-31T03:00:00+00:00"
|
|
|
|
|
|
@pytest.fixture
|
|
def app():
|
|
if not os.environ.get("STEWARD_DATABASE_URL"):
|
|
pytest.skip("needs Postgres")
|
|
from steward.app import create_app
|
|
return create_app(testing=False)
|
|
|
|
|
|
def _ha(sub):
|
|
from steward.core.plugin_manager import get_loaded_plugin_modules
|
|
loaded = get_loaded_plugin_modules().get("homeassistant")
|
|
base = loaded.__name__ if loaded is not None else "plugins.homeassistant"
|
|
return importlib.import_module(f"{base}.{sub}")
|
|
|
|
|
|
class FakeHomeAssistant:
|
|
"""Its entities, the actions it received, what it refuses, and its sign-ins."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.tokens = set()
|
|
self.refreshes = []
|
|
self.revoked = []
|
|
self.refuse = False
|
|
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"),
|
|
"sensor.backup_last_successful_automatic_backup": {"state": BEFORE, "attributes": {}},
|
|
"event.backup_automatic_backup": {"state": BEFORE, "attributes": {"event_type": "completed"}},
|
|
}
|
|
|
|
def _update(self, state, installed):
|
|
return {"state": state, "attributes": {"title": "Example", "installed_version": installed,
|
|
"latest_version": "1.1", "in_progress": False,
|
|
"entity_picture": "/api/hassio/addons/example/icon"}}
|
|
|
|
def backup_lands(self):
|
|
self.entities["sensor.backup_last_successful_automatic_backup"]["state"] = AFTER
|
|
|
|
def update_lands(self):
|
|
self.entities[ADDON] = self._update("off", "1.1")
|
|
|
|
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
if self.down:
|
|
raise httpx.ConnectError("connection refused")
|
|
path = request.url.path
|
|
if path == "/auth/token":
|
|
form = parse_qs(request.content.decode())
|
|
self.refreshes.append(form)
|
|
if self.refuse:
|
|
return httpx.Response(400, json={"error": "invalid_grant"})
|
|
return httpx.Response(200, json={"access_token": f"access-{len(self.refreshes)}", "expires_in": 1800,
|
|
"refresh_token": "refresh-new", "token_type": "Bearer"})
|
|
if path == "/auth/revoke":
|
|
self.revoked.append(parse_qs(request.content.decode())["token"][0])
|
|
return httpx.Response(200)
|
|
self.tokens.add(request.headers.get("authorization"))
|
|
if self.refuse:
|
|
return httpx.Response(401)
|
|
if path == "/api/template":
|
|
return httpx.Response(200, text="1") if self.admin else httpx.Response(401)
|
|
if path.startswith("/api/services/"):
|
|
self.calls.append((path.removeprefix("/api/services/"), json.loads(request.content)))
|
|
if self.cut_off:
|
|
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()])
|
|
entity = self.entities.get(path.removeprefix("/api/states/"))
|
|
return httpx.Response(200, json=entity) if entity else httpx.Response(404)
|
|
|
|
@property
|
|
def transport(self):
|
|
return httpx.MockTransport(self.handler)
|
|
|
|
|
|
BACKUP = ("backup/create_automatic", {})
|
|
INSTALL = ("update/install", {"entity_id": ADDON})
|
|
|
|
|
|
async def _clean(app, **connection):
|
|
from steward.core.crypto import encrypt_secret
|
|
m = _ha("models")
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
await s.execute(delete(m.HomeAssistantUpdate))
|
|
await s.execute(delete(m.HomeAssistantConnection))
|
|
await s.execute(delete(m.HomeAssistantSchedule))
|
|
s.add(m.HomeAssistantConnection(**{
|
|
"url": "http://ha.test:8123", "client_id": "http://steward.test/",
|
|
"refresh_token": encrypt_secret(f"refresh-{uuid.uuid4().hex}"), "can_install": True, **connection}))
|
|
|
|
|
|
async def _state(app):
|
|
m = _ha("models")
|
|
async with app.db_sessionmaker() as s:
|
|
connection = (await s.execute(select(m.HomeAssistantConnection))).scalars().first()
|
|
runs = (await s.execute(select(m.HomeAssistantUpdate))).scalars().all()
|
|
return connection, runs
|
|
|
|
|
|
def test_a_poll_stores_what_it_read_and_records_its_metrics(app):
|
|
from steward.models.metrics import PluginMetricLatest
|
|
sched = _ha("scheduler")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport)
|
|
connection, _ = await _state(app)
|
|
async with app.db_sessionmaker() as s:
|
|
rows = (await s.execute(select(PluginMetricLatest).where(
|
|
PluginMetricLatest.source_module == "homeassistant"))).scalars().all()
|
|
return connection, {r.metric_name: r.value for r in rows}
|
|
|
|
connection, metrics = asyncio.run(go())
|
|
assert ha.tokens == {"Bearer access-1"}
|
|
assert connection.last_error is None and not connection.refused
|
|
assert [u["entity_id"] for u in connection.updates] == [ADDON]
|
|
assert connection.backup["last_ok"] == BEFORE
|
|
assert metrics["is_up"] == 1.0 and metrics["updates_waiting"] == 1.0 and metrics["backup_age_hours"] >= 0
|
|
|
|
|
|
def test_a_sign_in_lasts_until_its_access_token_nearly_runs_out_then_is_refreshed(app):
|
|
client = _ha("client")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
connection = (await _state(app))[0]
|
|
first = await client.access_token(connection, ha.transport)
|
|
again = await client.access_token(connection, ha.transport)
|
|
assert first == again and len(ha.refreshes) == 1
|
|
for key, (token, _until) in list(client._access.items()):
|
|
client._access[key] = (token, time.monotonic() - 1)
|
|
return await client.access_token(connection, ha.transport)
|
|
|
|
assert asyncio.run(go()) == "access-2"
|
|
[form] = ha.refreshes[:1]
|
|
assert form["grant_type"] == ["refresh_token"] and form["client_id"] == ["http://steward.test/"]
|
|
|
|
|
|
def test_a_refused_sign_in_is_asked_once_and_not_again(app):
|
|
sched = _ha("scheduler")
|
|
ha = FakeHomeAssistant()
|
|
ha.refuse = True
|
|
seen = []
|
|
real = ha.handler
|
|
|
|
def counted(request):
|
|
seen.append(request.url.path)
|
|
return real(request)
|
|
transport = httpx.MockTransport(counted)
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=transport)
|
|
await sched.poll(app, transport=transport)
|
|
return (await _state(app))[0]
|
|
|
|
connection = asyncio.run(go())
|
|
assert seen == ["/auth/token"]
|
|
assert connection.refused and "Sign in again" in connection.last_error
|
|
|
|
|
|
def test_an_update_waits_for_a_newer_backup_then_installs_and_ends_on_the_timeline(app):
|
|
from steward.models.timeline import TimelineEntry
|
|
sched, upd = _ha("scheduler"), _ha("updates")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport)
|
|
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
|
|
assert ha.calls == [BACKUP]
|
|
assert run.state == "backing_up" and run.from_version == "1.0" and run.to_version == "1.1"
|
|
|
|
# No newer backup yet: nothing is installed, however often it is asked.
|
|
await upd.advance(app, transport=ha.transport)
|
|
await upd.advance(app, transport=ha.transport)
|
|
assert ha.calls == [BACKUP]
|
|
|
|
ha.backup_lands()
|
|
await upd.advance(app, transport=ha.transport)
|
|
_, [row] = await _state(app)
|
|
assert row.state == "installing" and row.backup_at is not None
|
|
await upd.advance(app, transport=ha.transport)
|
|
assert ha.calls == [BACKUP, INSTALL]
|
|
|
|
# Home Assistant restarting is expected, and isn't the end of the update.
|
|
ha.down = True
|
|
await upd.advance(app, transport=ha.transport)
|
|
_, [row] = await _state(app)
|
|
assert row.state == "installing"
|
|
ha.down = False
|
|
|
|
ha.update_lands()
|
|
await upd.advance(app, transport=ha.transport)
|
|
connection, [row] = await _state(app)
|
|
assert row.state == "done" and row.finished_at is not None
|
|
assert connection.last_polled_at is None # the page shows the new version at the next tick
|
|
await upd.advance(app, transport=ha.transport)
|
|
assert len(ha.calls) == 2
|
|
async with app.db_sessionmaker() as s:
|
|
entries = (await s.execute(select(TimelineEntry).where(
|
|
TimelineEntry.ref_type == "homeassistant_update", TimelineEntry.ref_id == row.id))).scalars().all()
|
|
return entries
|
|
|
|
[entry] = asyncio.run(go())
|
|
assert entry.type == "homeassistant.updated" and entry.actor == "someone"
|
|
assert "1.0 to 1.1" in entry.summary and entry.area == "service"
|
|
|
|
|
|
def test_a_failed_backup_installs_nothing(app):
|
|
sched, upd = _ha("scheduler"), _ha("updates")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport)
|
|
await upd.start_update(app, ADDON, "someone", transport=ha.transport)
|
|
ha.entities["event.backup_automatic_backup"] = {
|
|
"state": AFTER, "attributes": {"event_type": "failed", "failed_reason": "upload_failed"}}
|
|
await upd.advance(app, transport=ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
[run] = asyncio.run(go())
|
|
assert run.state == "failed" and "upload_failed" in run.message and "Nothing was installed" in run.message
|
|
assert ha.calls == [BACKUP]
|
|
|
|
|
|
def test_an_action_home_assistant_is_still_working_on_counts_as_sent(app):
|
|
"""It answers when finished, and an update that restarts it never does."""
|
|
sched, upd = _ha("scheduler"), _ha("updates")
|
|
ha = FakeHomeAssistant()
|
|
ha.cut_off = True
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport)
|
|
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
|
|
assert run.state == "backing_up"
|
|
ha.backup_lands()
|
|
await upd.advance(app, transport=ha.transport)
|
|
await upd.advance(app, transport=ha.transport)
|
|
await upd.advance(app, transport=ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
[run] = asyncio.run(go())
|
|
assert run.state == "installing" and run.install_sent_at is not None
|
|
assert ha.calls == [BACKUP, INSTALL]
|
|
|
|
|
|
def test_an_install_home_assistant_rejects_fails_with_what_it_said(app):
|
|
sched, upd = _ha("scheduler"), _ha("updates")
|
|
ha = FakeHomeAssistant()
|
|
ha.reject_install = "Not enough free space"
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport)
|
|
await upd.start_update(app, ADDON, "someone", transport=ha.transport)
|
|
ha.backup_lands()
|
|
await upd.advance(app, transport=ha.transport)
|
|
await upd.advance(app, transport=ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
[run] = asyncio.run(go())
|
|
assert run.state == "failed" and "Not enough free space" in run.message
|
|
|
|
|
|
def test_an_install_that_couldnt_reach_home_assistant_is_asked_for_again(app):
|
|
sched, upd = _ha("scheduler"), _ha("updates")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport)
|
|
await upd.start_update(app, ADDON, "someone", transport=ha.transport)
|
|
# Now, as the install's deadline counts from the backup while it is unsent.
|
|
ha.entities["sensor.backup_last_successful_automatic_backup"]["state"] = datetime.now(timezone.utc).isoformat()
|
|
await upd.advance(app, transport=ha.transport)
|
|
ha.down = True
|
|
await upd.advance(app, transport=ha.transport)
|
|
assert (await _state(app))[1][0].install_sent_at is None
|
|
ha.down = False
|
|
await upd.advance(app, transport=ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
[run] = asyncio.run(go())
|
|
assert run.install_sent_at is not None and ha.calls == [BACKUP, INSTALL]
|
|
|
|
|
|
def test_an_install_past_its_deadline_fails_and_only_one_runs_at_a_time(app):
|
|
from datetime import timedelta
|
|
sched, upd, m = _ha("scheduler"), _ha("updates"), _ha("models")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport)
|
|
run = await upd.start_update(app, ADDON, "someone", transport=ha.transport)
|
|
with pytest.raises(upd.CannotStart, match="under way"):
|
|
await upd.start_update(app, ADDON, "someone else", transport=ha.transport)
|
|
ha.backup_lands()
|
|
await upd.advance(app, transport=ha.transport)
|
|
await upd.advance(app, transport=ha.transport)
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
row = await s.get(m.HomeAssistantUpdate, run.id)
|
|
row.install_sent_at -= timedelta(seconds=upd.INSTALL_DEADLINE_S["addon"] + 1)
|
|
await upd.advance(app, transport=ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
[run] = asyncio.run(go())
|
|
assert run.state == "failed" and "Still not on 1.1" in run.message
|
|
assert len(ha.calls) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("entity,change,says", [
|
|
("update.unknown", None, "isn't a Core, OS or add-on"),
|
|
(ADDON, "no_longer_waiting", "no longer waiting"),
|
|
(ADDON, "not_an_admin", "isn't an administrator"),
|
|
])
|
|
def test_nothing_starts_unless_the_update_can_run(app, entity, change, says):
|
|
sched, upd = _ha("scheduler"), _ha("updates")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app, can_install=change != "not_an_admin")
|
|
await sched.poll(app, transport=ha.transport)
|
|
if change == "no_longer_waiting":
|
|
ha.update_lands()
|
|
with pytest.raises(upd.CannotStart, match=says):
|
|
await upd.start_update(app, entity, "someone", transport=ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
assert asyncio.run(go()) == []
|
|
assert ha.calls == []
|
|
|
|
|
|
def _admin_client(app):
|
|
from steward.models.users import User, UserRole
|
|
tag = uuid.uuid4().hex[:8]
|
|
uid = str(uuid.uuid4())
|
|
|
|
async def make():
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
s.add(User(id=uid, username=f"ha-{tag}", email=f"ha-{tag}@test", password_hash="x",
|
|
role=UserRole.admin, is_active=True))
|
|
client = app.test_client()
|
|
async with client.session_transaction() as sess:
|
|
sess.update(user_id=uid, user_role="admin", username=f"ha-{tag}", _csrf="test-token", login_at=time.time())
|
|
return client, f"ha-{tag}"
|
|
return make()
|
|
|
|
|
|
CSRF = {"X-CSRF-Token": "test-token"}
|
|
|
|
|
|
@pytest.mark.parametrize("admin", [True, False])
|
|
def test_signing_in_goes_to_home_assistant_and_keeps_what_comes_back(app, monkeypatch, admin):
|
|
from steward.core.crypto import decrypt_secret
|
|
client, m = _ha("client"), _ha("models")
|
|
ha = FakeHomeAssistant()
|
|
ha.admin = admin
|
|
for name in ("redeem_code", "is_admin", "revoke"):
|
|
real = getattr(client, name)
|
|
if name == "revoke":
|
|
monkeypatch.setattr(client, name, lambda c, real=real: real(c, ha.transport))
|
|
else:
|
|
monkeypatch.setattr(client, name, lambda *a, real=real: real(*a, ha.transport))
|
|
|
|
async def go():
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
await s.execute(delete(m.HomeAssistantUpdate))
|
|
await s.execute(delete(m.HomeAssistantConnection))
|
|
web, username = await _admin_client(app)
|
|
response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF,
|
|
form={"url": "http://ha.test:8123/", "verify_tls": "on"})
|
|
assert response.status_code == 302
|
|
to = urlparse(response.headers["Location"])
|
|
query = parse_qs(to.query)
|
|
assert f"{to.scheme}://{to.netloc}{to.path}" == "http://ha.test:8123/auth/authorize"
|
|
assert query["response_type"] == ["code"]
|
|
assert urlparse(query["redirect_uri"][0]).netloc == urlparse(query["client_id"][0]).netloc
|
|
assert query["redirect_uri"][0].endswith("/plugins/homeassistant/connection/callback")
|
|
|
|
# Coming back with someone else's state changes nothing.
|
|
forged = await web.get("/plugins/homeassistant/connection/callback?code=c&state=forged")
|
|
assert forged.status_code == 400 and not ha.refreshes
|
|
assert (await _state(app))[0] is None
|
|
|
|
response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF,
|
|
form={"url": "http://ha.test:8123/", "verify_tls": "on"})
|
|
state = parse_qs(urlparse(response.headers["Location"]).query)["state"][0]
|
|
back = await web.get(f"/plugins/homeassistant/connection/callback?code=the-code&state={state}")
|
|
assert back.status_code == 302
|
|
again = await web.get(f"/plugins/homeassistant/connection/callback?code=the-code&state={state}")
|
|
assert again.status_code == 400 and len(ha.refreshes) == 1 # a state works once
|
|
connection = (await _state(app))[0]
|
|
|
|
removed = await web.post("/plugins/homeassistant/connection/delete", headers=CSRF)
|
|
assert removed.status_code == 302
|
|
return connection, username, (await _state(app))[0]
|
|
|
|
connection, username, after = asyncio.run(go())
|
|
assert ha.refreshes[0]["grant_type"] == ["authorization_code"] and ha.refreshes[0]["code"] == ["the-code"]
|
|
assert connection.url == "http://ha.test:8123" and connection.verify_tls
|
|
assert decrypt_secret(connection.refresh_token, context="test") == "refresh-new"
|
|
assert connection.refresh_token != "refresh-new"
|
|
assert connection.can_install is admin and connection.signed_in_by == username and connection.signed_in_at
|
|
assert ha.revoked == ["refresh-new"] and after is None
|
|
|
|
|
|
def test_signing_in_again_ends_the_sign_in_it_replaces(app, monkeypatch):
|
|
from steward.core.crypto import encrypt_secret
|
|
client = _ha("client")
|
|
ha = FakeHomeAssistant()
|
|
for name in ("redeem_code", "is_admin"):
|
|
real = getattr(client, name)
|
|
monkeypatch.setattr(client, name, lambda *a, real=real: real(*a, ha.transport))
|
|
real_revoke = client.revoke
|
|
monkeypatch.setattr(client, "revoke", lambda c: real_revoke(c, ha.transport))
|
|
|
|
async def go():
|
|
await _clean(app, refresh_token=encrypt_secret("refresh-old"), refused=True)
|
|
web, _ = await _admin_client(app)
|
|
response = await web.post("/plugins/homeassistant/connection/sign-in", headers=CSRF,
|
|
form={"url": "http://ha.test:8123", "verify_tls": "on"})
|
|
state = parse_qs(urlparse(response.headers["Location"]).query)["state"][0]
|
|
await web.get(f"/plugins/homeassistant/connection/callback?code=c&state={state}")
|
|
return (await _state(app))[0]
|
|
|
|
connection = asyncio.run(go())
|
|
assert ha.revoked == ["refresh-old"] and not connection.refused and connection.last_polled_at is None
|
|
|
|
|
|
# ── The system, over the websocket ───────────────────────────────────────────
|
|
|
|
class FakeSocket:
|
|
"""Home Assistant's websocket: `answers` by question type or Supervisor endpoint,
|
|
where one left out is refused as it is for a non-administrator."""
|
|
|
|
def __init__(self, answers, auth="auth_ok"):
|
|
self.answers, self.auth = answers, auth
|
|
self.waiting = [{"type": "auth_required"}]
|
|
self.asked = []
|
|
|
|
def __call__(self, url, **options):
|
|
self.url = url
|
|
return self
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
async def send(self, text):
|
|
question = json.loads(text)
|
|
if question["type"] == "auth":
|
|
self.waiting.append({"type": self.auth})
|
|
return
|
|
key = question.get("endpoint") or question["type"]
|
|
self.asked.append(key)
|
|
# Something unasked arriving first must be passed over.
|
|
self.waiting.append({"type": "event", "id": 9999})
|
|
self.waiting.append({"id": question["id"], "success": key in self.answers, "result": self.answers.get(key)})
|
|
|
|
async def recv(self):
|
|
return json.dumps(self.waiting.pop(0))
|
|
|
|
|
|
def test_a_poll_keeps_the_system_problems_and_records_them(app):
|
|
from steward.models.metrics import PluginMetricLatest
|
|
sched = _ha("scheduler")
|
|
ha = FakeHomeAssistant()
|
|
socket = FakeSocket({
|
|
"get_config": {"recovery_mode": False},
|
|
"repairs/list_issues": {"issues": [{"domain": "hassio", "translation_key": "issue_x", "severity": "warning"}]},
|
|
"frontend/get_translations": {"resources": {"component.hassio.issues.issue_x.title": "Something's wrong"}},
|
|
"/resolution/info": {"unhealthy": [], "unsupported": []},
|
|
"/host/info": {"disk_free": 50.0, "disk_total": 100.0},
|
|
"/addons": {"addons": [{"slug": "a", "state": "started"}, {"slug": "b", "name": "B", "state": "stopped"}]},
|
|
"/addons/b/info": {"boot": "auto"},
|
|
})
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport, connect=socket)
|
|
connection, _ = await _state(app)
|
|
async with app.db_sessionmaker() as s:
|
|
rows = (await s.execute(select(PluginMetricLatest).where(
|
|
PluginMetricLatest.source_module == "homeassistant"))).scalars().all()
|
|
return connection, {r.metric_name: r.value for r in rows}
|
|
|
|
connection, metrics = asyncio.run(go())
|
|
assert socket.url == "ws://ha.test:8123/api/websocket"
|
|
assert "/addons/a/info" not in socket.asked # a running add-on isn't asked after
|
|
assert [p["title"] for p in connection.health["problems"]] == ["Something's wrong", "B should start on its own and isn't running."]
|
|
assert metrics["problems_open"] == 2.0 and metrics["disk_free_pct"] == 50.0 and metrics["update_failed"] == 0.0
|
|
|
|
|
|
def test_the_page_shows_storage_used_of_the_total_with_the_percentage(app):
|
|
async def go():
|
|
await _clean(app, health={"repairs_read": True, "problems": [], "disk_free_gb": 99.8, "disk_used_gb": 12.0,
|
|
"disk_total_gb": 116.5, "disk_free_pct": 85.7, "disk_used_pct": 10.3})
|
|
web, _ = await _admin_client(app)
|
|
return await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
|
|
|
page = asyncio.run(go())
|
|
assert "12.0 of 116.5 GB (10%)" in page
|
|
assert "Disk free" not in page
|
|
|
|
|
|
def test_the_page_says_an_os_update_waits_on_a_reboot_rather_than_making_one(app):
|
|
async def go():
|
|
await _clean(app)
|
|
web, _ = await _admin_client(app)
|
|
return await (await web.get("/plugins/homeassistant/")).get_data(as_text=True)
|
|
|
|
page = asyncio.run(go())
|
|
assert "takes effect only once you reboot the machine" in page
|
|
assert "reboots the machine" not in page
|
|
|
|
|
|
def test_a_report_it_couldnt_read_leaves_the_last_one_and_home_assistant_still_answers(app):
|
|
sched = _ha("scheduler")
|
|
ha = FakeHomeAssistant()
|
|
|
|
def unreachable(url, **options):
|
|
raise OSError("connection refused")
|
|
|
|
async def go():
|
|
await _clean(app, health={"repairs_read": True, "problems": [{"title": "Old"}]})
|
|
await sched.poll(app, transport=ha.transport, connect=unreachable)
|
|
return (await _state(app))[0]
|
|
|
|
connection = asyncio.run(go())
|
|
assert connection.last_error is None and not connection.refused
|
|
assert connection.health["problems"] == [{"title": "Old"}] and "connection refused" in connection.health["error"]
|
|
|
|
|
|
def test_a_refused_websocket_stops_the_polling_like_any_refusal(app):
|
|
sched = _ha("scheduler")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}, auth="auth_invalid"))
|
|
return (await _state(app))[0]
|
|
|
|
connection = asyncio.run(go())
|
|
assert connection.refused and "Sign in again" in connection.last_error
|
|
|
|
|
|
# ── Updates on a schedule ────────────────────────────────────────────────────
|
|
|
|
CORE = "update.home_assistant_core_update"
|
|
|
|
|
|
async def _a_slot_just_passed(app, minutes=10):
|
|
from datetime import timedelta
|
|
m = _ha("models")
|
|
at = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
s.add(m.HomeAssistantSchedule(enabled=True, weekday=at.weekday(), at_time=f"{at:%H:%M}", time_zone="UTC",
|
|
window_minutes=240, created_at=at - timedelta(days=30)))
|
|
|
|
|
|
async def _schedule_row(app):
|
|
m = _ha("models")
|
|
async with app.db_sessionmaker() as s:
|
|
return (await s.execute(select(m.HomeAssistantSchedule))).scalars().first()
|
|
|
|
|
|
def test_a_slot_installs_the_add_on_first_then_core_and_stops_at_a_failure(app):
|
|
sched, plan = _ha("scheduler"), _ha("schedule")
|
|
m = _ha("models")
|
|
ha = FakeHomeAssistant()
|
|
ha.entities[CORE] = ha._update("on", "1.0")
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await _a_slot_just_passed(app)
|
|
await plan.step(app, ha.transport) # the slot fires and asks for a fresh read
|
|
assert not (await _state(app))[1] and (await _state(app))[0].last_polled_at is None
|
|
await plan.step(app, ha.transport) # nothing starts before that read
|
|
assert not (await _state(app))[1]
|
|
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
|
await plan.step(app, ha.transport)
|
|
[first] = (await _state(app))[1]
|
|
assert first.entity_id == ADDON and first.started_by == "schedule"
|
|
await plan.step(app, ha.transport) # one at a time
|
|
assert len((await _state(app))[1]) == 1
|
|
async with app.db_sessionmaker() as s:
|
|
async with s.begin():
|
|
run = await s.get(m.HomeAssistantUpdate, first.id)
|
|
run.state, run.message = "failed", "It broke."
|
|
await plan.step(app, ha.transport)
|
|
assert "It broke." in (await _schedule_row(app)).stopped
|
|
await plan.step(app, ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
assert [r.entity_id for r in asyncio.run(go())] == [ADDON]
|
|
assert ha.calls == [BACKUP]
|
|
|
|
|
|
def test_a_slot_moves_on_after_a_good_update_and_never_retries_one(app):
|
|
sched, plan, updates = _ha("scheduler"), _ha("schedule"), _ha("updates")
|
|
ha = FakeHomeAssistant()
|
|
ha.entities[CORE] = ha._update("on", "1.0")
|
|
|
|
async def go():
|
|
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
|
|
ha.update_lands()
|
|
await updates.advance(app, ha.transport) # it's done, and asks for a fresh read
|
|
await plan.step(app, ha.transport)
|
|
assert len((await _state(app))[1]) == 1
|
|
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
|
await plan.step(app, ha.transport)
|
|
runs = (await _state(app))[1]
|
|
return {r.entity_id: r.state for r in runs}
|
|
|
|
assert asyncio.run(go()) == {ADDON: "done", CORE: "backing_up"}
|
|
|
|
|
|
def test_a_slot_that_cant_start_an_update_says_why_and_asks_no_more(app):
|
|
sched, plan = _ha("scheduler"), _ha("schedule")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await _a_slot_just_passed(app)
|
|
await plan.step(app, ha.transport)
|
|
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
|
del ha.entities["sensor.backup_last_successful_automatic_backup"]
|
|
await plan.step(app, ha.transport)
|
|
return await _schedule_row(app)
|
|
|
|
row = asyncio.run(go())
|
|
assert "wasn't started" in row.stopped and "no automatic backups" in row.stopped and not ha.calls
|
|
|
|
|
|
def test_a_slot_past_its_window_starts_nothing_and_a_new_schedule_waits_for_its_next(app):
|
|
sched, plan = _ha("scheduler"), _ha("schedule")
|
|
ha = FakeHomeAssistant()
|
|
|
|
async def go():
|
|
await _clean(app)
|
|
await _a_slot_just_passed(app, minutes=300)
|
|
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
|
await plan.step(app, ha.transport)
|
|
assert not (await _state(app))[1]
|
|
await _clean(app)
|
|
at = datetime.now(timezone.utc)
|
|
await plan.save(app, enabled=True, weekday=at.weekday(), at_time="00:00", time_zone="UTC")
|
|
await sched.poll(app, transport=ha.transport, connect=FakeSocket({}))
|
|
await plan.step(app, ha.transport)
|
|
return (await _state(app))[1]
|
|
|
|
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
|