diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index c73b658f4..041a46e87 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -589,3 +589,7 @@ class MountInvalidError(MountError): class MountNotFound(MountError): """Raise on mount not found.""" + + +class MountJobError(MountError, JobException): + """Raise on Mount job error.""" diff --git a/supervisor/host/const.py b/supervisor/host/const.py index 45393ced5..34bdae6c0 100644 --- a/supervisor/host/const.py +++ b/supervisor/host/const.py @@ -46,6 +46,7 @@ class HostFeature(str, Enum): HAOS = "haos" HOSTNAME = "hostname" JOURNAL = "journal" + MOUNT = "mount" NETWORK = "network" OS_AGENT = "os_agent" REBOOT = "reboot" diff --git a/supervisor/host/manager.py b/supervisor/host/manager.py index 299504796..ab54173dd 100644 --- a/supervisor/host/manager.py +++ b/supervisor/host/manager.py @@ -3,7 +3,7 @@ from contextlib import suppress from functools import lru_cache import logging -from supervisor.host.logs import LogsControl +from awesomeversion import AwesomeVersion from ..const import BusEvent from ..coresys import CoreSys, CoreSysAttributes @@ -14,6 +14,7 @@ from .apparmor import AppArmorControl from .const import HostFeature from .control import SystemControl from .info import InfoCenter +from .logs import LogsControl from .network import NetworkManager from .services import ServiceManager from .sound import SoundControl @@ -110,6 +111,12 @@ class HostManager(CoreSysAttributes): if self.sys_dbus.udisks2.is_connected: features.append(HostFeature.DISK) + # Support added in OS10. For supervised, assume they can if systemd is connected + if self.sys_dbus.systemd.is_connected and ( + not self.sys_os.available or self.sys_os.version >= AwesomeVersion("10") + ): + features.append(HostFeature.MOUNT) + return features async def reload(self): diff --git a/supervisor/jobs/const.py b/supervisor/jobs/const.py index 580e37b27..091e46c67 100644 --- a/supervisor/jobs/const.py +++ b/supervisor/jobs/const.py @@ -19,6 +19,7 @@ class JobCondition(str, Enum): HOST_NETWORK = "host_network" INTERNET_HOST = "internet_host" INTERNET_SYSTEM = "internet_system" + MOUNT_AVAILABLE = "mount_available" OS_AGENT = "os_agent" PLUGINS_UPDATED = "plugins_updated" RUNNING = "running" diff --git a/supervisor/jobs/decorator.py b/supervisor/jobs/decorator.py index abe951e6b..ebdda844c 100644 --- a/supervisor/jobs/decorator.py +++ b/supervisor/jobs/decorator.py @@ -269,6 +269,14 @@ class Job(CoreSysAttributes): f"'{self._method.__qualname__}' blocked from execution, was unable to update plugin(s) {', '.join(update_failures)} and all plugins must be up to date first" ) + if ( + JobCondition.MOUNT_AVAILABLE in used_conditions + and HostFeature.MOUNT not in self.sys_host.features + ): + raise JobConditionException( + f"'{self._method.__qualname__}' blocked from execution, mounting not supported on system" + ) + async def _acquire_exection_limit(self) -> None: """Process exection limits.""" if self.limit not in ( diff --git a/supervisor/mounts/manager.py b/supervisor/mounts/manager.py index 2d9b29d18..5050eec6b 100644 --- a/supervisor/mounts/manager.py +++ b/supervisor/mounts/manager.py @@ -9,7 +9,10 @@ from pathlib import PurePath from ..const import ATTR_NAME from ..coresys import CoreSys, CoreSysAttributes from ..dbus.const import UnitActiveState -from ..exceptions import MountActivationError, MountError, MountNotFound +from ..exceptions import MountActivationError, MountError, MountJobError, MountNotFound +from ..host.const import HostFeature +from ..jobs.const import JobCondition +from ..jobs.decorator import Job from ..resolution.const import SuggestionType from ..utils.common import FileConfiguration from ..utils.sentry import capture_exception @@ -102,6 +105,12 @@ class MountManager(FileConfiguration, CoreSysAttributes): if not self.mounts: return + if HostFeature.MOUNT not in self.sys_host.features: + _LOGGER.error( + "Cannot load configured mounts because mounting not supported on system!" + ) + return + _LOGGER.info("Initializing all user-configured mounts") await self._mount_errors_to_issues( self.mounts.copy(), [mount.load() for mount in self.mounts] @@ -116,6 +125,7 @@ class MountManager(FileConfiguration, CoreSysAttributes): ] ) + @Job(conditions=[JobCondition.MOUNT_AVAILABLE]) async def reload(self) -> None: """Update mounts info via dbus and reload failed mounts.""" await asyncio.wait( @@ -153,6 +163,7 @@ class MountManager(FileConfiguration, CoreSysAttributes): ], ) + @Job(conditions=[JobCondition.MOUNT_AVAILABLE], on_condition=MountJobError) async def create_mount(self, mount: Mount) -> None: """Add/update a mount.""" if mount.name in self._mounts: @@ -170,6 +181,7 @@ class MountManager(FileConfiguration, CoreSysAttributes): if mount.usage == MountUsage.MEDIA: await self._bind_media(mount) + @Job(conditions=[JobCondition.MOUNT_AVAILABLE], on_condition=MountJobError) async def remove_mount(self, name: str, *, retain_entry: bool = False) -> None: """Remove a mount.""" if name not in self._mounts: @@ -192,6 +204,7 @@ class MountManager(FileConfiguration, CoreSysAttributes): return mount + @Job(conditions=[JobCondition.MOUNT_AVAILABLE], on_condition=MountJobError) async def reload_mount(self, name: str) -> None: """Reload a mount to retry mounting with same config.""" if name not in self._mounts: diff --git a/tests/api/test_mounts.py b/tests/api/test_mounts.py index 061a074e5..9e3a9ba30 100644 --- a/tests/api/test_mounts.py +++ b/tests/api/test_mounts.py @@ -158,6 +158,32 @@ async def test_api_create_dbus_error_mount_not_added( assert result["data"]["mounts"] == [] +@pytest.mark.parametrize("os_available", ["9.5"], indirect=True) +async def test_api_create_mount_fails_not_supported_feature( + api_client: TestClient, + coresys: CoreSys, + os_available, +): + """Test creating a mount via API fails when mounting isn't a supported feature on system..""" + resp = await api_client.post( + "/mounts", + json={ + "name": "backup_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "backups", + }, + ) + assert resp.status == 400 + result = await resp.json() + assert result["result"] == "error" + assert ( + result["message"] + == "'MountManager.create_mount' blocked from execution, mounting not supported on system" + ) + + async def test_api_update_mount(api_client: TestClient, coresys: CoreSys, mount): """Test updating a mount via API.""" resp = await api_client.put( diff --git a/tests/api/test_os.py b/tests/api/test_os.py index af256e83e..e92f17edb 100644 --- a/tests/api/test_os.py +++ b/tests/api/test_os.py @@ -17,8 +17,6 @@ from tests.dbus_service_mocks.agent_boards_yellow import Yellow as YellowService from tests.dbus_service_mocks.agent_datadisk import DataDisk as DataDiskService from tests.dbus_service_mocks.base import DBusServiceMock -# pylint: disable=protected-access - @pytest.fixture(name="boards_service") async def fixture_boards_service( @@ -58,11 +56,12 @@ async def test_api_os_info_with_agent(api_client: TestClient, coresys: CoreSys): ids=["non-existent", "unavailable drive by path", "unavailable drive by id"], ) async def test_api_os_datadisk_move_fail( - api_client: TestClient, coresys: CoreSys, new_disk: str + api_client: TestClient, + coresys: CoreSys, + new_disk: str, + os_available, ): """Test datadisk move to non-existent or invalid devices.""" - coresys.os._available = True - resp = await api_client.post("/os/datadisk/move", json={"device": new_disk}) result = await resp.json() @@ -98,11 +97,11 @@ async def test_api_os_datadisk_migrate( coresys: CoreSys, os_agent_services: dict[str, DBusServiceMock], new_disk: str, + os_available, ): """Test migrating datadisk.""" datadisk_service: DataDiskService = os_agent_services["agent_datadisk"] datadisk_service.ChangeDevice.calls.clear() - coresys.os._available = True with patch.object(SystemControl, "reboot") as reboot: resp = await api_client.post("/os/datadisk/move", json={"device": new_disk}) diff --git a/tests/conftest.py b/tests/conftest.py index c9a146cba..6775fca41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,7 @@ from supervisor.dbus.network import NetworkManager from supervisor.docker.manager import DockerAPI from supervisor.docker.monitor import DockerMonitor from supervisor.host.logs import LogsControl +from supervisor.os.manager import OSManager from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository from supervisor.utils.dt import utcnow @@ -602,3 +603,17 @@ async def capture_exception() -> Mock: "supervisor.utils.sentry.sentry_sdk.capture_exception" ) as capture_exception: yield capture_exception + + +@pytest.fixture +async def os_available(request: pytest.FixtureRequest) -> None: + """Mock os as available.""" + version = ( + AwesomeVersion(request.param) + if hasattr(request, "param") + else AwesomeVersion("10.0") + ) + with patch.object( + OSManager, "available", new=PropertyMock(return_value=True) + ), patch.object(OSManager, "version", new=PropertyMock(return_value=version)): + yield diff --git a/tests/misc/test_scheduler.py b/tests/misc/test_scheduler.py index 216b1ac64..4a9d246c3 100644 --- a/tests/misc/test_scheduler.py +++ b/tests/misc/test_scheduler.py @@ -29,7 +29,11 @@ async def test_simple_task_repeat(coresys): trigger.append(True) coresys.scheduler.register_task(test_task, 0.1, True) - await asyncio.sleep(0.3) + + for _ in range(5): + await asyncio.sleep(0.1) + if len(trigger) > 1: + break assert len(trigger) > 1 diff --git a/tests/mounts/test_manager.py b/tests/mounts/test_manager.py index 660231e81..a1821c001 100644 --- a/tests/mounts/test_manager.py +++ b/tests/mounts/test_manager.py @@ -10,7 +10,12 @@ import pytest from supervisor.coresys import CoreSys from supervisor.dbus.const import UnitActiveState -from supervisor.exceptions import MountActivationError, MountError, MountNotFound +from supervisor.exceptions import ( + MountActivationError, + MountError, + MountJobError, + MountNotFound, +) from supervisor.mounts.manager import MountManager from supervisor.mounts.mount import Mount from supervisor.resolution.const import ContextType, IssueType, SuggestionType @@ -506,3 +511,33 @@ async def test_reload_mounts( assert mount.state == UnitActiveState.ACTIVE assert mount.failed_issue not in coresys.resolution.issues assert not coresys.resolution.suggestions_for_issue(mount.failed_issue) + + +@pytest.mark.parametrize("os_available", ["9.5"], indirect=True) +async def test_mounting_not_supported( + coresys: CoreSys, + caplog: pytest.LogCaptureFixture, + os_available, +): + """Test mounting not supported on system.""" + caplog.clear() + + await coresys.mounts.load() + assert not caplog.text + + mount = Mount.from_dict(coresys, MEDIA_TEST_DATA) + coresys.mounts._mounts = {"media_test": mount} # pylint: disable=protected-access + + # Only tell the user about an issue here if they actually have mounts we couldn't load + # This is an edge case but users can downgrade OS so its possible + await coresys.mounts.load() + assert "Cannot load configured mounts" in caplog.text + + with pytest.raises(MountJobError): + await coresys.mounts.create_mount(mount) + + with pytest.raises(MountJobError): + await coresys.mounts.reload_mount("media_test") + + with pytest.raises(MountJobError): + await coresys.mounts.remove_mount("media_test") diff --git a/tests/os/test_data_disk.py b/tests/os/test_data_disk.py index 05ac89d4f..c45e15709 100644 --- a/tests/os/test_data_disk.py +++ b/tests/os/test_data_disk.py @@ -22,8 +22,6 @@ from tests.dbus_service_mocks.udisks2_partition_table import ( PartitionTable as PartitionTableService, ) -# pylint: disable=protected-access - @pytest.fixture(autouse=True) async def add_unusable_drive( @@ -70,10 +68,8 @@ async def tests_datadisk_current(coresys: CoreSys): ["/dev/sdaaaa", "/dev/mmcblk1", "Generic-Flash-Disk-61BCDDB6"], ids=["non-existent", "unavailable drive by path", "unavailable drive by id"], ) -async def test_datadisk_move_fail(coresys: CoreSys, new_disk: str): +async def test_datadisk_move_fail(coresys: CoreSys, new_disk: str, os_available): """Test datadisk move to non-existent or invalid devices.""" - coresys.os._available = True - with pytest.raises( HassOSDataDiskError, match=f"'{new_disk}' not a valid data disk target!" ): @@ -112,13 +108,13 @@ async def test_datadisk_migrate( coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], new_disk: str, + os_available, ): """Test migrating data disk.""" datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] datadisk_service.ChangeDevice.calls.clear() logind_service: LogindService = all_dbus_services["logind"] logind_service.Reboot.calls.clear() - coresys.os._available = True with patch.object(Core, "shutdown") as shutdown: await coresys.os.datadisk.migrate_disk(new_disk) @@ -137,6 +133,7 @@ async def test_datadisk_migrate_mark_data_move( coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], new_disk: str, + os_available, ): """Test migrating data disk with os agent 1.5.0 or later.""" datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] @@ -155,7 +152,6 @@ async def test_datadisk_migrate_mark_data_move( all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"}) await all_dbus_services["os_agent"].ping() - coresys.os._available = True with patch.object(Core, "shutdown") as shutdown: await coresys.os.datadisk.migrate_disk(new_disk) @@ -181,6 +177,7 @@ async def test_datadisk_migrate_mark_data_move( async def test_datadisk_migrate_too_small( coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], + os_available, ): """Test migration stops and exits if new partition is too small.""" datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] @@ -198,7 +195,6 @@ async def test_datadisk_migrate_too_small( all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"}) await all_dbus_services["os_agent"].ping() - coresys.os._available = True with pytest.raises( HassOSDataDiskError, @@ -214,6 +210,7 @@ async def test_datadisk_migrate_too_small( async def test_datadisk_migrate_multiple_external_data_disks( coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], + os_available, ): """Test migration stops when another hassos-data-external partition detected.""" datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] @@ -226,7 +223,6 @@ async def test_datadisk_migrate_multiple_external_data_disks( sdb1_filesystem_service.fixture = replace( sdb1_filesystem_service.fixture, MountPoints=[] ) - coresys.os._available = True with pytest.raises( HassOSDataDiskError, @@ -241,6 +237,7 @@ async def test_datadisk_migrate_multiple_external_data_disks( async def test_datadisk_migrate_between_external_renames( coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], + os_available, ): """Test migration from one external data disk to another renames the original.""" sdb1_partition_service: PartitionService = all_dbus_services["udisks2_partition"][ @@ -266,7 +263,6 @@ async def test_datadisk_migrate_between_external_renames( all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"}) await all_dbus_services["os_agent"].ping() - coresys.os._available = True await coresys.os.datadisk.migrate_disk("Generic-Flash-Disk-61BCDDB6") diff --git a/tests/resolution/evaluation/test_evaluate_cgroup.py b/tests/resolution/evaluation/test_evaluate_cgroup.py index da81b69eb..df9881bd7 100644 --- a/tests/resolution/evaluation/test_evaluate_cgroup.py +++ b/tests/resolution/evaluation/test_evaluate_cgroup.py @@ -1,5 +1,5 @@ """Test evaluation base.""" -# pylint: disable=import-error,protected-access +# pylint: disable=import-error from unittest.mock import patch from supervisor.const import CoreState @@ -24,7 +24,6 @@ async def test_evaluation(coresys: CoreSys): coresys.resolution.unsupported.clear() coresys.docker.info.cgroup = CGROUP_V2_VERSION - coresys.os._available = False await cgroup_version() assert cgroup_version.reason in coresys.resolution.unsupported coresys.resolution.unsupported.clear() @@ -34,12 +33,11 @@ async def test_evaluation(coresys: CoreSys): assert cgroup_version.reason not in coresys.resolution.unsupported -async def test_evaluation_os_available(coresys: CoreSys): +async def test_evaluation_os_available(coresys: CoreSys, os_available): """Test evaluation with OS available.""" cgroup_version = EvaluateCGroupVersion(coresys) coresys.core.state = CoreState.SETUP - coresys.os._available = True coresys.docker.info.cgroup = CGROUP_V2_VERSION await cgroup_version() assert cgroup_version.reason not in coresys.resolution.unsupported