The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.
A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.
The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
681 lines
28 KiB
Python
681 lines
28 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.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})
|
|
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_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 == []
|