mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-08-21 21:57:26 +01:00
Add repair to move local data blocking a mount target (#7089)
* 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 <name>_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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
143da593b8
commit
ccdd8c1ef4
@@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
+142
-15
@@ -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
|
||||
`<name>_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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
):
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user