diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index 6d393a9aa..6c4f61124 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -453,6 +453,10 @@ class RestAPI(CoreSysAttributes): web.post( "/resolution/check/{check}/run", api_resolution.run_check_v1 ), + web.get( + "/resolution/issue/{issue}/suggestions", + api_resolution.suggestions_for_issue_v1, + ), ] ) else: @@ -464,6 +468,10 @@ class RestAPI(CoreSysAttributes): api_resolution.options_check, ), web.post("/resolution/check/{check}/run", api_resolution.run_check), + web.get( + "/resolution/issue/{issue}/suggestions", + api_resolution.suggestions_for_issue, + ), ] ) @@ -481,10 +489,6 @@ class RestAPI(CoreSysAttributes): "/resolution/issue/{issue}", api_resolution.dismiss_issue, ), - web.get( - "/resolution/issue/{issue}/suggestions", - api_resolution.suggestions_for_issue, - ), web.post("/resolution/healthcheck", api_resolution.healthcheck), ] ) diff --git a/supervisor/api/resolution.py b/supervisor/api/resolution.py index eff4c650b..c7f4aca5d 100644 --- a/supervisor/api/resolution.py +++ b/supervisor/api/resolution.py @@ -17,6 +17,7 @@ from ..const import ( ATTR_SUGGESTIONS, ATTR_UNHEALTHY, ATTR_UNSUPPORTED, + REQUEST_FROM, ) from ..coresys import CoreSysAttributes from ..resolution.checks.base import CheckBase @@ -67,14 +68,27 @@ class APIResolution(CoreSysAttributes): ) return resp - def _build_info_response(self) -> dict[str, Any]: + def _suggestions_for_caller( + self, request: web.Request, suggestions: list[Suggestion] | set[Suggestion] + ) -> list[Suggestion]: + """Filter suggestions the calling Core version cannot present. + + Only applied on the v1 API: Core versions predating the v2 API + render suggestions without fix flow translation as empty menu + entries. Any Core new enough for v2 filters those itself. + """ + if request.get(REQUEST_FROM) == self.sys_homeassistant: + return self.sys_resolution.core_compatible_suggestions(suggestions) + return list(suggestions) + + def _build_info_response(self, suggestions: list[Suggestion]) -> dict[str, Any]: """Build the resolution info response with current (v2) names.""" return { ATTR_UNSUPPORTED: sorted(self.sys_resolution.unsupported), ATTR_UNHEALTHY: sorted(self.sys_resolution.unhealthy), ATTR_SUGGESTIONS: [ self._generate_suggestion_information(suggestion) - for suggestion in self.sys_resolution.suggestions + for suggestion in suggestions ], ATTR_ISSUES: [asdict(issue) for issue in self.sys_resolution.issues], ATTR_CHECKS: [ @@ -86,12 +100,14 @@ class APIResolution(CoreSysAttributes): @api_process async def info(self, request: web.Request) -> dict[str, Any]: """Return resolution information.""" - return self._build_info_response() + return self._build_info_response(self.sys_resolution.suggestions) @api_process async def info_v1(self, request: web.Request) -> dict[str, Any]: """Return resolution info (v1: uses legacy issue types and check slugs).""" - data = self._build_info_response() + data = self._build_info_response( + self._suggestions_for_caller(request, self.sys_resolution.suggestions) + ) return data | { ATTR_ISSUES: [ process_issue_dict_for_legacy_compatibility(issue) @@ -126,6 +142,19 @@ class APIResolution(CoreSysAttributes): ] } + @api_process + async def suggestions_for_issue_v1(self, request: web.Request) -> dict[str, Any]: + """Return suggestions that fix an issue (v1: filtered for old Core).""" + issue = self._extract_issue(request) + return { + ATTR_SUGGESTIONS: [ + self._generate_suggestion_information(suggestion) + for suggestion in self._suggestions_for_caller( + request, self.sys_resolution.suggestions_for_issue(issue) + ) + ] + } + @api_process async def dismiss_issue(self, request: web.Request) -> None: """Dismiss issue.""" diff --git a/supervisor/mounts/manager.py b/supervisor/mounts/manager.py index 5339ab166..7f2655f58 100644 --- a/supervisor/mounts/manager.py +++ b/supervisor/mounts/manager.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable from contextlib import suppress from dataclasses import dataclass, replace import logging -from pathlib import PurePath +from pathlib import Path, PurePath from typing import Self from ..const import ATTR_NAME @@ -21,7 +21,7 @@ from ..exceptions import ( from ..host.const import HostFeature from ..jobs.const import JobCondition from ..jobs.decorator import Job -from ..resolution.const import SuggestionType +from ..resolution.const import ContextType, SuggestionType from ..utils.common import FileConfiguration from ..utils.sentry import async_capture_exception from .const import ( @@ -135,22 +135,21 @@ class MountManager(FileConfiguration, CoreSysAttributes): self.mounts.copy(), [mount.load() for mount in self.mounts] ) - # Bind all media mounts to directories in media + # Bind all media mounts to directories in media. Bind failures used + # to be silently swallowed as fire-and-forget tasks — route them into + # resolution issues so the user learns about e.g. local data blocking + # the bind mount target. if self.media_mounts: - await asyncio.wait( - [ - self.sys_create_task(self._bind_media(mount)) - for mount in self.media_mounts - ] + await self._mount_errors_to_issues( + self.media_mounts, + [self._bind_media(mount) for mount in self.media_mounts], ) # Bind all share mounts to directories in share if self.share_mounts: - await asyncio.wait( - [ - self.sys_create_task(self._bind_share(mount)) - for mount in self.share_mounts - ] + await self._mount_errors_to_issues( + self.share_mounts, + [self._bind_share(mount) for mount in self.share_mounts], ) @Job(name="mount_manager_reload", conditions=[JobCondition.MOUNT_AVAILABLE]) @@ -175,12 +174,15 @@ class MountManager(FileConfiguration, CoreSysAttributes): async def _mount_errors_to_issues( self, mounts: list[Mount], mount_tasks: list[Awaitable[None]] ) -> None: - """Await a list of tasks on mounts and turn each error into a failed mount issue.""" + """Await a list of tasks on mounts and turn each error into a resolution issue.""" errors = await asyncio.gather(*mount_tasks, return_exceptions=True) for i in range(len(errors)): # pylint: disable=consider-using-enumerate if not (err := errors[i]): continue + if isinstance(err, MountTargetNotEmptyError | MountTargetNotDirectoryError): + self._add_local_data_issue(mounts[i]) + continue if mounts[i].failed_issue in self.sys_resolution.issues: continue if not isinstance(err, MountError): @@ -194,6 +196,24 @@ class MountManager(FileConfiguration, CoreSysAttributes): ], ) + def _add_local_data_issue(self, mount: Mount) -> None: + """Add mount failed issue offering to move blocking local data. + + Uses the same mount failed issue as other mount failures so at most + one issue exists per mount, with an additional suggestion to move the + blocking data aside. Reload stays available for users who prefer to + clear the data themselves. Adding is idempotent: an existing issue + just gains the extra suggestion. + """ + self.sys_resolution.add_issue( + replace(mount.failed_issue), + suggestions=[ + SuggestionType.MOVE_LOCAL_DATA, + SuggestionType.EXECUTE_RELOAD, + SuggestionType.EXECUTE_REMOVE, + ], + ) + @Job( name="mount_manager_create_mount", conditions=[JobCondition.MOUNT_AVAILABLE], @@ -323,7 +343,114 @@ class MountManager(FileConfiguration, CoreSysAttributes): # restarting a failed data mount tears down the bind mount as well — # our BoundMount bookkeeping cannot know whether that happened. if bound_mount := self._bound_mounts.get(name): - await self._bind_mount(bound_mount.mount, bound_mount.bind_mount.where) + try: + await self._bind_mount(bound_mount.mount, bound_mount.bind_mount.where) + except MountTargetNotEmptyError, MountTargetNotDirectoryError: + # The reload above already dismissed the mount failed issue — + # re-add it so the repair does not vanish while media/share + # is still blocked by local data. + self._add_local_data_issue(bound_mount.mount) + raise + + @Job( + name="mount_manager_relocate_local_data", + conditions=[JobCondition.MOUNT_AVAILABLE], + on_condition=MountJobError, + ) + async def relocate_local_data(self, name: str) -> None: + """Move local data out of a mount's target directories, then remount. + + Local data ends up in a mount's target directory when something + wrote into it while the mount was not in place (e.g. an add-on + recording to its media directory before network storage was set up + or after the bind mount was torn down). The data is moved to a + `_local_recovery` folder in a user-accessible location + (media, share or local backup storage) instead of being deleted. + """ + # Add mount name to job + self.sys_jobs.current.reference = name + + if name not in self._mounts: + raise MountNotFound( + f"Cannot relocate local data for '{name}', no mount exists with that name" + ) + mount = self._mounts[name] + + paths = [mount.local_where] + if mount.usage == MountUsage.MEDIA: + recovery_base = self.sys_config.path_media + paths.append(self.sys_config.path_media / name) + elif mount.usage == MountUsage.SHARE: + recovery_base = self.sys_config.path_share + paths.append(self.sys_config.path_share / name) + else: + # Backup mounts have no bind mount and their data mount directory + # is not user-accessible — move the data to local backup storage, + # which is reachable via the backup share and add-ons. + recovery_base = self.sys_config.path_backup + + def move_aside() -> list[tuple[Path, Path]]: + moved: list[tuple[Path, Path]] = [] + recovery_dir: Path | None = None + for path in paths: + try: + if path.is_mount() or not path.exists(): + continue + if path.is_dir() and not any(path.iterdir()): + continue + except OSError: + continue + + # All local data blocking this mount goes to one recovery + # folder so the user finds it as a single fix. If more than + # one directory holds data, later ones become subfolders + # named after their parent (e.g. "mounts"). + if recovery_dir is None: + target = recovery_base / f"{name}_local_recovery" + counter = 1 + while target.exists(): + counter += 1 + target = recovery_base / f"{name}_local_recovery_{counter}" + recovery_dir = target + else: + target = recovery_dir / path.parent.name + + path.rename(target) + # Keep the path present for consumers even if the remount + # below fails: an empty directory instead of a missing one + path.mkdir() + moved.append((path, target)) + return moved + + try: + moved = await self.sys_run_in_executor(move_aside) + except OSError as err: + self.sys_resolution.check_oserror(err) + raise MountError( + f"Could not move local data for mount {name}: {err!s}", _LOGGER.error + ) from err + + for path, target in moved: + _LOGGER.info( + "Moved local data blocking mount %s from %s to %s", + name, + path.as_posix(), + target.as_posix(), + ) + + # With the local data out of the way, moving it again can no longer + # help. Drop the suggestion even if the remount below fails: the + # mount failed issue then remains with reload/remove, and detection + # re-adds the move suggestion if local data blocks the target again. + for suggestion in self.sys_resolution.suggestions: + if ( + suggestion.type == SuggestionType.MOVE_LOCAL_DATA + and suggestion.context == ContextType.MOUNT + and suggestion.reference == name + ): + self.sys_resolution.dismiss_suggestion(suggestion) + + await self.reload_mount(name) async def _bind_media(self, mount: Mount) -> None: """Bind a media mount to media directory.""" diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index 18943cbcb..90c0f2a5e 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -3,6 +3,8 @@ from enum import StrEnum from pathlib import Path +from awesomeversion import AwesomeVersion + from ..const import SUPERVISOR_DATA FILE_CONFIG_RESOLUTION = Path(SUPERVISOR_DATA, "resolution.json") @@ -134,10 +136,20 @@ class SuggestionType(StrEnum): EXECUTE_START = "execute_start" EXECUTE_STOP = "execute_stop" EXECUTE_UPDATE = "execute_update" + MOVE_LOCAL_DATA = "move_local_data" REGISTRY_LOGIN = "registry_login" RENAME_DATA_DISK = "rename_data_disk" +# Suggestions the Home Assistant frontend can only present from a given Core +# version on (the fix flow translations ship with Core). Suggestions below +# their minimum version are filtered from Core-facing API responses and +# events; all other API consumers always see them. +SUGGESTION_MIN_CORE_VERSION: dict[SuggestionType, AwesomeVersion] = { + SuggestionType.MOVE_LOCAL_DATA: AwesomeVersion("2026.9.0b0"), +} + + # Maps legacy check slugs to current slugs. # Legacy slugs are stored in old resolution.json or in incoming REST API. # Used to migrate persisted metadata on load and translate incoming V1 API diff --git a/supervisor/resolution/fixups/mount_move_local_data.py b/supervisor/resolution/fixups/mount_move_local_data.py new file mode 100644 index 000000000..58cf8ea7c --- /dev/null +++ b/supervisor/resolution/fixups/mount_move_local_data.py @@ -0,0 +1,53 @@ +"""Helper to fix an issue with a mount by moving local data out of its target.""" + +import logging + +from ...coresys import CoreSys +from ...exceptions import MountError, MountNotFound, ResolutionFixupError +from ..const import ContextType, IssueType, SuggestionType +from ..data import Suggestion +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> FixupBase: + """Check setup function.""" + return FixupMountMoveLocalData(coresys) + + +class FixupMountMoveLocalData(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, suggestion: Suggestion) -> None: + """Move local data out of the mount target directories and remount.""" + try: + await self.sys_mounts.relocate_local_data(suggestion.reference) + except MountNotFound: + _LOGGER.warning("Can't find mount %s for fixup", suggestion.reference) + except MountError as err: + # Leave the issue/suggestion in place so the user can try again + _LOGGER.warning( + "Could not move local data for mount %s: %s", suggestion.reference, err + ) + raise ResolutionFixupError from err + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.MOVE_LOCAL_DATA + + @property + def context(self) -> ContextType: + """Return a ContextType enum.""" + return ContextType.MOUNT + + @property + def issues(self) -> list[IssueType]: + """Return a IssueType enum list.""" + return [IssueType.MOUNT_FAILED] + + @property + def auto(self) -> bool: + """Return if a fixup can be apply as auto fix.""" + return False diff --git a/supervisor/resolution/module.py b/supervisor/resolution/module.py index 584a22a47..567bebf7d 100644 --- a/supervisor/resolution/module.py +++ b/supervisor/resolution/module.py @@ -1,5 +1,6 @@ """Supervisor resolution center.""" +from collections.abc import Iterable from dataclasses import asdict import errno import logging @@ -13,7 +14,8 @@ from ..exceptions import ( ResolutionIssueNotFound, ResolutionSuggestionNotFound, ) -from ..homeassistant.const import WSEvent +from ..homeassistant.const import LANDINGPAGE, WSEvent +from ..utils import version_is_new_enough from ..utils.common import FileConfiguration from .check import ResolutionCheck from .const import ( @@ -21,6 +23,7 @@ from .const import ( LEGACY_ISSUE_TYPE_MAP, OUTGOING_LEGACY_CHECK_SLUG_MAP, SCHEDULED_HEALTHCHECK, + SUGGESTION_MIN_CORE_VERSION, ContextType, IssueType, SuggestionType, @@ -198,25 +201,49 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): """ return self._issue_event_data(issue, with_suggestions=True) + def core_compatible_suggestions( + self, suggestions: Iterable[Suggestion] + ) -> list[Suggestion]: + """Filter suggestions to those the current Core version can present. + + Newer suggestions have no fix flow translation in older Core + frontends and would render as empty menu entries. Only used for + Core-facing output; other API consumers get the full list. + """ + version = self.sys_homeassistant.version + return [ + suggestion + for suggestion in suggestions + if (min_version := SUGGESTION_MIN_CORE_VERSION.get(suggestion.type)) is None + or ( + version is not None + and version != LANDINGPAGE + and version_is_new_enough(version, min_version) + ) + ] + def _issue_event_data( self, issue: Issue, *, with_suggestions: bool = False ) -> dict[str, Any]: """Build issue payload and apply legacy compatibility if needed.""" - data = ( - asdict(issue) - | { - "suggestions": [ - asdict(suggestion) - for suggestion in self.suggestions_for_issue(issue) - ] - } - if with_suggestions - else asdict(issue) + v2_api = self.sys_config.feature_flags.get( + FeatureFlag.SUPERVISOR_WEBSOCKET_V2_API, False ) - if not self.sys_config.feature_flags.get( - FeatureFlag.SUPERVISOR_WEBSOCKET_V2_API, False - ): + if with_suggestions: + suggestions: Iterable[Suggestion] = self.suggestions_for_issue(issue) + if not v2_api: + # Core versions predating the v2 API render suggestions + # without fix flow translation as empty menu entries. Any + # Core new enough to enable v2 filters those itself. + suggestions = self.core_compatible_suggestions(suggestions) + data = asdict(issue) | { + "suggestions": [asdict(suggestion) for suggestion in suggestions] + } + else: + data = asdict(issue) + + if not v2_api: data = process_issue_dict_for_legacy_compatibility(data) return data diff --git a/tests/api/test_resolution.py b/tests/api/test_resolution.py index 99312d897..186e3b78c 100644 --- a/tests/api/test_resolution.py +++ b/tests/api/test_resolution.py @@ -2,9 +2,10 @@ import asyncio from http import HTTPStatus -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, PropertyMock, patch from aiohttp.test_utils import TestClient +from awesomeversion import AwesomeVersion import pytest from supervisor.const import ( @@ -94,6 +95,45 @@ async def test_api_resolution_apply_suggestion( await coresys.resolution.apply_suggestion(clear_backup) +async def test_api_resolution_suggestions_filtered_for_old_core( + coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str] +): + """Test v1 responses hide suggestions the Core version cannot present. + + The v2 API never filters: no Core version predating the suggestion + filtering in its repair flow supports v2. + """ + api_client, prefix = api_client_with_prefix + coresys.resolution.add_issue( + issue := Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference="test"), + suggestions=[SuggestionType.MOVE_LOCAL_DATA, SuggestionType.EXECUTE_RELOAD], + ) + + all_types = {"execute_reload", "move_local_data"} + for version, expected_types in [ + (AwesomeVersion("2026.8.3"), {"execute_reload"} if not prefix else all_types), + (AwesomeVersion("2026.9.0b0"), all_types), + ]: + with patch.object( + type(coresys.homeassistant), + "version", + new=PropertyMock(return_value=version), + ): + resp = await api_client.get(f"{prefix}/resolution/info") + body = await resp.json() + assert { + suggestion["type"] for suggestion in body["data"]["suggestions"] + } == expected_types + + resp = await api_client.get( + f"{prefix}/resolution/issue/{issue.uuid}/suggestions" + ) + body = await resp.json() + assert { + suggestion["type"] for suggestion in body["data"]["suggestions"] + } == expected_types + + async def test_api_resolution_dismiss_issue( coresys: CoreSys, api_client_with_prefix: tuple[TestClient, str] ): diff --git a/tests/mounts/test_manager.py b/tests/mounts/test_manager.py index fcb8e7efc..0b13736b7 100644 --- a/tests/mounts/test_manager.py +++ b/tests/mounts/test_manager.py @@ -596,6 +596,122 @@ async def test_save_data( ] +async def test_load_bind_failure_creates_local_data_issue( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data, + path_extern, + mount_propagation, + mock_is_mount, +): + """Test local data blocking the bind mount at load creates a repair issue.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + + mount = Mount.from_dict(coresys, MEDIA_TEST_DATA) + coresys.mounts._mounts = {"media_test": mount} # pylint: disable=protected-access + + media_dir = coresys.config.path_media / "media_test" + media_dir.mkdir() + (media_dir / "recording.mp4").touch() + + systemd_service.response_get_unit = { + "mnt-data-supervisor-mounts-media_test.mount": [ + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + ], + "mnt-data-supervisor-media-media_test.mount": [ERROR_NO_UNIT], + } + await coresys.mounts.load() + + issue = Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference="media_test") + assert issue in coresys.resolution.issues + assert coresys.resolution.suggestions_for_issue(issue) == { + Suggestion( + SuggestionType.MOVE_LOCAL_DATA, ContextType.MOUNT, reference="media_test" + ), + Suggestion( + SuggestionType.EXECUTE_RELOAD, ContextType.MOUNT, reference="media_test" + ), + Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.MOUNT, reference="media_test" + ), + } + + +async def test_reload_mount_dismisses_local_data_issue( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + mount: Mount, +): + """Test a successful reload dismisses a stale local data issue.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + + coresys.resolution.create_issue( + IssueType.MOUNT_FAILED, + ContextType.MOUNT, + reference="media_test", + suggestions=[ + SuggestionType.MOVE_LOCAL_DATA, + SuggestionType.EXECUTE_RELOAD, + SuggestionType.EXECUTE_REMOVE, + ], + ) + + systemd_service.response_get_unit = [ + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ] + await coresys.mounts.reload_mount(mount.name) + + assert coresys.resolution.issues == [] + assert coresys.resolution.suggestions == [] + + +async def test_relocate_local_data_multiple_dirs_one_recovery_folder( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + mount: Mount, +): + """Test data from multiple blocked directories lands in one recovery folder.""" + mount_dir = mount.local_where + mount_dir.mkdir(parents=True, exist_ok=True) + (mount_dir / "stray.txt").touch() + + media_dir = coresys.config.path_media / "media_test" + media_dir.mkdir(exist_ok=True) + (media_dir / "recording.mp4").touch() + + await coresys.mounts.relocate_local_data(mount.name) + + recovery_dir = coresys.config.path_media / "media_test_local_recovery" + assert (recovery_dir / "stray.txt").exists() + assert (recovery_dir / "media" / "recording.mp4").exists() + assert not (coresys.config.path_media / "media_test_local_recovery_2").exists() + assert mount_dir.is_dir() + assert not any(mount_dir.iterdir()) + assert media_dir.is_dir() + assert not any(media_dir.iterdir()) + + +async def test_relocate_local_data_recovery_name_collision( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + mount: Mount, +): + """Test relocating local data picks a free recovery folder name.""" + media_dir = coresys.config.path_media / "media_test" + media_dir.mkdir(exist_ok=True) + (media_dir / "recording.mp4").touch() + (coresys.config.path_media / "media_test_local_recovery").mkdir() + + await coresys.mounts.relocate_local_data(mount.name) + + recovery_dir = coresys.config.path_media / "media_test_local_recovery_2" + assert (recovery_dir / "recording.mp4").exists() + assert media_dir.is_dir() + assert not any(media_dir.iterdir()) + + async def test_create_mount_blocked_by_existing_local_data( coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], diff --git a/tests/resolution/fixup/test_mount_move_local_data.py b/tests/resolution/fixup/test_mount_move_local_data.py new file mode 100644 index 000000000..7afed3805 --- /dev/null +++ b/tests/resolution/fixup/test_mount_move_local_data.py @@ -0,0 +1,224 @@ +"""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, ResolutionFixupError +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") + ), + # Swallowed today; raised once fixup failures propagate to the caller + suppress(ResolutionFixupError), + ): + 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") + ), + # Swallowed today; raised once fixup failures propagate to the caller + suppress(ResolutionFixupError), + ): + 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 == [] diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index 86f74d953..2be0996ff 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -2,10 +2,12 @@ import asyncio from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, PropertyMock, patch +from awesomeversion import AwesomeVersion import pytest +from supervisor.const import FeatureFlag from supervisor.coresys import CoreSys from supervisor.exceptions import ResolutionError from supervisor.resolution.const import ( @@ -479,3 +481,55 @@ def test_resolution_file_migration_legacy_check_slugs(legacy_slug: str, new_slug assert new_slug in migrated_config assert legacy_slug not in migrated_config assert migrated_config[new_slug]["enabled"] is False + + +async def test_core_compatible_suggestions(coresys: CoreSys): + """Test suggestions gated on a minimum Core version are filtered.""" + coresys.resolution.add_issue( + issue := Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference="test"), + suggestions=[SuggestionType.MOVE_LOCAL_DATA, SuggestionType.EXECUTE_RELOAD], + ) + + for version, expected_types in [ + (None, {SuggestionType.EXECUTE_RELOAD}), + (AwesomeVersion("landingpage"), {SuggestionType.EXECUTE_RELOAD}), + (AwesomeVersion("2026.8.3"), {SuggestionType.EXECUTE_RELOAD}), + ( + AwesomeVersion("2026.9.0b0"), + {SuggestionType.EXECUTE_RELOAD, SuggestionType.MOVE_LOCAL_DATA}, + ), + ( + AwesomeVersion("2026.10.1"), + {SuggestionType.EXECUTE_RELOAD, SuggestionType.MOVE_LOCAL_DATA}, + ), + ]: + with patch.object( + type(coresys.homeassistant), + "version", + new=PropertyMock(return_value=version), + ): + assert { + suggestion.type + for suggestion in coresys.resolution.core_compatible_suggestions( + coresys.resolution.suggestions_for_issue(issue) + ) + } == expected_types, f"unexpected filtering for Core {version}" + + # The legacy issue event payload applies the same filter + message = coresys.resolution._make_issue_message(issue) # pylint: disable=protected-access + assert { + suggestion["type"] for suggestion in message["suggestions"] + } == expected_types + + # With the v2 API enabled the Core filters itself — no filtering + coresys.config.set_feature_flag( + FeatureFlag.SUPERVISOR_WEBSOCKET_V2_API, True + ) + message = coresys.resolution._make_issue_message(issue) # pylint: disable=protected-access + assert {suggestion["type"] for suggestion in message["suggestions"]} == { + SuggestionType.EXECUTE_RELOAD, + SuggestionType.MOVE_LOCAL_DATA, + } + coresys.config.set_feature_flag( + FeatureFlag.SUPERVISOR_WEBSOCKET_V2_API, False + )