core/homeassistant/components/hassio/issues.py

359 lines
11 KiB
Python

"""Supervisor events monitor."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
import logging
from typing import Any, TypedDict
from typing_extensions import NotRequired
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .const import (
ATTR_DATA,
ATTR_HEALTHY,
ATTR_ISSUES,
ATTR_SUGGESTIONS,
ATTR_SUPPORTED,
ATTR_UNHEALTHY,
ATTR_UNHEALTHY_REASONS,
ATTR_UNSUPPORTED,
ATTR_UNSUPPORTED_REASONS,
ATTR_UPDATE_KEY,
ATTR_WS_EVENT,
DOMAIN,
EVENT_HEALTH_CHANGED,
EVENT_ISSUE_CHANGED,
EVENT_ISSUE_REMOVED,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_REFERENCE,
UPDATE_KEY_SUPERVISOR,
SupervisorIssueContext,
)
from .handler import HassIO, HassioAPIError
ISSUE_KEY_UNHEALTHY = "unhealthy"
ISSUE_KEY_UNSUPPORTED = "unsupported"
ISSUE_ID_UNHEALTHY = "unhealthy_system"
ISSUE_ID_UNSUPPORTED = "unsupported_system"
INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy"
INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
PLACEHOLDER_KEY_REASON = "reason"
UNSUPPORTED_REASONS = {
"apparmor",
"connectivity_check",
"content_trust",
"dbus",
"dns_server",
"docker_configuration",
"docker_version",
"cgroup_version",
"job_conditions",
"lxc",
"network_manager",
"os",
"os_agent",
"restart_policy",
"software",
"source_mods",
"supervisor_version",
"systemd",
"systemd_journal",
"systemd_resolved",
}
# Some unsupported reasons also mark the system as unhealthy. If the unsupported reason
# provides no additional information beyond the unhealthy one then skip that repair.
UNSUPPORTED_SKIP_REPAIR = {"privileged"}
UNHEALTHY_REASONS = {
"docker",
"supervisor",
"setup",
"privileged",
"untrusted",
}
# Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = {
"issue_mount_mount_failed",
"issue_system_multiple_data_disks",
"issue_system_reboot_required",
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
}
_LOGGER = logging.getLogger(__name__)
class SuggestionDataType(TypedDict):
"""Suggestion dictionary as received from supervisor."""
uuid: str
type: str
context: str
reference: str | None
@dataclass(slots=True, frozen=True)
class Suggestion:
"""Suggestion from Supervisor which resolves an issue."""
uuid: str
type: str
context: SupervisorIssueContext
reference: str | None = None
@property
def key(self) -> str:
"""Get key for suggestion (combination of context and type)."""
return f"{self.context}_{self.type}"
@classmethod
def from_dict(cls, data: SuggestionDataType) -> Suggestion:
"""Convert from dictionary representation."""
return cls(
uuid=data["uuid"],
type=data["type"],
context=SupervisorIssueContext(data["context"]),
reference=data["reference"],
)
class IssueDataType(TypedDict):
"""Issue dictionary as received from supervisor."""
uuid: str
type: str
context: str
reference: str | None
suggestions: NotRequired[list[SuggestionDataType]]
@dataclass(slots=True, frozen=True)
class Issue:
"""Issue from Supervisor."""
uuid: str
type: str
context: SupervisorIssueContext
reference: str | None = None
suggestions: list[Suggestion] = field(default_factory=list, compare=False)
@property
def key(self) -> str:
"""Get key for issue (combination of context and type)."""
return f"issue_{self.context}_{self.type}"
@classmethod
def from_dict(cls, data: IssueDataType) -> Issue:
"""Convert from dictionary representation."""
suggestions: list[SuggestionDataType] = data.get("suggestions", [])
return cls(
uuid=data["uuid"],
type=data["type"],
context=SupervisorIssueContext(data["context"]),
reference=data["reference"],
suggestions=[
Suggestion.from_dict(suggestion) for suggestion in suggestions
],
)
class SupervisorIssues:
"""Create issues from supervisor events."""
def __init__(self, hass: HomeAssistant, client: HassIO) -> None:
"""Initialize supervisor issues."""
self._hass = hass
self._client = client
self._unsupported_reasons: set[str] = set()
self._unhealthy_reasons: set[str] = set()
self._issues: dict[str, Issue] = {}
@property
def unhealthy_reasons(self) -> set[str]:
"""Get unhealthy reasons. Returns empty set if system is healthy."""
return self._unhealthy_reasons
@unhealthy_reasons.setter
def unhealthy_reasons(self, reasons: set[str]) -> None:
"""Set unhealthy reasons. Create or delete repairs as necessary."""
for unhealthy in reasons - self.unhealthy_reasons:
if unhealthy in UNHEALTHY_REASONS:
translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
translation_placeholders = None
else:
translation_key = ISSUE_KEY_UNHEALTHY
translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy}
async_create_issue(
self._hass,
DOMAIN,
f"{ISSUE_ID_UNHEALTHY}_{unhealthy}",
is_fixable=False,
learn_more_url=f"{INFO_URL_UNHEALTHY}/{unhealthy}",
severity=IssueSeverity.CRITICAL,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
for fixed in self.unhealthy_reasons - reasons:
async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNHEALTHY}_{fixed}")
self._unhealthy_reasons = reasons
@property
def unsupported_reasons(self) -> set[str]:
"""Get unsupported reasons. Returns empty set if system is supported."""
return self._unsupported_reasons
@unsupported_reasons.setter
def unsupported_reasons(self, reasons: set[str]) -> None:
"""Set unsupported reasons. Create or delete repairs as necessary."""
for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons:
if unsupported in UNSUPPORTED_REASONS:
translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
translation_placeholders = None
else:
translation_key = ISSUE_KEY_UNSUPPORTED
translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported}
async_create_issue(
self._hass,
DOMAIN,
f"{ISSUE_ID_UNSUPPORTED}_{unsupported}",
is_fixable=False,
learn_more_url=f"{INFO_URL_UNSUPPORTED}/{unsupported}",
severity=IssueSeverity.WARNING,
translation_key=translation_key,
translation_placeholders=translation_placeholders,
)
for fixed in self.unsupported_reasons - (reasons - UNSUPPORTED_SKIP_REPAIR):
async_delete_issue(self._hass, DOMAIN, f"{ISSUE_ID_UNSUPPORTED}_{fixed}")
self._unsupported_reasons = reasons
@property
def issues(self) -> set[Issue]:
"""Get issues."""
return set(self._issues.values())
def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
placeholders: dict[str, str] | None = None
if issue.reference:
placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference}
async_create_issue(
self._hass,
DOMAIN,
issue.uuid,
is_fixable=bool(issue.suggestions),
severity=IssueSeverity.WARNING,
translation_key=issue.key,
translation_placeholders=placeholders,
)
self._issues[issue.uuid] = issue
async def add_issue_from_data(self, data: IssueDataType) -> None:
"""Add issue from data to list after getting latest suggestions."""
try:
data["suggestions"] = (
await self._client.get_suggestions_for_issue(data["uuid"])
)[ATTR_SUGGESTIONS]
except HassioAPIError:
_LOGGER.error(
"Could not get suggestions for supervisor issue %s, skipping it",
data["uuid"],
)
return
self.add_issue(Issue.from_dict(data))
def remove_issue(self, issue: Issue) -> None:
"""Remove an issue from the list. Delete a repair if necessary."""
if issue.uuid not in self._issues:
return
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
async_delete_issue(self._hass, DOMAIN, issue.uuid)
del self._issues[issue.uuid]
def get_issue(self, issue_id: str) -> Issue | None:
"""Get issue from key."""
return self._issues.get(issue_id)
async def setup(self) -> None:
"""Create supervisor events listener."""
await self.update()
async_dispatcher_connect(
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_issues
)
async def update(self) -> None:
"""Update issues from Supervisor resolution center."""
try:
data = await self._client.get_resolution_info()
except HassioAPIError as err:
_LOGGER.error("Failed to update supervisor issues: %r", err)
return
self.unhealthy_reasons = set(data[ATTR_UNHEALTHY])
self.unsupported_reasons = set(data[ATTR_UNSUPPORTED])
# Remove any cached issues that weren't returned
for issue_id in set(self._issues.keys()) - {
issue["uuid"] for issue in data[ATTR_ISSUES]
}:
self.remove_issue(self._issues[issue_id])
# Add/update any issues that came back
await asyncio.gather(
*[self.add_issue_from_data(issue) for issue in data[ATTR_ISSUES]]
)
@callback
def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None:
"""Create issues from supervisor events."""
if ATTR_WS_EVENT not in event:
return
if (
event[ATTR_WS_EVENT] == EVENT_SUPERVISOR_UPDATE
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
):
self._hass.async_create_task(self.update())
elif event[ATTR_WS_EVENT] == EVENT_HEALTH_CHANGED:
self.unhealthy_reasons = (
set()
if event[ATTR_DATA][ATTR_HEALTHY]
else set(event[ATTR_DATA][ATTR_UNHEALTHY_REASONS])
)
elif event[ATTR_WS_EVENT] == EVENT_SUPPORTED_CHANGED:
self.unsupported_reasons = (
set()
if event[ATTR_DATA][ATTR_SUPPORTED]
else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS])
)
elif event[ATTR_WS_EVENT] == EVENT_ISSUE_CHANGED:
self.add_issue(Issue.from_dict(event[ATTR_DATA]))
elif event[ATTR_WS_EVENT] == EVENT_ISSUE_REMOVED:
self.remove_issue(Issue.from_dict(event[ATTR_DATA]))