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 <erik@montnemery.com>
This commit is contained in:
Mike Degatano 2023-05-24 04:33:41 -04:00 committed by GitHub
parent 9b53484e2e
commit 08a719e09e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 208 additions and 5 deletions

View File

@ -61,6 +61,7 @@ from .const import (
ATTR_FOLDERS, ATTR_FOLDERS,
ATTR_HOMEASSISTANT, ATTR_HOMEASSISTANT,
ATTR_INPUT, ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD, ATTR_PASSWORD,
ATTR_REPOSITORY, ATTR_REPOSITORY,
ATTR_SLUG, ATTR_SLUG,
@ -161,6 +162,9 @@ SCHEMA_BACKUP_FULL = vol.Schema(
vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_NAME): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean, vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
} }
) )

View File

@ -61,6 +61,7 @@ ATTR_VERSION = "version"
ATTR_VERSION_LATEST = "version_latest" ATTR_VERSION_LATEST = "version_latest"
ATTR_CPU_PERCENT = "cpu_percent" ATTR_CPU_PERCENT = "cpu_percent"
ATTR_CHANGELOG = "changelog" ATTR_CHANGELOG = "changelog"
ATTR_LOCATION = "location"
ATTR_MEMORY_PERCENT = "memory_percent" ATTR_MEMORY_PERCENT = "memory_percent"
ATTR_SLUG = "slug" ATTR_SLUG = "slug"
ATTR_STATE = "state" ATTR_STATE = "state"

View File

@ -85,6 +85,7 @@ UNHEALTHY_REASONS = {
# Keys (type + context) of issues that when found should be made into a repair # Keys (type + context) of issues that when found should be made into a repair
ISSUE_KEYS_FOR_REPAIRS = { ISSUE_KEYS_FOR_REPAIRS = {
"issue_mount_mount_failed",
"issue_system_multiple_data_disks", "issue_system_multiple_data_disks",
"issue_system_reboot_required", "issue_system_reboot_required",
} }

View File

@ -16,6 +16,12 @@ from .issues import Issue, Suggestion, SupervisorIssues
SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"}
EXTRA_PLACEHOLDERS = {
"issue_mount_mount_failed": {
"storage_url": "/config/storage",
}
}
class SupervisorIssueRepairFlow(RepairsFlow): class SupervisorIssueRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow.""" """Handler for an issue fixing flow."""
@ -42,11 +48,13 @@ class SupervisorIssueRepairFlow(RepairsFlow):
@property @property
def description_placeholders(self) -> dict[str, str] | None: def description_placeholders(self) -> dict[str, str] | None:
"""Get description placeholders for steps.""" """Get description placeholders for steps."""
return ( placeholders = {}
{PLACEHOLDER_KEY_REFERENCE: self.issue.reference} if self.issue:
if self.issue and self.issue.reference placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {})
else None if self.issue.reference:
) placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference}
return placeholders or None
def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult:
"""Return form for suggestion.""" """Return form for suggestion."""

View File

@ -88,6 +88,12 @@ backup_full:
default: true default: true
selector: selector:
boolean: 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: backup_partial:
name: Create a partial backup. name: Create a partial backup.
@ -128,6 +134,12 @@ backup_partial:
default: true default: true
selector: selector:
boolean: 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: restore_full:
name: Restore from full backup. name: Restore from full backup.

View File

@ -17,6 +17,23 @@
} }
}, },
"issues": { "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": { "issue_system_multiple_data_disks": {
"title": "Multiple data disks detected", "title": "Multiple data disks detected",
"fix_flow": { "fix_flow": {

View File

@ -340,6 +340,28 @@ class AttributeSelector(Selector[AttributeSelectorConfig]):
return attribute 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 BooleanSelectorConfig(TypedDict):
"""Class to represent a boolean selector config.""" """Class to represent a boolean selector config."""

View File

@ -571,6 +571,34 @@ async def test_service_calls(
"password": "123456", "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( async def test_service_calls_core(
hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker

View File

@ -400,3 +400,102 @@ async def test_supervisor_issue_repair_flow_skip_confirmation(
str(aioclient_mock.mock_calls[-1][1]) str(aioclient_mock.mock_calls[-1][1])
== "http://127.0.0.1/resolution/suggestion/1235" == "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"
)

View File

@ -415,6 +415,17 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections) ->
_test_selector("addon", 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( @pytest.mark.parametrize(
("schema", "valid_selections", "invalid_selections"), ("schema", "valid_selections", "invalid_selections"),
(({}, (1, "one", None), ()),), # Everything can be coerced to bool (({}, (1, "one", None), ()),), # Everything can be coerced to bool