From 08a719e09e924c169029998ea5a4b1476567d702 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 24 May 2023 04:33:41 -0400 Subject: [PATCH] Add backup location and mount failed repair (#93126) * Add backup location and mount failed repair * Fix coverage * Change storage_location to backup_location * Use backticks * Update homeassistant/helpers/selector.py --------- Co-authored-by: Erik Montnemery --- homeassistant/components/hassio/__init__.py | 4 + homeassistant/components/hassio/const.py | 1 + homeassistant/components/hassio/issues.py | 1 + homeassistant/components/hassio/repairs.py | 18 +++- homeassistant/components/hassio/services.yaml | 12 +++ homeassistant/components/hassio/strings.json | 17 ++++ homeassistant/helpers/selector.py | 22 +++++ tests/components/hassio/test_init.py | 28 ++++++ tests/components/hassio/test_repairs.py | 99 +++++++++++++++++++ tests/helpers/test_selector.py | 11 +++ 10 files changed, 208 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index c8f4b69d429..4091fa1d984 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -61,6 +61,7 @@ from .const import ( ATTR_FOLDERS, ATTR_HOMEASSISTANT, ATTR_INPUT, + ATTR_LOCATION, ATTR_PASSWORD, ATTR_REPOSITORY, ATTR_SLUG, @@ -161,6 +162,9 @@ SCHEMA_BACKUP_FULL = vol.Schema( vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, + vol.Optional(ATTR_LOCATION): vol.All( + cv.string, lambda v: None if v == "/backup" else v + ), } ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 1dfd5ce53cd..edd7086fd80 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -61,6 +61,7 @@ ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_CPU_PERCENT = "cpu_percent" ATTR_CHANGELOG = "changelog" +ATTR_LOCATION = "location" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" ATTR_STATE = "state" diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index ac6af7f3489..314b01ec388 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -85,6 +85,7 @@ UNHEALTHY_REASONS = { # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { + "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", } diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 50a9b087a7c..5874c99f13d 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -16,6 +16,12 @@ from .issues import Issue, Suggestion, SupervisorIssues SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} +EXTRA_PLACEHOLDERS = { + "issue_mount_mount_failed": { + "storage_url": "/config/storage", + } +} + class SupervisorIssueRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" @@ -42,11 +48,13 @@ class SupervisorIssueRepairFlow(RepairsFlow): @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 - ) + placeholders = {} + if self.issue: + placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {}) + if self.issue.reference: + placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} + + return placeholders or None def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: """Return form for suggestion.""" diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index e526074b1a9..60b54735493 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -88,6 +88,12 @@ backup_full: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: backup_partial: name: Create a partial backup. @@ -128,6 +134,12 @@ backup_partial: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: restore_full: name: Restore from full backup. diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 36cd61a0bb4..5ec63be7032 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,23 @@ } }, "issues": { + "issue_mount_mount_failed": { + "title": "Network storage device failed", + "fix_flow": { + "step": { + "fix_menu": { + "description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).", + "menu_options": { + "mount_execute_reload": "Reload", + "mount_execute_remove": "Remove" + } + } + }, + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the supervisor logs for more details." + } + } + }, "issue_system_multiple_data_disks": { "title": "Multiple data disks detected", "fix_flow": { diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index fec9d25563e..2e7df07cf04 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -340,6 +340,28 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): return attribute +class BackupLocationSelectorConfig(TypedDict, total=False): + """Class to represent a backup location selector config.""" + + +@SELECTORS.register("backup_location") +class BackupLocationSelector(Selector[BackupLocationSelectorConfig]): + """Selector of a backup location.""" + + selector_type = "backup_location" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: BackupLocationSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + name: str = vol.Match(r"^(?:\/backup|\w+)$")(data) + return name + + class BooleanSelectorConfig(TypedDict): """Class to represent a boolean selector config.""" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 1d86699d095..9d83537859a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -571,6 +571,34 @@ async def test_service_calls( "password": "123456", } + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "backup_share", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 17 + assert aioclient_mock.mock_calls[-1][2] == { + "location": "backup_share", + } + + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "/backup", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 18 + assert aioclient_mock.mock_calls[-1][2] == { + "location": None, + } + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 76b6b48b460..acef1cdd715 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -400,3 +400,102 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +async def test_mount_failed_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, +) -> None: + """Test repair flow for mount_failed issue.""" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "mount_failed", + "context": "mount", + "reference": "backup_share", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reload", + "context": "mount", + "reference": "backup_share", + }, + { + "uuid": "1236", + "type": "execute_remove", + "context": "mount", + "reference": "backup_share", + }, + ], + }, + ], + ) + + 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": [ + ["mount_execute_reload", "mount_execute_reload"], + ["mount_execute_remove", "mount_execute_remove"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["mount_execute_reload", "mount_execute_remove"], + "description_placeholders": { + "reference": "backup_share", + "storage_url": "/config/storage", + }, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "mount_execute_reload"}, + ) + + 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" + ) diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 3c04087e48d..f95da5c6e66 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -415,6 +415,17 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("addon", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + (({}, ("abc123", "/backup"), (None, "abc@123", "abc 123", "")),), +) +def test_backup_location_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test backup location selector.""" + _test_selector("backup_location", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), (({}, (1, "one", None), ()),), # Everything can be coerced to bool