From ccdd8c1ef441ee8a6cef5cf50f343d56fccca764 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 19 Aug 2026 23:40:47 +0200 Subject: [PATCH] Add repair to move local data blocking a mount target (#7089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add repair to move local data blocking a mount target When an add-on writes into a media/share directory while its network mount is not in place (#7037), the local data blocks re-creating the mount: mounting over a non-empty directory is refused. Until now this failed silently at Supervisor startup — the bind mounts were created as fire-and-forget tasks — and the only way out was to remove the data manually over SSH/Samba and re-create the mount via the API. Surface the condition as a new mount_target_not_empty issue and offer a move_local_data suggestion. The fixup moves the blocking data to a _local_recovery folder in a user-accessible location — media or share for bind mount targets, local backup storage for backup mounts (their data mount directory is not reachable for users) — then reloads the mount. Nothing is deleted; users can inspect and clean up the recovered data via the media browser or the share and backup folders. Bind mount failures during load are now awaited and routed into resolution issues instead of being swallowed as fire-and-forget tasks; bind failures other than blocking local data create the existing mount_failed issue. A successful mount reload dismisses a stale local data issue. Co-Authored-By: Claude Fable 5 * Attach move local data suggestion to the mount failed issue Review feedback on the repair: rather than introducing a separate mount_target_not_empty issue type, keep a single mount failed issue per mount and offer moving the blocking data as an additional suggestion alongside reload and remove. Reload stays available for users who prefer to clear the data themselves, and at most one repair exists per mount. Adding is idempotent, so an already-raised mount failed issue just gains the extra suggestion. When re-creating the bind mount after a successful reload fails on blocking local data, the mount failed issue is re-added together with the move suggestion, since the reload already dismissed it. This also resolves the reviewer note about not-a-directory conflicts being reported under a not-empty issue type: the issue type no longer encodes the filesystem detail, while the error messages keep the distinction. Co-Authored-By: Claude Fable 5 * Keep an empty directory in place after relocating local data If remounting fails after the local data was moved aside (e.g. the server is unreachable at that moment), the renamed directory left nothing behind: media/share consumers saw the folder disappear entirely. Recreate an empty directory right after the rename so the path stays present regardless of whether the remount succeeds. Co-Authored-By: Claude Fable 5 * Drop move local data suggestion once the data was moved When the remount after relocating local data fails (e.g. the server is unreachable at that moment), the mount failed issue stays — but the move suggestion stayed with it, offering to move data that is no longer in the way. Dismiss the suggestion after the relocation step so only reload and remove remain for the leftover failure. Detection re-adds the move suggestion if local data blocks the target again. Co-Authored-By: Claude Fable 5 * Filter Core-facing suggestions by minimum Core version The fix flow translations for a new suggestion ship with a Core release. Older Core frontends render an unknown suggestion as an empty, unlabeled menu entry in the repair fix flow. Filter such suggestions from Core-facing output — the issue events sent over the websocket and the resolution API responses when the caller is Home Assistant — until the connected Core is new enough. Other API consumers like the CLI always see the full suggestion list. The move_local_data suggestion requires Core 2026.9.0b0, the release its fix flow translations are targeted at. Co-Authored-By: Claude Fable 5 * Make fixup failure tests independent of error propagation behavior Suppress a potential ResolutionFixupError from the failing fixup calls so the tests pass both while fixup errors are swallowed and once they propagate to the caller (#7150). Co-Authored-By: Claude Fable 5 * Address review: one recovery folder, check OSError for known issues Move all local data blocking a mount into a single recovery folder so the user finds it as one fix: when more than one directory holds data, later ones become subfolders named after their parent directory instead of numbered sibling folders. Also run OSError from the relocation through check_oserror to pick up known filesystem issues like corruption (bad message). Co-Authored-By: Claude Fable 5 * Only filter Core-facing suggestions on v1 surfaces Per review: no Core version predating the repair suggestion filtering in its own fix flow (home-assistant/core#179540) supports the v2 API, so the Supervisor-side compatibility filter is only needed where old Core versions actually look. Filter the v1 resolution endpoints and the legacy websocket issue payloads; the v2 endpoints and v2 event payloads always carry the full suggestion list. The suggestions for issue endpoint gets a v1 handler for this. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- supervisor/api/__init__.py | 12 +- supervisor/api/resolution.py | 37 ++- supervisor/mounts/manager.py | 157 ++++++++++-- supervisor/resolution/const.py | 12 + .../fixups/mount_move_local_data.py | 53 +++++ supervisor/resolution/module.py | 55 +++-- tests/api/test_resolution.py | 42 +++- tests/mounts/test_manager.py | 116 +++++++++ .../fixup/test_mount_move_local_data.py | 224 ++++++++++++++++++ tests/resolution/test_resolution_manager.py | 56 ++++- 10 files changed, 725 insertions(+), 39 deletions(-) create mode 100644 supervisor/resolution/fixups/mount_move_local_data.py create mode 100644 tests/resolution/fixup/test_mount_move_local_data.py 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 + )