Add move local data option to hassio mount failed repair (#179369)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Stefan Agner
2026-08-20 11:08:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ace0e5a301
commit 840d981676
5 changed files with 180 additions and 9 deletions
+19 -7
View File
@@ -40,7 +40,7 @@ from homeassistant.core import (
callback,
is_callback_check_partial,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -399,16 +399,28 @@ class SupervisorIssuesCoordinator(DataUpdateCoordinator[SupervisorIssuesData]):
current_data: SupervisorIssuesData,
) -> None:
"""Create/delete issue repairs and notify subscribers based on issue deltas."""
issue_registry = ir.async_get(self.hass)
for issue in current_data.issues.values():
previous_issue = previous_data.issues.get(issue.uuid)
if previous_issue is not None and self._issue_equal(previous_issue, issue):
continue
self._create_or_update_issue_repair(issue)
self._process_issue_change(
IssueSubscriptionEvent(event="changed", issue=issue)
changed = previous_issue is None or not self._issue_equal(
previous_issue, issue
)
# Update the repair on changes, and re-create it if the registry
# entry went missing: a finished repair flow deletes the entry
# even when applying the suggestion failed in Supervisor and the
# issue is unchanged.
if changed or (
issue.key in ISSUE_KEYS_FOR_REPAIRS
and not issue_registry.async_get_issue(DOMAIN, issue.uuid.hex)
):
self._create_or_update_issue_repair(issue)
if changed:
self._process_issue_change(
IssueSubscriptionEvent(event="changed", issue=issue)
)
for issue_uuid, issue in previous_data.issues.items():
if issue_uuid not in current_data.issues:
self._delete_issue_repair(issue)
@@ -41,6 +41,7 @@ from .issues import Issue, Suggestion
SUGGESTION_CONFIRMATION_REQUIRED = {
"addon_execute_remove",
"mount_move_local_data",
"system_adopt_data_disk",
"system_execute_reboot",
}
+6 -2
View File
@@ -152,11 +152,15 @@
},
"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}).",
"description": "Could not set up `{reference}`. This can happen when the storage device is unreachable, or when local data is blocking the mount location. Check host logs for errors from the mount service for more details.\n\nUse reload to try again. If you need to update `{reference}`, go to [storage]({storage_url}).",
"menu_options": {
"mount_execute_reload": "[%key:common::action::reload%]",
"mount_execute_remove": "Remove"
"mount_execute_remove": "Remove",
"mount_move_local_data": "Move blocking local data away"
}
},
"mount_move_local_data": {
"description": "Select **Submit** to move the local data blocking `{reference}` to a `{reference}_local_recovery` folder and set up the mount again.\n\nNo files are deleted. For media and share mounts the recovery folder is created next to the mount, where you can review and remove it. For backup mounts it is placed in local backup storage."
}
}
},
+54
View File
@@ -33,6 +33,7 @@ from homeassistant.components.hassio.coordinator import (
)
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
@@ -1436,6 +1437,59 @@ async def test_supervisor_issues_periodic_refresh_backstop(
supervisor_client.resolution.info.assert_called_once()
@pytest.mark.usefixtures("all_setup_requests")
async def test_issue_repair_recreated_when_registry_entry_missing(
hass: HomeAssistant,
supervisor_client: AsyncMock,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test a repair deleted from the registry is re-created for an unchanged issue.
A finished repair flow deletes the issue registry entry even when applying
the suggestion failed in Supervisor. The issue then comes back unchanged on
the next refresh and must be re-created instead of skipped as known.
"""
mock_resolution_info(
supervisor_client,
issues=[
Issue(
type=IssueType.MOUNT_FAILED,
context=ContextType.MOUNT,
reference="m1",
uuid=(issue_uuid := uuid4()),
reference_extra=None,
)
],
suggestions_by_issue={
issue_uuid: [
Suggestion(
SuggestionType.EXECUTE_RELOAD,
context=ContextType.MOUNT,
reference="m1",
uuid=uuid4(),
auto=False,
reference_extra=None,
)
]
},
)
result = await async_setup_component(hass, DOMAIN, {})
assert result
assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_uuid.hex)
# Simulate a finished repair flow whose suggestion failed to apply in
# Supervisor: the registry entry is gone, the supervisor issue unchanged
ir.async_delete_issue(hass, DOMAIN, issue_uuid.hex)
assert not issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_uuid.hex)
async_fire_time_changed(hass, dt_util.utcnow() + HASSIO_ISSUES_UPDATE_INTERVAL)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_uuid.hex)
@pytest.mark.usefixtures("all_setup_requests")
async def test_supervisor_issues_suggestions_change_updates_fixable_state(
hass: HomeAssistant,
+100
View File
@@ -749,6 +749,106 @@ async def test_mount_failed_repair_flow(
supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid)
@pytest.mark.usefixtures("all_setup_requests")
async def test_mount_failed_move_local_data_repair_flow(
hass: HomeAssistant,
supervisor_client: AsyncMock,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test moving blocking local data from the mount_failed repair."""
mock_resolution_info(
supervisor_client,
issues=[
Issue(
type=IssueType.MOUNT_FAILED,
context=ContextType.MOUNT,
reference="media_share",
uuid=(issue_uuid := uuid4()),
reference_extra=None,
),
],
suggestions_by_issue={
issue_uuid: [
Suggestion(
# Not in aiohasupervisor's SuggestionType enum yet, arrives
# as a plain string like any newer supervisor suggestion
type="move_local_data",
context=ContextType.MOUNT,
reference="media_share",
uuid=(sugg_uuid := uuid4()),
auto=False,
reference_extra=None,
),
Suggestion(
type=SuggestionType.EXECUTE_RELOAD,
context=ContextType.MOUNT,
reference="media_share",
uuid=uuid4(),
auto=False,
reference_extra=None,
),
Suggestion(
type=SuggestionType.EXECUTE_REMOVE,
context=ContextType.MOUNT,
reference="media_share",
uuid=uuid4(),
auto=False,
reference_extra=None,
),
]
},
)
assert await async_setup_component(hass, DOMAIN, {})
repair_issue = issue_registry.async_get_issue(
domain="hassio", issue_id=issue_uuid.hex
)
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"
assert data["menu_options"] == [
"mount_move_local_data",
"mount_execute_reload",
"mount_execute_remove",
]
# Moving data aside requires a confirmation step
resp = await client.post(
f"/api/repairs/issues/fix/{flow_id}",
json={"next_step_id": "mount_move_local_data"},
)
assert resp.status == HTTPStatus.OK
data = await resp.json()
flow_id = data["flow_id"]
assert data["type"] == "form"
assert data["step_id"] == "mount_move_local_data"
supervisor_client.resolution.apply_suggestion.assert_not_called()
resp = await client.post(f"/api/repairs/issues/fix/{flow_id}", json={})
assert resp.status == HTTPStatus.OK
data = await resp.json()
assert data["type"] == "create_entry"
assert not issue_registry.async_get_issue(domain="hassio", issue_id=issue_uuid.hex)
supervisor_client.resolution.apply_suggestion.assert_called_once_with(sugg_uuid)
@pytest.mark.parametrize(
"all_setup_requests", [{"include_addons": True}], indirect=True
)