From a511e7d6bcc88cebb6abd15083a06574e84c855c Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 19 Apr 2023 02:07:38 -0400 Subject: [PATCH] Make repairs out of select supervisor issues (#90893) * Make repairs out of select supervisor issues * Fix comment formatting * Add a test case for API error * Testing and type fix --- homeassistant/components/hassio/__init__.py | 4 +- homeassistant/components/hassio/const.py | 7 + homeassistant/components/hassio/handler.py | 31 ++ homeassistant/components/hassio/issues.py | 190 ++++++++- homeassistant/components/hassio/repairs.py | 121 ++++++ homeassistant/components/hassio/strings.json | 26 ++ tests/components/hassio/conftest.py | 76 ++++ tests/components/hassio/test_issues.py | 399 ++++++++++++++---- tests/components/hassio/test_repairs.py | 402 +++++++++++++++++++ 9 files changed, 1170 insertions(+), 86 deletions(-) create mode 100644 homeassistant/components/hassio/repairs.py create mode 100644 tests/components/hassio/test_repairs.py diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e6ff9888b15..53693664651 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -72,6 +72,7 @@ from .const import ( DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, SupervisorEntityModel, ) @@ -126,7 +127,6 @@ DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_STATS = "hassio_addons_stats" -DATA_SUPERVISOR_ISSUES = "supervisor_issues" HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) ADDONS_COORDINATOR = "hassio_addons_coordinator" @@ -611,7 +611,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) # Start listening for problems with supervisor and making issues - hass.data[DATA_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) + hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio) await issues.setup() return True diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index cc9c58a3d27..1dfd5ce53cd 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -16,10 +16,12 @@ ATTR_FOLDERS = "folders" ATTR_HEALTHY = "healthy" ATTR_HOMEASSISTANT = "homeassistant" ATTR_INPUT = "input" +ATTR_ISSUES = "issues" ATTR_METHOD = "method" ATTR_PANELS = "panels" ATTR_PASSWORD = "password" ATTR_RESULT = "result" +ATTR_SUGGESTIONS = "suggestions" ATTR_SUPPORTED = "supported" ATTR_TIMEOUT = "timeout" ATTR_TITLE = "title" @@ -49,6 +51,8 @@ EVENT_SUPERVISOR_EVENT = "supervisor_event" EVENT_SUPERVISOR_UPDATE = "supervisor_update" EVENT_HEALTH_CHANGED = "health_changed" EVENT_SUPPORTED_CHANGED = "supported_changed" +EVENT_ISSUE_CHANGED = "issue_changed" +EVENT_ISSUE_REMOVED = "issue_removed" UPDATE_KEY_SUPERVISOR = "supervisor" @@ -69,6 +73,9 @@ DATA_KEY_OS = "os" DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" +DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" + +PLACEHOLDER_KEY_REFERENCE = "reference" class SupervisorEntityModel(str, Enum): diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index d7af26851d0..9c4feb3989f 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -5,6 +5,7 @@ import asyncio from http import HTTPStatus import logging import os +from typing import Any import aiohttp @@ -249,6 +250,18 @@ async def async_update_core( ) +@bind_hass +@_api_bool +async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> bool: + """Apply a suggestion from supervisor's resolution center. + + The caller of the function should handle HassioAPIError. + """ + hassio = hass.data[DOMAIN] + command = f"/resolution/suggestion/{suggestion_uuid}" + return await hassio.send_command(command, timeout=None) + + class HassIO: """Small API wrapper for Hass.io.""" @@ -416,6 +429,16 @@ class HassIO: """ return self.send_command("/resolution/info", method="get") + @api_data + def get_suggestions_for_issue(self, issue_id: str) -> dict[str, Any]: + """Return suggestions for issue from Supervisor resolution center. + + This method returns a coroutine. + """ + return self.send_command( + f"/resolution/issue/{issue_id}/suggestions", method="get" + ) + @_api_bool async def update_hass_api(self, http_config, refresh_token): """Update Home Assistant API data on Hass.io.""" @@ -454,6 +477,14 @@ class HassIO: "/supervisor/options", payload={"diagnostics": diagnostics} ) + @_api_bool + def apply_suggestion(self, suggestion_uuid: str): + """Apply a suggestion from supervisor's resolution center. + + This method returns a coroutine. + """ + return self.send_command(f"/resolution/suggestion/{suggestion_uuid}") + async def send_command( self, command, diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index a0d51c4806d..c460731ad39 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -1,7 +1,12 @@ """Supervisor events monitor.""" from __future__ import annotations -from typing import Any +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 @@ -14,6 +19,8 @@ from homeassistant.helpers.issue_registry import ( from .const import ( ATTR_DATA, ATTR_HEALTHY, + ATTR_ISSUES, + ATTR_SUGGESTIONS, ATTR_SUPPORTED, ATTR_UNHEALTHY, ATTR_UNHEALTHY_REASONS, @@ -23,19 +30,26 @@ from .const import ( ATTR_WS_EVENT, DOMAIN, EVENT_HEALTH_CHANGED, + EVENT_ISSUE_CHANGED, + EVENT_ISSUE_REMOVED, EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + PLACEHOLDER_KEY_REFERENCE, UPDATE_KEY_SUPERVISOR, ) -from .handler import HassIO +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", @@ -69,6 +83,88 @@ UNHEALTHY_REASONS = { "untrusted", } +# Keys (type + context) of issues that when found should be made into a repair +ISSUE_KEYS_FOR_REPAIRS = { + "issue_system_multiple_data_disks", + "issue_system_reboot_required", +} + +_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: str + reference: str | None = None + + @property + def key(self) -> str: + """Get key for suggestion (combination of context and type).""" + return f"{self.context}_{self.type_}" + + @staticmethod + def from_dict(data: SuggestionDataType) -> Suggestion: + """Convert from dictionary representation.""" + return Suggestion( + uuid=data["uuid"], + type_=data["type"], + context=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: str + 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_}" + + @staticmethod + def from_dict(data: IssueDataType) -> Issue: + """Convert from dictionary representation.""" + suggestions: list[SuggestionDataType] = data.get("suggestions", []) + return Issue( + uuid=data["uuid"], + type_=data["type"], + context=data["context"], + reference=data["reference"], + suggestions=[ + Suggestion.from_dict(suggestion) for suggestion in suggestions + ], + ) + class SupervisorIssues: """Create issues from supervisor events.""" @@ -79,6 +175,7 @@ class SupervisorIssues: 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]: @@ -87,14 +184,14 @@ class SupervisorIssues: @unhealthy_reasons.setter def unhealthy_reasons(self, reasons: set[str]) -> None: - """Set unhealthy reasons. Create or delete issues as necessary.""" + """Set unhealthy reasons. Create or delete repairs as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: if unhealthy in UNHEALTHY_REASONS: - translation_key = f"unhealthy_{unhealthy}" + translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" translation_placeholders = None else: - translation_key = "unhealthy" - translation_placeholders = {"reason": unhealthy} + translation_key = ISSUE_KEY_UNHEALTHY + translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy} async_create_issue( self._hass, @@ -119,14 +216,14 @@ class SupervisorIssues: @unsupported_reasons.setter def unsupported_reasons(self, reasons: set[str]) -> None: - """Set unsupported reasons. Create or delete issues as necessary.""" + """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"unsupported_{unsupported}" + translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" translation_placeholders = None else: - translation_key = "unsupported" - translation_placeholders = {"reason": unsupported} + translation_key = ISSUE_KEY_UNSUPPORTED + translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported} async_create_issue( self._hass, @@ -144,6 +241,60 @@ class SupervisorIssues: self._unsupported_reasons = reasons + 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: + async_create_issue( + self._hass, + DOMAIN, + issue.uuid, + is_fixable=bool(issue.suggestions), + severity=IssueSeverity.WARNING, + translation_key=issue.key, + translation_placeholders={PLACEHOLDER_KEY_REFERENCE: issue.reference} + if issue.reference + else None, + ) + + 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: + suggestions = (await self._client.get_suggestions_for_issue(data["uuid"]))[ + ATTR_SUGGESTIONS + ] + self.add_issue( + Issue( + uuid=data["uuid"], + type_=data["type"], + context=data["context"], + reference=data["reference"], + suggestions=[ + Suggestion.from_dict(suggestion) for suggestion in suggestions + ], + ) + ) + except HassioAPIError: + _LOGGER.error( + "Could not get suggestions for supervisor issue %s, skipping it", + data["uuid"], + ) + + 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() @@ -153,11 +304,22 @@ class SupervisorIssues: ) async def update(self) -> None: - """Update issuess from Supervisor resolution center.""" + """Update issues from Supervisor resolution center.""" data = await self._client.get_resolution_info() 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.""" @@ -183,3 +345,9 @@ class SupervisorIssues: 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])) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py new file mode 100644 index 00000000000..5040243cc8d --- /dev/null +++ b/homeassistant/components/hassio/repairs.py @@ -0,0 +1,121 @@ +"""Repairs implementation for supervisor integration.""" + +from collections.abc import Callable +from types import MethodType +from typing import Any + +import voluptuous as vol + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult + +from .const import DATA_KEY_SUPERVISOR_ISSUES, PLACEHOLDER_KEY_REFERENCE +from .handler import HassioAPIError, async_apply_suggestion +from .issues import Issue, Suggestion, SupervisorIssues + +SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} + + +class SupervisorIssueRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + _data: dict[str, Any] | None = None + _issue: Issue | None = None + + def __init__(self, issue_id: str) -> None: + """Initialize repair flow.""" + self._issue_id = issue_id + super().__init__() + + @property + def issue(self) -> Issue | None: + """Get associated issue.""" + if not self._issue: + supervisor_issues: SupervisorIssues = self.hass.data[ + DATA_KEY_SUPERVISOR_ISSUES + ] + self._issue = supervisor_issues.get_issue(self._issue_id) + + return self._issue + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + return ( + {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} + if self.issue and self.issue.reference + else None + ) + + def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: + """Return form for suggestion.""" + return self.async_show_form( + step_id=suggestion.key, + data_schema=vol.Schema({}), + description_placeholders=self.description_placeholders, + last_step=True, + ) + + async def async_step_init(self, _: None = None) -> FlowResult: + """Handle the first step of a fix flow.""" + # Got out of sync with supervisor, issue is resolved or isn't fixable. Either way, resolve the repair + if not self.issue or not self.issue.suggestions: + return self.async_create_entry(data={}) + + # All suggestions do the same thing: apply them in supervisor, optionally with a confirmation step. + # Generating the required handler for each allows for shared logic but screens can still be translated per step id. + for suggestion in self.issue.suggestions: + setattr( + self, + f"async_step_{suggestion.key}", + MethodType(self._async_step(suggestion), self), + ) + + if len(self.issue.suggestions) > 1: + return self.async_show_menu( + step_id="fix_menu", + menu_options=[suggestion.key for suggestion in self.issue.suggestions], + description_placeholders=self.description_placeholders, + ) + + # Always show a form if there's only one suggestion so we can explain to the user what's happening + return self._async_form_for_suggestion(self.issue.suggestions[0]) + + async def _async_step_apply_suggestion( + self, suggestion: Suggestion, confirmed: bool = False + ) -> FlowResult: + """Handle applying a suggestion as a flow step. Optionally request confirmation.""" + if not confirmed and suggestion.key in SUGGESTION_CONFIRMATION_REQUIRED: + return self._async_form_for_suggestion(suggestion) + + try: + await async_apply_suggestion(self.hass, suggestion.uuid) + except HassioAPIError: + return self.async_abort(reason="apply_suggestion_fail") + + return self.async_create_entry(data={}) + + @staticmethod + def _async_step(suggestion: Suggestion) -> Callable: + """Generate a step handler for a suggestion.""" + + async def _async_step( + self: SupervisorIssueRepairFlow, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle a flow step for a suggestion.""" + # pylint: disable-next=protected-access + return await self._async_step_apply_suggestion( + suggestion, confirmed=user_input is not None + ) + + return _async_step + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 7cda053f43a..5bdfe8d807c 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,32 @@ } }, "issues": { + "issue_system_multiple_data_disks": { + "title": "Multiple data disks detected", + "fix_flow": { + "step": { + "system_rename_data_disk": { + "description": "'{reference}' is a filesystem with the name 'hassos-data' and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." + } + }, + "abort": { + "apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details." + } + } + }, + "issue_system_reboot_required": { + "title": "Reboot required", + "fix_flow": { + "step": { + "system_execute_reboot": { + "description": "Settings were changed which require a system reboot to take effect.\n\nThis fix will initiate a system reboot which will make Home Assistant and all the Add-ons inaccessible for a brief period." + } + }, + "abort": { + "apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details." + } + } + }, "unhealthy": { "title": "Unhealthy system - {reason}", "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index afe641405e3..678ba641e80 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -12,6 +12,8 @@ from homeassistant.setup import async_setup_component from . import SUPERVISOR_TOKEN +from tests.test_util.aiohttp import AiohttpClientMocker + @pytest.fixture(autouse=True) def disable_security_filter(): @@ -89,3 +91,77 @@ async def hassio_handler(hass, aioclient_mock): """Create mock hassio handler.""" with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): yield HassIO(hass.loop, async_get_clientsession(hass), "127.0.0.1") + + +@pytest.fixture +def all_setup_requests( + aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest +): + """Mock all setup requests.""" + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) + aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) + aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) + aioclient_mock.get( + "http://127.0.0.1/info", + json={ + "result": "ok", + "data": { + "supervisor": "222", + "homeassistant": "0.110.0", + "hassos": "1.2.3", + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/store", + json={ + "result": "ok", + "data": {"addons": [], "repositories": []}, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/host/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "data": { + "chassis": "vm", + "operating_system": "Debian GNU/Linux 10 (buster)", + "kernel": "4.19.0-6-amd64", + }, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/info", + json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, + ) + aioclient_mock.get( + "http://127.0.0.1/os/info", + json={ + "result": "ok", + "data": { + "version_latest": "1.0.0", + "version": "1.0.0", + "update_available": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/info", + json={ + "result": "ok", + "data": { + "result": "ok", + "version": "1.0.0", + "version_latest": "1.0.0", + "auto_update": True, + "addons": [], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} + ) + aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 5b280d0c827..c8ce5fcb490 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -1,6 +1,7 @@ """Test issues from supervisor issues.""" from __future__ import annotations +from asyncio import TimeoutError import os from typing import Any from unittest.mock import ANY, patch @@ -24,75 +25,8 @@ async def setup_repairs(hass): @pytest.fixture(autouse=True) -def mock_all(aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest): +async def mock_all(all_setup_requests): """Mock all setup requests.""" - aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) - aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) - aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) - aioclient_mock.get( - "http://127.0.0.1/info", - json={ - "result": "ok", - "data": { - "supervisor": "222", - "homeassistant": "0.110.0", - "hassos": "1.2.3", - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/store", - json={ - "result": "ok", - "data": {"addons": [], "repositories": []}, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/host/info", - json={ - "result": "ok", - "data": { - "result": "ok", - "data": { - "chassis": "vm", - "operating_system": "Debian GNU/Linux 10 (buster)", - "kernel": "4.19.0-6-amd64", - }, - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/core/info", - json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}}, - ) - aioclient_mock.get( - "http://127.0.0.1/os/info", - json={ - "result": "ok", - "data": { - "version_latest": "1.0.0", - "version": "1.0.0", - "update_available": False, - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/supervisor/info", - json={ - "result": "ok", - "data": { - "result": "ok", - "version": "1.0.0", - "version_latest": "1.0.0", - "auto_update": True, - "addons": [], - }, - }, - ) - aioclient_mock.get( - "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} - ) - aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) @pytest.fixture(autouse=True) @@ -106,8 +40,9 @@ def mock_resolution_info( aioclient_mock: AiohttpClientMocker, unsupported: list[str] | None = None, unhealthy: list[str] | None = None, + issues: list[dict[str, str]] | None = None, ): - """Mock resolution/info endpoint with unsupported/unhealthy reasons.""" + """Mock resolution/info endpoint with unsupported/unhealthy reasons and/or issues.""" aioclient_mock.get( "http://127.0.0.1/resolution/info", json={ @@ -116,7 +51,12 @@ def mock_resolution_info( "unsupported": unsupported or [], "unhealthy": unhealthy or [], "suggestions": [], - "issues": [], + "issues": [ + {k: v for k, v in issue.items() if k != "suggestions"} + for issue in issues + ] + if issues + else [], "checks": [ {"enabled": True, "slug": "supervisor_trust"}, {"enabled": True, "slug": "free_space"}, @@ -125,6 +65,21 @@ def mock_resolution_info( }, ) + if issues: + suggestions_by_issue = { + issue["uuid"]: issue.get("suggestions", []) for issue in issues + } + for issue_uuid, suggestions in suggestions_by_issue.items(): + aioclient_mock.get( + f"http://127.0.0.1/resolution/issue/{issue_uuid}/suggestions", + json={"result": "ok", "data": {"suggestions": suggestions}}, + ) + for suggestion in suggestions: + aioclient_mock.post( + f"http://127.0.0.1/resolution/suggestion/{suggestion['uuid']}", + json={"result": "ok"}, + ) + def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: str): """Assert repair for unhealthy/unsupported in list.""" @@ -145,6 +100,31 @@ def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: } in issues +def assert_issue_repair_in_list( + issues: list[dict[str, Any]], + uuid: str, + context: str, + type_: str, + fixable: bool, + reference: str | None, +): + """Assert repair for unhealthy/unsupported in list.""" + assert { + "breaks_in_ha_version": None, + "created": ANY, + "dismissed_version": None, + "domain": "hassio", + "ignored": False, + "is_fixable": fixable, + "issue_id": uuid, + "issue_domain": None, + "learn_more_url": None, + "severity": "warning", + "translation_key": f"issue_{context}_{type_}", + "translation_placeholders": {"reference": reference} if reference else None, + } in issues + + async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, @@ -306,8 +286,20 @@ async def test_reset_issues_supervisor_restart( aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, ) -> None: - """Unsupported/unhealthy issues reset on supervisor restart.""" - mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) + """All issues reset on supervisor restart.""" + mock_resolution_info( + aioclient_mock, + unsupported=["os"], + unhealthy=["docker"], + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + } + ], + ) result = await async_setup_component(hass, "hassio", {}) assert result @@ -317,9 +309,17 @@ async def test_reset_issues_supervisor_restart( await client.send_json({"id": 1, "type": "repairs/list_issues"}) msg = await client.receive_json() assert msg["success"] - assert len(msg["result"]["issues"]) == 2 + assert len(msg["result"]["issues"]) == 3 assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="docker") assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=False, + reference=None, + ) aioclient_mock.clear_requests() mock_resolution_info(aioclient_mock) @@ -462,3 +462,256 @@ async def test_new_unsupported_unhealthy_reason( "translation_key": "unsupported", "translation_placeholders": {"reason": "fake_unsupported"}, } in msg["result"]["issues"] + + +async def test_supervisor_issues( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test repairs added for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + { + "uuid": "1235", + "type": "multiple_data_disks", + "context": "system", + "reference": "/dev/sda1", + "suggestions": [ + { + "uuid": "1236", + "type": "rename_data_disk", + "context": "system", + "reference": "/dev/sda1", + } + ], + }, + { + "uuid": "1237", + "type": "should_not_be_repair", + "context": "fake", + "reference": None, + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 2 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=False, + reference=None, + ) + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1235", + context="system", + type_="multiple_data_disks", + fixable=True, + reference="/dev/sda1", + ) + + +async def test_supervisor_issues_add_remove( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test supervisor issues added and removed from dispatches.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=False, + reference=None, + ) + + await client.send_json( + { + "id": 3, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": None, + } + ], + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 4, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="system", + type_="reboot_required", + fixable=True, + reference=None, + ) + + await client.send_json( + { + "id": 5, + "type": "supervisor/event", + "data": { + "event": "issue_removed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 6, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"issues": []} + + +async def test_supervisor_issues_suggestions_fail( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test failing to get suggestions for issue skips it.""" + aioclient_mock.get( + "http://127.0.0.1/resolution/info", + json={ + "result": "ok", + "data": { + "unsupported": [], + "unhealthy": [], + "suggestions": [], + "issues": [ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + } + ], + "checks": [ + {"enabled": True, "slug": "supervisor_trust"}, + {"enabled": True, "slug": "free_space"}, + ], + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/resolution/issue/1234/suggestions", + exc=TimeoutError(), + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 0 + + +async def test_supervisor_remove_missing_issue_without_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test HA skips message to remove issue that it didn't know about (sync issue).""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 5, + "type": "supervisor/event", + "data": { + "event": "issue_removed", + "data": { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py new file mode 100644 index 00000000000..76b6b48b460 --- /dev/null +++ b/tests/components/hassio/test_repairs.py @@ -0,0 +1,402 @@ +"""Test supervisor repairs.""" + +from http import HTTPStatus +import os +from unittest.mock import patch + +import pytest + +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN +from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from .test_init import MOCK_ENVIRON +from .test_issues import mock_resolution_info + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +async def setup_repairs(hass): + """Set up the repairs integration.""" + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + +@pytest.fixture(autouse=True) +async def mock_all(all_setup_requests): + """Mock all setup requests.""" + + +@pytest.fixture(autouse=True) +async def fixture_supervisor_environ(): + """Mock os environ for supervisor.""" + with patch.dict(os.environ, MOCK_ENVIRON): + yield + + +async def test_supervisor_issue_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test fix flow for supervisor issue.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "multiple_data_disks", + "context": "system", + "reference": "/dev/sda1", + "suggestions": [ + { + "uuid": "1235", + "type": "rename_data_disk", + "context": "system", + "reference": "/dev/sda1", + } + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "system_rename_data_disk", + "data_schema": [], + "errors": None, + "description_placeholders": {"reference": "/dev/sda1"}, + "last_step": True, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) + + +async def test_supervisor_issue_repair_flow_with_multiple_suggestions( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test fix flow for supervisor issue with multiple suggestions.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": "test", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": "test", + }, + { + "uuid": "1236", + "type": "test_type", + "context": "system", + "reference": "test", + }, + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["system_execute_reboot", "system_execute_reboot"], + ["system_test_type", "system_test_type"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["system_execute_reboot", "system_test_type"], + "description_placeholders": {"reference": "test"}, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", json={"next_step_id": "system_test_type"} + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1236" + ) + + +async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confirmation( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test fix flow for supervisor issue with multiple suggestions and choice requires confirmation.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": None, + }, + { + "uuid": "1236", + "type": "test_type", + "context": "system", + "reference": None, + }, + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["system_execute_reboot", "system_execute_reboot"], + ["system_test_type", "system_test_type"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["system_execute_reboot", "system_test_type"], + "description_placeholders": None, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "system_execute_reboot"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "system_execute_reboot", + "data_schema": [], + "errors": None, + "description_placeholders": None, + "last_step": True, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) + + +async def test_supervisor_issue_repair_flow_skip_confirmation( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test confirmation skipped for fix flow for supervisor issue with one suggestion.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "reboot_required", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reboot", + "context": "system", + "reference": None, + } + ], + }, + ], + ) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "system_execute_reboot", + "data_schema": [], + "errors": None, + "description_placeholders": None, + "last_step": True, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + )