feat(homeassistant): an admin signs in to Home Assistant, with nothing added to it (#6355)
Replaces the read-only token and the webhook automation, which every install would have had to paste into its own Home Assistant. Connect now goes to Home Assistant's own sign-in page, and Steward keeps the refresh token that comes back, encrypted, and calls backup.create_automatic and update.install directly. The connection page warns that the stored credential has the same access as the user who signed in, and recommends a service account so Steward's actions are easy to tell apart in Home Assistant's logs. A user who isn't an administrator gets the status page and no Install. Those actions answer only when finished, so a request cut off once sent counts as sent and the outcome is read from the entities. Removing the connection, or signing in again, ends the old sign-in on Home Assistant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,19 +10,15 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from steward.core.crypto import decrypt_secret
|
||||
|
||||
from . import client
|
||||
from .models import HomeAssistantConnection, HomeAssistantUpdate
|
||||
from .service import BACKUP_EVENT, LAST_BACKUP, automation_entity_id, is_newer, parse_moment
|
||||
from .service import BACKUP_EVENT, LAST_BACKUP, is_newer, parse_moment
|
||||
from .timeline import update_entry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNDER_WAY = ("backing_up", "installing")
|
||||
BACKUP_DEADLINE_S = 30 * 60
|
||||
# How long the automation has to show it ran before the webhook is called wrong.
|
||||
TRIGGER_GRACE_S = 90
|
||||
# An OS update reboots the machine, and a Pi is slow to come back.
|
||||
INSTALL_DEADLINE_S = {"addon": 20 * 60, "core": 30 * 60, "os": 45 * 60}
|
||||
|
||||
@@ -31,11 +27,6 @@ class CannotStart(Exception):
|
||||
"""Why an update wasn't started, said to the person."""
|
||||
|
||||
|
||||
def _secrets(connection) -> tuple[str, str]:
|
||||
return (decrypt_secret(connection.token, context="Home Assistant token"),
|
||||
decrypt_secret(connection.webhook_id, context="Home Assistant webhook id"))
|
||||
|
||||
|
||||
async def _mark_refused(app, connection_id: str, said: str) -> None:
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
@@ -52,27 +43,26 @@ async def under_way(session) -> HomeAssistantUpdate | None:
|
||||
async def start_update(app, entity_id: str, username: str, transport=None) -> HomeAssistantUpdate:
|
||||
"""Start installing `entity_id`, an explicit action that may contact Home
|
||||
Assistant. Raises CannotStart, with nothing started, unless the update is
|
||||
still waiting and the automation is there to install it."""
|
||||
still waiting and the signed-in user may install it."""
|
||||
async with app.db_sessionmaker() as session:
|
||||
connection = (await session.execute(select(HomeAssistantConnection))).scalars().first()
|
||||
if connection is None:
|
||||
raise CannotStart("Home Assistant isn't connected")
|
||||
if connection.refused:
|
||||
raise CannotStart("Home Assistant refused Steward's token")
|
||||
raise CannotStart("Home Assistant refused Steward's sign-in")
|
||||
if await under_way(session) is not None:
|
||||
raise CannotStart("another update is under way")
|
||||
if connection.automation != "on":
|
||||
raise CannotStart("the Steward maintenance automation isn't on in Home Assistant")
|
||||
if not connection.can_install:
|
||||
raise CannotStart("the Home Assistant user who signed in isn't an administrator")
|
||||
url, verify = connection.url, connection.verify_tls
|
||||
token, webhook_id = _secrets(connection)
|
||||
known = next((u for u in connection.updates or [] if u["entity_id"] == entity_id), None)
|
||||
if known is None or not known["kind"]:
|
||||
raise CannotStart("that isn't a Core, OS or add-on update")
|
||||
try:
|
||||
token = await client.access_token(connection, transport)
|
||||
current = await client.read_state(url, entity_id, token, verify, transport)
|
||||
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
|
||||
event = await client.read_state(url, BACKUP_EVENT, token, verify, transport)
|
||||
automation = await client.read_state(url, automation_entity_id(connection), token, verify, transport)
|
||||
except client.Refused as exc:
|
||||
await _mark_refused(app, connection.id, str(exc))
|
||||
raise CannotStart(str(exc).rstrip(".")) from None
|
||||
@@ -87,8 +77,7 @@ async def start_update(app, entity_id: str, username: str, transport=None) -> Ho
|
||||
entity_id=entity_id, title=known["title"], kind=known["kind"],
|
||||
from_version=attrs.get("installed_version"), to_version=attrs.get("latest_version"),
|
||||
started_by=username, backup_before=backup.get("state"),
|
||||
backup_event_before=(event or {}).get("state"),
|
||||
triggered_before=((automation or {}).get("attributes") or {}).get("last_triggered"))
|
||||
backup_event_before=(event or {}).get("state"))
|
||||
async with app.db_sessionmaker() as session:
|
||||
try:
|
||||
async with session.begin():
|
||||
@@ -98,8 +87,12 @@ async def start_update(app, entity_id: str, username: str, transport=None) -> Ho
|
||||
except IntegrityError:
|
||||
raise CannotStart("another update is under way") from None
|
||||
try:
|
||||
await client.send_webhook(url, webhook_id, {"action": "backup"}, verify, transport)
|
||||
except client.Unreachable as exc:
|
||||
await client.call_action(url, "backup", "create_automatic", {}, token, verify, transport)
|
||||
except client.Refused as exc:
|
||||
await _mark_refused(app, connection.id, str(exc))
|
||||
await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.")
|
||||
raise CannotStart(str(exc).rstrip(".")) from None
|
||||
except (client.Unreachable, client.Rejected) as exc:
|
||||
await _finish(app, run_id, "failed", f"The backup couldn't be started: {exc} Nothing was installed.")
|
||||
raise CannotStart(str(exc).rstrip(".")) from None
|
||||
async with app.db_sessionmaker() as session:
|
||||
@@ -140,6 +133,8 @@ async def advance(app, transport=None) -> None:
|
||||
except client.Refused as exc:
|
||||
await _mark_refused(app, connection.id, str(exc))
|
||||
await _finish(app, run.id, "failed", str(exc))
|
||||
except client.Rejected as exc:
|
||||
await _finish(app, run.id, "failed", f"Home Assistant wouldn't install it: {exc}")
|
||||
except client.Unreachable as exc:
|
||||
# Expected while Home Assistant restarts: the next tick asks again.
|
||||
logger.info("Home Assistant update %s: %s", run.id, exc)
|
||||
@@ -165,7 +160,7 @@ async def _past_deadline(app, run) -> bool:
|
||||
|
||||
|
||||
async def _advance_backup(app, run, connection, transport) -> None:
|
||||
token, _webhook = _secrets(connection)
|
||||
token = await client.access_token(connection, transport)
|
||||
url, verify = connection.url, connection.verify_tls
|
||||
backup = await client.read_state(url, LAST_BACKUP, token, verify, transport)
|
||||
value = (backup or {}).get("state")
|
||||
@@ -182,27 +177,28 @@ async def _advance_backup(app, run, connection, transport) -> None:
|
||||
await _finish(app, run.id, "failed",
|
||||
f"The backup failed{f' ({reason})' if reason else ''}. Nothing was installed.")
|
||||
return
|
||||
if _elapsed(run.started_at) > TRIGGER_GRACE_S:
|
||||
automation = await client.read_state(url, automation_entity_id(connection), token, verify, transport)
|
||||
triggered = ((automation or {}).get("attributes") or {}).get("last_triggered")
|
||||
if not is_newer(triggered, run.triggered_before):
|
||||
await _finish(app, run.id, "failed",
|
||||
"Home Assistant didn't run the maintenance automation, so its webhook id may not "
|
||||
"match Steward's, or Steward isn't on its local network. Nothing was installed.")
|
||||
return
|
||||
await _past_deadline(app, run)
|
||||
|
||||
|
||||
async def _mark_sent(app, run_id: str, at: datetime | None) -> None:
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
row = await session.get(HomeAssistantUpdate, run_id)
|
||||
row.install_sent_at = at
|
||||
|
||||
|
||||
async def _advance_install(app, run, connection, transport) -> None:
|
||||
token, webhook_id = _secrets(connection)
|
||||
token = await client.access_token(connection, transport)
|
||||
url, verify = connection.url, connection.verify_tls
|
||||
if run.install_sent_at is None:
|
||||
await client.send_webhook(url, webhook_id, {"action": "install", "entity_id": run.entity_id},
|
||||
verify, transport)
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
row = await session.get(HomeAssistantUpdate, run.id)
|
||||
row.install_sent_at = datetime.now(timezone.utc)
|
||||
# Marked first: Steward stopping here leaves an install that runs out its
|
||||
# deadline, where asking twice would fail one that is going fine.
|
||||
await _mark_sent(app, run.id, datetime.now(timezone.utc))
|
||||
try:
|
||||
await client.call_action(url, "update", "install", {"entity_id": run.entity_id}, token, verify, transport)
|
||||
except client.Unreachable:
|
||||
await _mark_sent(app, run.id, None)
|
||||
raise
|
||||
return
|
||||
current = await client.read_state(url, run.entity_id, token, verify, transport)
|
||||
attrs = (current or {}).get("attributes") or {}
|
||||
|
||||
Reference in New Issue
Block a user