mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
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:
parent
9b53484e2e
commit
08a719e09e
@ -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
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -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"
|
||||||
|
@ -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",
|
||||||
}
|
}
|
||||||
|
@ -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."""
|
||||||
|
@ -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.
|
||||||
|
@ -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": {
|
||||||
|
@ -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."""
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user