diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 4091fa1d984..2ae4faa7878 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -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 {} diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index edd7086fd80..2bc314f169a 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -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" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 314b01ec388..a92fc392fa4 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -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", diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 5874c99f13d..d5e26d4670f 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -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) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 5ec63be7032..f9c212f946c 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -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." } } }, diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 678ba641e80..22051808ccc 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -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, + }, + }, + ) diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index c8ce5fcb490..7bd30e452c0 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -496,7 +496,7 @@ async def test_supervisor_issues( { "uuid": "1237", "type": "should_not_be_repair", - "context": "fake", + "context": "os", "reference": None, }, ], diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index acef1cdd715..237c20a5272 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -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" + )