Add docker config repair for supervisor issue (#93820)

pull/93826/head
Mike Degatano 2023-05-30 16:08:45 -04:00 committed by GitHub
parent 05c3d8bb37
commit c25b26214b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 376 additions and 60 deletions

View File

@ -299,7 +299,7 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None:
@callback
@bind_hass
def get_addons_info(hass):
def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None:
"""Return Addons info.
Async friendly.
@ -367,6 +367,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
return hass.data.get(DATA_CORE_INFO)
@callback
@bind_hass
def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None:
"""Return Supervisor issues info.
Async friendly.
"""
return hass.data.get(DATA_KEY_SUPERVISOR_ISSUES)
@callback
@bind_hass
def is_hassio(hass: HomeAssistant) -> bool:
@ -778,7 +788,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
new_data: dict[str, Any] = {}
supervisor_info = get_supervisor_info(self.hass) or {}
addons_info = get_addons_info(self.hass)
addons_info = get_addons_info(self.hass) or {}
addons_stats = get_addons_stats(self.hass)
addons_changelogs = get_addons_changelogs(self.hass)
store_data = get_store(self.hass) or {}

View File

@ -1,5 +1,5 @@
"""Hass.io const variables."""
from enum import Enum
from homeassistant.backports.enum import StrEnum
DOMAIN = "hassio"
@ -77,9 +77,12 @@ DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
PLACEHOLDER_KEY_REFERENCE = "reference"
PLACEHOLDER_KEY_COMPONENTS = "components"
ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config"
class SupervisorEntityModel(str, Enum):
class SupervisorEntityModel(StrEnum):
"""Supervisor entity model."""
ADDON = "Home Assistant Add-on"
@ -87,3 +90,17 @@ class SupervisorEntityModel(str, Enum):
CORE = "Home Assistant Core"
SUPERVIOSR = "Home Assistant Supervisor"
HOST = "Home Assistant Host"
class SupervisorIssueContext(StrEnum):
"""Context for supervisor issues."""
ADDON = "addon"
CORE = "core"
DNS_SERVER = "dns_server"
MOUNT = "mount"
OS = "os"
PLUGIN = "plugin"
SUPERVISOR = "supervisor"
STORE = "store"
SYSTEM = "system"

View File

@ -35,8 +35,10 @@ from .const import (
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
EVENT_SUPPORTED_CHANGED,
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_REFERENCE,
UPDATE_KEY_SUPERVISOR,
SupervisorIssueContext,
)
from .handler import HassIO, HassioAPIError
@ -88,6 +90,7 @@ ISSUE_KEYS_FOR_REPAIRS = {
"issue_mount_mount_failed",
"issue_system_multiple_data_disks",
"issue_system_reboot_required",
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
}
_LOGGER = logging.getLogger(__name__)
@ -107,22 +110,22 @@ class Suggestion:
"""Suggestion from Supervisor which resolves an issue."""
uuid: str
type_: str
context: str
type: str
context: SupervisorIssueContext
reference: str | None = None
@property
def key(self) -> str:
"""Get key for suggestion (combination of context and type)."""
return f"{self.context}_{self.type_}"
return f"{self.context}_{self.type}"
@classmethod
def from_dict(cls, data: SuggestionDataType) -> Suggestion:
"""Convert from dictionary representation."""
return cls(
uuid=data["uuid"],
type_=data["type"],
context=data["context"],
type=data["type"],
context=SupervisorIssueContext(data["context"]),
reference=data["reference"],
)
@ -142,15 +145,15 @@ class Issue:
"""Issue from Supervisor."""
uuid: str
type_: str
context: str
type: str
context: SupervisorIssueContext
reference: str | None = None
suggestions: list[Suggestion] = field(default_factory=list, compare=False)
@property
def key(self) -> str:
"""Get key for issue (combination of context and type)."""
return f"issue_{self.context}_{self.type_}"
return f"issue_{self.context}_{self.type}"
@classmethod
def from_dict(cls, data: IssueDataType) -> Issue:
@ -158,8 +161,8 @@ class Issue:
suggestions: list[SuggestionDataType] = data.get("suggestions", [])
return cls(
uuid=data["uuid"],
type_=data["type"],
context=data["context"],
type=data["type"],
context=SupervisorIssueContext(data["context"]),
reference=data["reference"],
suggestions=[
Suggestion.from_dict(suggestion) for suggestion in suggestions
@ -242,6 +245,11 @@ class SupervisorIssues:
self._unsupported_reasons = reasons
@property
def issues(self) -> set[Issue]:
"""Get issues."""
return set(self._issues.values())
def add_issue(self, issue: Issue) -> None:
"""Add or update an issue in the list. Create or update a repair if necessary."""
if issue.key in ISSUE_KEYS_FOR_REPAIRS:
@ -263,20 +271,10 @@ class SupervisorIssues:
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
],
)
)
data["suggestions"] = (
await self._client.get_suggestions_for_issue(data["uuid"])
)[ATTR_SUGGESTIONS]
self.add_issue(Issue.from_dict(data))
except HassioAPIError:
_LOGGER.error(
"Could not get suggestions for supervisor issue %s, skipping it",

View File

@ -10,9 +10,15 @@ 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 . import get_addons_info, get_issues_info
from .const import (
ISSUE_KEY_SYSTEM_DOCKER_CONFIG,
PLACEHOLDER_KEY_COMPONENTS,
PLACEHOLDER_KEY_REFERENCE,
SupervisorIssueContext,
)
from .handler import HassioAPIError, async_apply_suggestion
from .issues import Issue, Suggestion, SupervisorIssues
from .issues import Issue, Suggestion
SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"}
@ -37,10 +43,8 @@ class SupervisorIssueRepairFlow(RepairsFlow):
@property
def issue(self) -> Issue | None:
"""Get associated issue."""
if not self._issue:
supervisor_issues: SupervisorIssues = self.hass.data[
DATA_KEY_SUPERVISOR_ISSUES
]
supervisor_issues = get_issues_info(self.hass)
if not self._issue and supervisor_issues:
self._issue = supervisor_issues.get_issue(self._issue_id)
return self._issue
@ -121,10 +125,49 @@ class SupervisorIssueRepairFlow(RepairsFlow):
return _async_step
class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow):
"""Handler for docker config issue fixing flow."""
@property
def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps."""
placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""}
supervisor_issues = get_issues_info(self.hass)
if supervisor_issues and self.issue:
addons = get_addons_info(self.hass) or {}
components: list[str] = []
for issue in supervisor_issues.issues:
if issue.key == self.issue.key or issue.type != self.issue.type:
continue
if issue.context == SupervisorIssueContext.CORE:
components.insert(0, "Home Assistant")
elif issue.context == SupervisorIssueContext.ADDON:
components.append(
next(
(
info["name"]
for slug, info in addons.items()
if slug == issue.reference
),
issue.reference or "",
)
)
placeholders[PLACEHOLDER_KEY_COMPONENTS] = "\n- ".join(components)
return placeholders
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
supervisor_issues = get_issues_info(hass)
issue = supervisor_issues and supervisor_issues.get_issue(issue_id)
if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG:
return DockerConfigIssueRepairFlow(issue_id)
return SupervisorIssueRepairFlow(issue_id)

View File

@ -30,7 +30,20 @@
}
},
"abort": {
"apply_suggestion_fail": "Could not apply the fix. Check the supervisor logs for more details."
"apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details."
}
}
},
"issue_system_docker_config": {
"title": "Restart(s) required",
"fix_flow": {
"step": {
"system_execute_rebuild": {
"description": "The default configuration for add-ons and Home Assistant has changed. To update the configuration with the new defaults, a restart is required for the following:\n\n- {components}"
}
},
"abort": {
"apply_suggestion_fail": "One or more of the restarts failed. Check the Supervisor logs for more details."
}
}
},
@ -43,7 +56,7 @@
}
},
"abort": {
"apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details."
"apply_suggestion_fail": "Could not rename the filesystem. Check the Supervisor logs for more details."
}
}
},
@ -56,7 +69,7 @@
}
},
"abort": {
"apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details."
"apply_suggestion_fail": "Could not reboot the system. Check the Supervisor logs for more details."
}
}
},

View File

@ -98,6 +98,10 @@ def all_setup_requests(
aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest
):
"""Mock all setup requests."""
include_addons = hasattr(request, "param") and request.param.get(
"include_addons", False
)
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"})
@ -157,7 +161,30 @@ def all_setup_requests(
"version": "1.0.0",
"version_latest": "1.0.0",
"auto_update": True,
"addons": [],
"addons": [
{
"name": "test",
"slug": "test",
"update_available": False,
"version": "1.0.0",
"version_latest": "1.0.0",
"repository": "core",
"state": "started",
"icon": False,
},
{
"name": "test2",
"slug": "test2",
"update_available": False,
"version": "1.0.0",
"version_latest": "1.0.0",
"repository": "core",
"state": "started",
"icon": False,
},
]
if include_addons
else [],
},
},
)
@ -165,3 +192,106 @@ def all_setup_requests(
"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"})
aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="")
aioclient_mock.get(
"http://127.0.0.1/addons/test/info",
json={
"result": "ok",
"data": {
"name": "test",
"slug": "test",
"update_available": False,
"version": "1.0.0",
"version_latest": "1.0.0",
"repository": "core",
"state": "started",
"icon": False,
"url": "https://github.com/home-assistant/addons/test",
"auto_update": True,
},
},
)
aioclient_mock.get("http://127.0.0.1/addons/test2/changelog", text="")
aioclient_mock.get(
"http://127.0.0.1/addons/test2/info",
json={
"result": "ok",
"data": {
"name": "test2",
"slug": "test2",
"update_available": False,
"version": "1.0.0",
"version_latest": "1.0.0",
"repository": "core",
"state": "started",
"icon": False,
"url": "https://github.com",
"auto_update": False,
},
},
)
aioclient_mock.get(
"http://127.0.0.1/core/stats",
json={
"result": "ok",
"data": {
"cpu_percent": 0.99,
"memory_usage": 182611968,
"memory_limit": 3977146368,
"memory_percent": 4.59,
"network_rx": 362570232,
"network_tx": 82374138,
"blk_read": 46010945536,
"blk_write": 15051526144,
},
},
)
aioclient_mock.get(
"http://127.0.0.1/supervisor/stats",
json={
"result": "ok",
"data": {
"cpu_percent": 0.99,
"memory_usage": 182611968,
"memory_limit": 3977146368,
"memory_percent": 4.59,
"network_rx": 362570232,
"network_tx": 82374138,
"blk_read": 46010945536,
"blk_write": 15051526144,
},
},
)
aioclient_mock.get(
"http://127.0.0.1/addons/test/stats",
json={
"result": "ok",
"data": {
"cpu_percent": 0.99,
"memory_usage": 182611968,
"memory_limit": 3977146368,
"memory_percent": 4.59,
"network_rx": 362570232,
"network_tx": 82374138,
"blk_read": 46010945536,
"blk_write": 15051526144,
},
},
)
aioclient_mock.get(
"http://127.0.0.1/addons/test2/stats",
json={
"result": "ok",
"data": {
"cpu_percent": 0.8,
"memory_usage": 51941376,
"memory_limit": 3977146368,
"memory_percent": 1.31,
"network_rx": 31338284,
"network_tx": 15692900,
"blk_read": 740077568,
"blk_write": 6004736,
},
},
)

View File

@ -496,7 +496,7 @@ async def test_supervisor_issues(
{
"uuid": "1237",
"type": "should_not_be_repair",
"context": "fake",
"context": "os",
"reference": None,
},
],

View File

@ -19,16 +19,11 @@ from tests.typing import ClientSessionGenerator
@pytest.fixture(autouse=True)
async def setup_repairs(hass):
async def setup_repairs(hass: HomeAssistant):
"""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."""
@ -40,9 +35,10 @@ async def test_supervisor_issue_repair_flow(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
all_setup_requests,
) -> None:
"""Test fix flow for supervisor issue."""
issue_registry: ir.IssueRegistry = ir.async_get(hass)
mock_resolution_info(
aioclient_mock,
issues=[
@ -63,8 +59,7 @@ async def test_supervisor_issue_repair_flow(
],
)
result = await async_setup_component(hass, "hassio", {})
assert result
assert await async_setup_component(hass, "hassio", {})
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert repair_issue
@ -119,9 +114,10 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
all_setup_requests,
) -> None:
"""Test fix flow for supervisor issue with multiple suggestions."""
issue_registry: ir.IssueRegistry = ir.async_get(hass)
mock_resolution_info(
aioclient_mock,
issues=[
@ -148,8 +144,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions(
],
)
result = await async_setup_component(hass, "hassio", {})
assert result
assert await async_setup_component(hass, "hassio", {})
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert repair_issue
@ -214,9 +209,10 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
all_setup_requests,
) -> 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=[
@ -243,8 +239,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir
],
)
result = await async_setup_component(hass, "hassio", {})
assert result
assert await async_setup_component(hass, "hassio", {})
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert repair_issue
@ -327,9 +322,10 @@ async def test_supervisor_issue_repair_flow_skip_confirmation(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
all_setup_requests,
) -> 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=[
@ -350,8 +346,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation(
],
)
result = await async_setup_component(hass, "hassio", {})
assert result
assert await async_setup_component(hass, "hassio", {})
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert repair_issue
@ -406,9 +401,10 @@ async def test_mount_failed_repair_flow(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
all_setup_requests,
) -> None:
"""Test repair flow for mount_failed issue."""
issue_registry: ir.IssueRegistry = ir.async_get(hass)
mock_resolution_info(
aioclient_mock,
issues=[
@ -435,8 +431,7 @@ async def test_mount_failed_repair_flow(
],
)
result = await async_setup_component(hass, "hassio", {})
assert result
assert await async_setup_component(hass, "hassio", {})
repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234")
assert repair_issue
@ -499,3 +494,113 @@ async def test_mount_failed_repair_flow(
str(aioclient_mock.mock_calls[-1][1])
== "http://127.0.0.1/resolution/suggestion/1235"
)
@pytest.mark.parametrize(
"all_setup_requests", [{"include_addons": True}], indirect=True
)
async def test_supervisor_issue_docker_config_repair_flow(
hass: HomeAssistant,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
all_setup_requests,
) -> None:
"""Test fix flow for supervisor issue."""
mock_resolution_info(
aioclient_mock,
issues=[
{
"uuid": "1234",
"type": "docker_config",
"context": "system",
"reference": None,
"suggestions": [
{
"uuid": "1235",
"type": "execute_rebuild",
"context": "system",
"reference": None,
}
],
},
{
"uuid": "1236",
"type": "docker_config",
"context": "core",
"reference": None,
"suggestions": [
{
"uuid": "1237",
"type": "execute_rebuild",
"context": "core",
"reference": None,
}
],
},
{
"uuid": "1238",
"type": "docker_config",
"context": "addon",
"reference": "test",
"suggestions": [
{
"uuid": "1239",
"type": "execute_rebuild",
"context": "addon",
"reference": "test",
}
],
},
],
)
assert await async_setup_component(hass, "hassio", {})
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_rebuild",
"data_schema": [],
"errors": None,
"description_placeholders": {"components": "Home Assistant\n- test"},
"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"
)