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:
Stefan Agner
2026-08-19 23:40:47 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 143da593b8
commit ccdd8c1ef4
10 changed files with 725 additions and 39 deletions
+8 -4
View File
@@ -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),
]
)
+33 -4
View File
@@ -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
View File
@@ -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."""
+12
View File
@@ -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
+41 -14
View File
@@ -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
+41 -1
View File
@@ -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]
):
+116
View File
@@ -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 == []
+55 -1
View File
@@ -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
)