359 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
	
			
		
		
	
	
			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]))
 |