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:
parent
6b5e82ed40
commit
a511e7d6bc
9 changed files with 1170 additions and 86 deletions
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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]))
|
||||||
|
|
121
homeassistant/components/hassio/repairs.py
Normal file
121
homeassistant/components/hassio/repairs.py
Normal 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)
|
|
@ -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."
|
||||||
|
|
|
@ -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"})
|
||||||
|
|
|
@ -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()
|
||||||
|
|
402
tests/components/hassio/test_repairs.py
Normal file
402
tests/components/hassio/test_repairs.py
Normal 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"
|
||||||
|
)
|
Loading…
Add table
Add a link
Reference in a new issue