Add mount to supported features (#4301)

* Add mount to supported features

* Typo in enable

* Fix places mocking os available without version

* Increase resilence of problematic repeat task test
This commit is contained in:
Mike Degatano 2023-05-23 08:00:15 -04:00 committed by GitHub
parent fbb2776277
commit f6c3bdb6a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 131 additions and 24 deletions

View File

@ -589,3 +589,7 @@ class MountInvalidError(MountError):
class MountNotFound(MountError):
"""Raise on mount not found."""
class MountJobError(MountError, JobException):
"""Raise on Mount job error."""

View File

@ -46,6 +46,7 @@ class HostFeature(str, Enum):
HAOS = "haos"
HOSTNAME = "hostname"
JOURNAL = "journal"
MOUNT = "mount"
NETWORK = "network"
OS_AGENT = "os_agent"
REBOOT = "reboot"

View File

@ -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):

View File

@ -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"

View File

@ -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 (

View File

@ -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:

View File

@ -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(

View File

@ -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})

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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