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
This commit is contained in:
Mike Degatano 2023-04-19 02:07:38 -04:00 committed by GitHub
parent 6b5e82ed40
commit a511e7d6bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 1170 additions and 86 deletions

View file

@ -72,6 +72,7 @@ from .const import (
DATA_KEY_HOST, DATA_KEY_HOST,
DATA_KEY_OS, DATA_KEY_OS,
DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR,
DATA_KEY_SUPERVISOR_ISSUES,
DOMAIN, DOMAIN,
SupervisorEntityModel, SupervisorEntityModel,
) )
@ -126,7 +127,6 @@ DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs" DATA_ADDONS_CHANGELOGS = "hassio_addons_changelogs"
DATA_ADDONS_INFO = "hassio_addons_info" DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats" DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_SUPERVISOR_ISSUES = "supervisor_issues"
HASSIO_UPDATE_INTERVAL = timedelta(minutes=5) HASSIO_UPDATE_INTERVAL = timedelta(minutes=5)
ADDONS_COORDINATOR = "hassio_addons_coordinator" 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 # 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() await issues.setup()
return True return True

View file

@ -16,10 +16,12 @@ ATTR_FOLDERS = "folders"
ATTR_HEALTHY = "healthy" ATTR_HEALTHY = "healthy"
ATTR_HOMEASSISTANT = "homeassistant" ATTR_HOMEASSISTANT = "homeassistant"
ATTR_INPUT = "input" ATTR_INPUT = "input"
ATTR_ISSUES = "issues"
ATTR_METHOD = "method" ATTR_METHOD = "method"
ATTR_PANELS = "panels" ATTR_PANELS = "panels"
ATTR_PASSWORD = "password" ATTR_PASSWORD = "password"
ATTR_RESULT = "result" ATTR_RESULT = "result"
ATTR_SUGGESTIONS = "suggestions"
ATTR_SUPPORTED = "supported" ATTR_SUPPORTED = "supported"
ATTR_TIMEOUT = "timeout" ATTR_TIMEOUT = "timeout"
ATTR_TITLE = "title" ATTR_TITLE = "title"
@ -49,6 +51,8 @@ EVENT_SUPERVISOR_EVENT = "supervisor_event"
EVENT_SUPERVISOR_UPDATE = "supervisor_update" EVENT_SUPERVISOR_UPDATE = "supervisor_update"
EVENT_HEALTH_CHANGED = "health_changed" EVENT_HEALTH_CHANGED = "health_changed"
EVENT_SUPPORTED_CHANGED = "supported_changed" EVENT_SUPPORTED_CHANGED = "supported_changed"
EVENT_ISSUE_CHANGED = "issue_changed"
EVENT_ISSUE_REMOVED = "issue_removed"
UPDATE_KEY_SUPERVISOR = "supervisor" UPDATE_KEY_SUPERVISOR = "supervisor"
@ -69,6 +73,9 @@ DATA_KEY_OS = "os"
DATA_KEY_SUPERVISOR = "supervisor" DATA_KEY_SUPERVISOR = "supervisor"
DATA_KEY_CORE = "core" DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host" DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
PLACEHOLDER_KEY_REFERENCE = "reference"
class SupervisorEntityModel(str, Enum): class SupervisorEntityModel(str, Enum):

View file

@ -5,6 +5,7 @@ import asyncio
from http import HTTPStatus from http import HTTPStatus
import logging import logging
import os import os
from typing import Any
import aiohttp 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: class HassIO:
"""Small API wrapper for Hass.io.""" """Small API wrapper for Hass.io."""
@ -416,6 +429,16 @@ class HassIO:
""" """
return self.send_command("/resolution/info", method="get") 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 @_api_bool
async def update_hass_api(self, http_config, refresh_token): async def update_hass_api(self, http_config, refresh_token):
"""Update Home Assistant API data on Hass.io.""" """Update Home Assistant API data on Hass.io."""
@ -454,6 +477,14 @@ class HassIO:
"/supervisor/options", payload={"diagnostics": diagnostics} "/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( async def send_command(
self, self,
command, command,

View file

@ -1,7 +1,12 @@
"""Supervisor events monitor.""" """Supervisor events monitor."""
from __future__ import annotations 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.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
@ -14,6 +19,8 @@ from homeassistant.helpers.issue_registry import (
from .const import ( from .const import (
ATTR_DATA, ATTR_DATA,
ATTR_HEALTHY, ATTR_HEALTHY,
ATTR_ISSUES,
ATTR_SUGGESTIONS,
ATTR_SUPPORTED, ATTR_SUPPORTED,
ATTR_UNHEALTHY, ATTR_UNHEALTHY,
ATTR_UNHEALTHY_REASONS, ATTR_UNHEALTHY_REASONS,
@ -23,19 +30,26 @@ from .const import (
ATTR_WS_EVENT, ATTR_WS_EVENT,
DOMAIN, DOMAIN,
EVENT_HEALTH_CHANGED, EVENT_HEALTH_CHANGED,
EVENT_ISSUE_CHANGED,
EVENT_ISSUE_REMOVED,
EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE, EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED, EVENT_SUPPORTED_CHANGED,
PLACEHOLDER_KEY_REFERENCE,
UPDATE_KEY_SUPERVISOR, 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_UNHEALTHY = "unhealthy_system"
ISSUE_ID_UNSUPPORTED = "unsupported_system" ISSUE_ID_UNSUPPORTED = "unsupported_system"
INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy" INFO_URL_UNHEALTHY = "https://www.home-assistant.io/more-info/unhealthy"
INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported" INFO_URL_UNSUPPORTED = "https://www.home-assistant.io/more-info/unsupported"
PLACEHOLDER_KEY_REASON = "reason"
UNSUPPORTED_REASONS = { UNSUPPORTED_REASONS = {
"apparmor", "apparmor",
"connectivity_check", "connectivity_check",
@ -69,6 +83,88 @@ UNHEALTHY_REASONS = {
"untrusted", "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: class SupervisorIssues:
"""Create issues from supervisor events.""" """Create issues from supervisor events."""
@ -79,6 +175,7 @@ class SupervisorIssues:
self._client = client self._client = client
self._unsupported_reasons: set[str] = set() self._unsupported_reasons: set[str] = set()
self._unhealthy_reasons: set[str] = set() self._unhealthy_reasons: set[str] = set()
self._issues: dict[str, Issue] = {}
@property @property
def unhealthy_reasons(self) -> set[str]: def unhealthy_reasons(self) -> set[str]:
@ -87,14 +184,14 @@ class SupervisorIssues:
@unhealthy_reasons.setter @unhealthy_reasons.setter
def unhealthy_reasons(self, reasons: set[str]) -> None: 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: for unhealthy in reasons - self.unhealthy_reasons:
if unhealthy in UNHEALTHY_REASONS: if unhealthy in UNHEALTHY_REASONS:
translation_key = f"unhealthy_{unhealthy}" translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}"
translation_placeholders = None translation_placeholders = None
else: else:
translation_key = "unhealthy" translation_key = ISSUE_KEY_UNHEALTHY
translation_placeholders = {"reason": unhealthy} translation_placeholders = {PLACEHOLDER_KEY_REASON: unhealthy}
async_create_issue( async_create_issue(
self._hass, self._hass,
@ -119,14 +216,14 @@ class SupervisorIssues:
@unsupported_reasons.setter @unsupported_reasons.setter
def unsupported_reasons(self, reasons: set[str]) -> None: 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: for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons:
if unsupported in UNSUPPORTED_REASONS: if unsupported in UNSUPPORTED_REASONS:
translation_key = f"unsupported_{unsupported}" translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}"
translation_placeholders = None translation_placeholders = None
else: else:
translation_key = "unsupported" translation_key = ISSUE_KEY_UNSUPPORTED
translation_placeholders = {"reason": unsupported} translation_placeholders = {PLACEHOLDER_KEY_REASON: unsupported}
async_create_issue( async_create_issue(
self._hass, self._hass,
@ -144,6 +241,60 @@ class SupervisorIssues:
self._unsupported_reasons = reasons 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: async def setup(self) -> None:
"""Create supervisor events listener.""" """Create supervisor events listener."""
await self.update() await self.update()
@ -153,11 +304,22 @@ class SupervisorIssues:
) )
async def update(self) -> None: async def update(self) -> None:
"""Update issuess from Supervisor resolution center.""" """Update issues from Supervisor resolution center."""
data = await self._client.get_resolution_info() data = await self._client.get_resolution_info()
self.unhealthy_reasons = set(data[ATTR_UNHEALTHY]) self.unhealthy_reasons = set(data[ATTR_UNHEALTHY])
self.unsupported_reasons = set(data[ATTR_UNSUPPORTED]) 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 @callback
def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None: def _supervisor_events_to_issues(self, event: dict[str, Any]) -> None:
"""Create issues from supervisor events.""" """Create issues from supervisor events."""
@ -183,3 +345,9 @@ class SupervisorIssues:
if event[ATTR_DATA][ATTR_SUPPORTED] if event[ATTR_DATA][ATTR_SUPPORTED]
else set(event[ATTR_DATA][ATTR_UNSUPPORTED_REASONS]) 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]))

View file

@ -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)

View file

@ -17,6 +17,32 @@
} }
}, },
"issues": { "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": { "unhealthy": {
"title": "Unhealthy system - {reason}", "title": "Unhealthy system - {reason}",
"description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this." "description": "System is currently unhealthy due to {reason}. Use the link to learn more and how to fix this."

View file

@ -12,6 +12,8 @@ from homeassistant.setup import async_setup_component
from . import SUPERVISOR_TOKEN from . import SUPERVISOR_TOKEN
from tests.test_util.aiohttp import AiohttpClientMocker
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def disable_security_filter(): def disable_security_filter():
@ -89,3 +91,77 @@ async def hassio_handler(hass, aioclient_mock):
"""Create mock hassio handler.""" """Create mock hassio handler."""
with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}):
yield HassIO(hass.loop, async_get_clientsession(hass), "127.0.0.1") 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"})

View file

@ -1,6 +1,7 @@
"""Test issues from supervisor issues.""" """Test issues from supervisor issues."""
from __future__ import annotations from __future__ import annotations
from asyncio import TimeoutError
import os import os
from typing import Any from typing import Any
from unittest.mock import ANY, patch from unittest.mock import ANY, patch
@ -24,75 +25,8 @@ async def setup_repairs(hass):
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_all(aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest): async def mock_all(all_setup_requests):
"""Mock 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) @pytest.fixture(autouse=True)
@ -106,8 +40,9 @@ def mock_resolution_info(
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
unsupported: list[str] | None = None, unsupported: list[str] | None = None,
unhealthy: 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( aioclient_mock.get(
"http://127.0.0.1/resolution/info", "http://127.0.0.1/resolution/info",
json={ json={
@ -116,7 +51,12 @@ def mock_resolution_info(
"unsupported": unsupported or [], "unsupported": unsupported or [],
"unhealthy": unhealthy or [], "unhealthy": unhealthy or [],
"suggestions": [], "suggestions": [],
"issues": [], "issues": [
{k: v for k, v in issue.items() if k != "suggestions"}
for issue in issues
]
if issues
else [],
"checks": [ "checks": [
{"enabled": True, "slug": "supervisor_trust"}, {"enabled": True, "slug": "supervisor_trust"},
{"enabled": True, "slug": "free_space"}, {"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): def assert_repair_in_list(issues: list[dict[str, Any]], unhealthy: bool, reason: str):
"""Assert repair for unhealthy/unsupported in list.""" """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 } 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( async def test_unhealthy_issues(
hass: HomeAssistant, hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
@ -306,8 +286,20 @@ async def test_reset_issues_supervisor_restart(
aioclient_mock: AiohttpClientMocker, aioclient_mock: AiohttpClientMocker,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
) -> None: ) -> None:
"""Unsupported/unhealthy issues reset on supervisor restart.""" """All issues reset on supervisor restart."""
mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) 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", {}) result = await async_setup_component(hass, "hassio", {})
assert result assert result
@ -317,9 +309,17 @@ async def test_reset_issues_supervisor_restart(
await client.send_json({"id": 1, "type": "repairs/list_issues"}) await client.send_json({"id": 1, "type": "repairs/list_issues"})
msg = await client.receive_json() msg = await client.receive_json()
assert msg["success"] 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=True, reason="docker")
assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") 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() aioclient_mock.clear_requests()
mock_resolution_info(aioclient_mock) mock_resolution_info(aioclient_mock)
@ -462,3 +462,256 @@ async def test_new_unsupported_unhealthy_reason(
"translation_key": "unsupported", "translation_key": "unsupported",
"translation_placeholders": {"reason": "fake_unsupported"}, "translation_placeholders": {"reason": "fake_unsupported"},
} in msg["result"]["issues"] } 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()

View file

@ -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"
)