mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-09-30 20:43:46 +01:00
* Surface fixup failures to the caller applying a suggestion Applying a suggestion whose fixup failed reported success: the fixup base swallowed ResolutionFixupError, the API returned OK, and the repair flow in Home Assistant completed and removed the repair from the UI while the issue persisted in Supervisor. Let the error propagate from the fixup instead. The autofix loop already handles and logs per-fixup errors, bus-event triggered fixups now get the same treatment in the event callback, and a user-applied suggestion surfaces the failure as an API error so clients can show that the fix did not apply. ResolutionFixupError becomes an APIError with a translatable error key so it is reported as a client-visible error instead of an unexpected one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop ResolutionFixupError, let fixup failures bubble Per review: the generic wrapper existed for a time when suggestions were only applied by autofix and the one job was separating fixup failures from real bugs for Sentry. The suggestion API is in regular use now and the wrapper actively hurts it — well-defined errors from the underlying operations were caught and replaced with a generic message. Remove the exception type entirely and let the original errors reach the caller. The autofix loop and the bus-event fixup path treat any HassioError as an environmental/config failure: log and continue without Sentry capture (the raise site reports to Sentry where warranted); everything else is still captured. Direct raises in the data disk fixups become HassOSDataDiskError, the could-not-start check in the app start fixup becomes AppsError. ResolutionFixupJobError now derives from ResolutionError and JobException. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Share unattended fixup error handling between autofix and bus events Address review: the bus event listener only caught HassioError, so a Supervisor bug in a bus-triggered fixup ended as an unretrieved task exception without Sentry report, unlike the same bug during autofix. Both unattended paths now use a common apply_fixup_safely() helper: log HassioError (environmental, reported at the raise site if warranted), capture everything else. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
225 lines
6.6 KiB
Python
225 lines
6.6 KiB
Python
"""Test fixup mount move local data."""
|
|
|
|
from contextlib import suppress
|
|
from unittest.mock import patch
|
|
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.exceptions import MountError
|
|
from supervisor.mounts.manager import MountManager
|
|
from supervisor.mounts.mount import Mount
|
|
from supervisor.resolution.const import ContextType, IssueType, SuggestionType
|
|
from supervisor.resolution.fixups.mount_move_local_data import FixupMountMoveLocalData
|
|
|
|
from tests.dbus_service_mocks.base import DBusServiceMock
|
|
|
|
MEDIA_TEST_DATA = {
|
|
"name": "media_test",
|
|
"type": "nfs",
|
|
"usage": "media",
|
|
"server": "media.local",
|
|
"path": "/media",
|
|
}
|
|
BACKUP_TEST_DATA = {
|
|
"name": "backup_test",
|
|
"type": "cifs",
|
|
"usage": "backup",
|
|
"server": "backup.local",
|
|
"share": "backups",
|
|
}
|
|
|
|
|
|
async def test_fixup_media_mount(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock],
|
|
tmp_supervisor_data,
|
|
path_extern,
|
|
mount_propagation,
|
|
mock_is_mount,
|
|
):
|
|
"""Test fixup moves local data out of the media directory and remounts."""
|
|
mount_move_local_data = FixupMountMoveLocalData(coresys)
|
|
|
|
assert mount_move_local_data.auto is False
|
|
|
|
await coresys.mounts.create_mount(Mount.from_dict(coresys, MEDIA_TEST_DATA))
|
|
|
|
media_dir = coresys.config.path_media / "media_test"
|
|
media_dir.mkdir(exist_ok=True)
|
|
(media_dir / "recording.mp4").touch()
|
|
|
|
coresys.resolution.create_issue(
|
|
IssueType.MOUNT_FAILED,
|
|
ContextType.MOUNT,
|
|
reference="media_test",
|
|
suggestions=[
|
|
SuggestionType.MOVE_LOCAL_DATA,
|
|
SuggestionType.EXECUTE_RELOAD,
|
|
SuggestionType.EXECUTE_REMOVE,
|
|
],
|
|
)
|
|
|
|
await mount_move_local_data()
|
|
|
|
recovery_dir = coresys.config.path_media / "media_test_local_recovery"
|
|
assert (recovery_dir / "recording.mp4").exists()
|
|
assert media_dir.is_dir()
|
|
assert not any(media_dir.iterdir())
|
|
assert coresys.resolution.issues == []
|
|
assert coresys.resolution.suggestions == []
|
|
assert "media_test" in coresys.mounts
|
|
|
|
|
|
async def test_fixup_backup_mount(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock],
|
|
tmp_supervisor_data,
|
|
path_extern,
|
|
mount_propagation,
|
|
mock_is_mount,
|
|
):
|
|
"""Test fixup moves local data of a backup mount to local backup storage."""
|
|
mount_move_local_data = FixupMountMoveLocalData(coresys)
|
|
|
|
await coresys.mounts.create_mount(Mount.from_dict(coresys, BACKUP_TEST_DATA))
|
|
|
|
mount_dir = coresys.mounts.get("backup_test").local_where
|
|
mount_dir.mkdir(parents=True, exist_ok=True)
|
|
(mount_dir / "stranded_backup.tar").touch()
|
|
|
|
coresys.resolution.create_issue(
|
|
IssueType.MOUNT_FAILED,
|
|
ContextType.MOUNT,
|
|
reference="backup_test",
|
|
suggestions=[
|
|
SuggestionType.MOVE_LOCAL_DATA,
|
|
SuggestionType.EXECUTE_RELOAD,
|
|
SuggestionType.EXECUTE_REMOVE,
|
|
],
|
|
)
|
|
|
|
await mount_move_local_data()
|
|
|
|
recovery_dir = coresys.config.path_backup / "backup_test_local_recovery"
|
|
assert (recovery_dir / "stranded_backup.tar").exists()
|
|
assert mount_dir.is_dir()
|
|
assert not any(mount_dir.iterdir())
|
|
assert coresys.resolution.issues == []
|
|
assert coresys.resolution.suggestions == []
|
|
|
|
|
|
async def test_fixup_failed_remount_drops_move_suggestion(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock],
|
|
tmp_supervisor_data,
|
|
path_extern,
|
|
mount_propagation,
|
|
mock_is_mount,
|
|
):
|
|
"""Test a failed remount after moving keeps the issue without move suggestion.
|
|
|
|
Once the data was moved aside, moving it again cannot help — only the
|
|
reload and remove suggestions still apply to the remaining failure.
|
|
"""
|
|
mount_move_local_data = FixupMountMoveLocalData(coresys)
|
|
|
|
await coresys.mounts.create_mount(Mount.from_dict(coresys, MEDIA_TEST_DATA))
|
|
|
|
media_dir = coresys.config.path_media / "media_test"
|
|
media_dir.mkdir(exist_ok=True)
|
|
(media_dir / "recording.mp4").touch()
|
|
|
|
coresys.resolution.create_issue(
|
|
IssueType.MOUNT_FAILED,
|
|
ContextType.MOUNT,
|
|
reference="media_test",
|
|
suggestions=[
|
|
SuggestionType.MOVE_LOCAL_DATA,
|
|
SuggestionType.EXECUTE_RELOAD,
|
|
SuggestionType.EXECUTE_REMOVE,
|
|
],
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
MountManager, "reload_mount", side_effect=MountError("Test remount failure")
|
|
),
|
|
# The failure propagates with its original error
|
|
suppress(MountError),
|
|
):
|
|
await mount_move_local_data()
|
|
|
|
recovery_dir = coresys.config.path_media / "media_test_local_recovery"
|
|
assert (recovery_dir / "recording.mp4").exists()
|
|
assert len(coresys.resolution.issues) == 1
|
|
assert {suggestion.type for suggestion in coresys.resolution.suggestions} == {
|
|
SuggestionType.EXECUTE_RELOAD,
|
|
SuggestionType.EXECUTE_REMOVE,
|
|
}
|
|
|
|
|
|
async def test_fixup_failure_keeps_suggestion(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock],
|
|
tmp_supervisor_data,
|
|
path_extern,
|
|
mount_propagation,
|
|
mock_is_mount,
|
|
):
|
|
"""Test failing to relocate keeps the issue and suggestion for retry."""
|
|
mount_move_local_data = FixupMountMoveLocalData(coresys)
|
|
|
|
await coresys.mounts.create_mount(Mount.from_dict(coresys, MEDIA_TEST_DATA))
|
|
|
|
coresys.resolution.create_issue(
|
|
IssueType.MOUNT_FAILED,
|
|
ContextType.MOUNT,
|
|
reference="media_test",
|
|
suggestions=[
|
|
SuggestionType.MOVE_LOCAL_DATA,
|
|
SuggestionType.EXECUTE_RELOAD,
|
|
SuggestionType.EXECUTE_REMOVE,
|
|
],
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
MountManager, "relocate_local_data", side_effect=MountError("fail")
|
|
),
|
|
# The failure propagates with its original error
|
|
suppress(MountError),
|
|
):
|
|
await mount_move_local_data()
|
|
|
|
assert len(coresys.resolution.issues) == 1
|
|
assert len(coresys.resolution.suggestions) == 3
|
|
|
|
|
|
async def test_fixup_missing_mount(
|
|
coresys: CoreSys,
|
|
all_dbus_services: dict[str, DBusServiceMock],
|
|
tmp_supervisor_data,
|
|
path_extern,
|
|
mount_propagation,
|
|
mock_is_mount,
|
|
):
|
|
"""Test fixup dismisses the issue if the mount no longer exists."""
|
|
mount_move_local_data = FixupMountMoveLocalData(coresys)
|
|
|
|
await coresys.mounts.load()
|
|
|
|
coresys.resolution.create_issue(
|
|
IssueType.MOUNT_FAILED,
|
|
ContextType.MOUNT,
|
|
reference="does_not_exist",
|
|
suggestions=[
|
|
SuggestionType.MOVE_LOCAL_DATA,
|
|
SuggestionType.EXECUTE_RELOAD,
|
|
SuggestionType.EXECUTE_REMOVE,
|
|
],
|
|
)
|
|
|
|
await mount_move_local_data()
|
|
|
|
assert coresys.resolution.issues == []
|
|
assert coresys.resolution.suggestions == []
|