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:
@@ -3,14 +3,12 @@
|
||||
Steward may install, and how its backups stand."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
SOURCE = "homeassistant"
|
||||
RESOURCE = "homeassistant"
|
||||
# Backups run daily, so past a day and a bit one has been missed.
|
||||
STALE_BACKUP_HOURS = 26
|
||||
AUTOMATION_ID = "steward_maintenance"
|
||||
|
||||
CORE = "update.home_assistant_core_update"
|
||||
OS = "update.home_assistant_operating_system_update"
|
||||
@@ -23,54 +21,6 @@ BACKUP_EVENT = "event.backup_automatic_backup"
|
||||
_KIND_WORDS = {"core": "Core", "os": "Operating system", "addon": "Add-on"}
|
||||
_KIND_ORDER = {"core": 0, "os": 1, "addon": 2, None: 3}
|
||||
|
||||
# The automation that gives Steward its only way to act on Home Assistant. It
|
||||
# takes a backup or installs a Core, OS or add-on update, and does nothing else.
|
||||
AUTOMATION_YAML = """\
|
||||
id: steward_maintenance
|
||||
alias: Steward maintenance
|
||||
description: Lets Steward take a backup or install a Core, OS or add-on update. Does nothing else.
|
||||
mode: single
|
||||
triggers:
|
||||
- trigger: webhook
|
||||
webhook_id: {webhook_id}
|
||||
allowed_methods:
|
||||
- POST
|
||||
local_only: true
|
||||
variables:
|
||||
request: "{{{{ trigger.json | default({{}}) }}}}"
|
||||
allowed: >-
|
||||
{{{{ integration_entities('hassio') | select('match', 'update\\\\.')
|
||||
| reject('eq', 'update.home_assistant_supervisor_update') | list }}}}
|
||||
actions:
|
||||
- choose:
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{{{ request.get('action') == 'backup' }}}}"
|
||||
sequence:
|
||||
- action: backup.create_automatic
|
||||
- conditions:
|
||||
- condition: template
|
||||
value_template: "{{{{ request.get('action') == 'install' and request.get('entity_id') in allowed }}}}"
|
||||
sequence:
|
||||
- action: update.install
|
||||
target:
|
||||
entity_id: "{{{{ request.get('entity_id') }}}}"
|
||||
"""
|
||||
|
||||
|
||||
def new_webhook_id() -> str:
|
||||
return "steward_" + secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def automation_yaml(webhook_id: str) -> str:
|
||||
return AUTOMATION_YAML.format(webhook_id=webhook_id)
|
||||
|
||||
|
||||
def no_spaces(typed: str | None) -> str:
|
||||
"""A pasted token or id with a terminal's wrapping taken back out."""
|
||||
return "".join((typed or "").split())
|
||||
|
||||
|
||||
def classify(entity: dict) -> str | None:
|
||||
""""core", "os" or "addon" for an update Steward may install; None for the
|
||||
rest: Supervisor updates itself, and firmware and custom integrations are
|
||||
@@ -126,15 +76,11 @@ def read_snapshot(states: list[dict]) -> dict:
|
||||
updates.sort(key=lambda u: (not u["waiting"], _KIND_ORDER[u["kind"]], u["title"].lower()))
|
||||
event = by_id.get(BACKUP_EVENT) or {}
|
||||
event_attrs = event.get("attributes") or {}
|
||||
automation = next((e for e in states if (e.get("entity_id") or "").startswith("automation.")
|
||||
and (e.get("attributes") or {}).get("id") == AUTOMATION_ID), None)
|
||||
return {
|
||||
"updates": updates,
|
||||
"backup": {"last_ok": _state(by_id.get(LAST_BACKUP)), "next": _state(by_id.get(NEXT_BACKUP)),
|
||||
"manager": _state(by_id.get(BACKUP_MANAGER)), "last_result": event_attrs.get("event_type"),
|
||||
"failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id,
|
||||
"automation_entity": automation.get("entity_id") if automation else None},
|
||||
"automation": automation.get("state") if automation else None,
|
||||
"failed_reason": event_attrs.get("failed_reason"), "known": LAST_BACKUP in by_id},
|
||||
}
|
||||
|
||||
|
||||
@@ -162,8 +108,3 @@ def backup_verdict(backup: dict | None, now: datetime) -> tuple[str, str]:
|
||||
if age > STALE_BACKUP_HOURS:
|
||||
return "warn", f"The last good backup is {age:.0f} hours old, past the {STALE_BACKUP_HOURS} hours expected."
|
||||
return "ok", "Backups are up to date."
|
||||
|
||||
|
||||
def automation_entity_id(connection) -> str:
|
||||
"""The automation's entity, as a poll found it."""
|
||||
return (connection.backup or {}).get("automation_entity") or f"automation.{AUTOMATION_ID}"
|
||||
|
||||
Reference in New Issue
Block a user