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): class MountNotFound(MountError):
"""Raise on mount not found.""" """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" HAOS = "haos"
HOSTNAME = "hostname" HOSTNAME = "hostname"
JOURNAL = "journal" JOURNAL = "journal"
MOUNT = "mount"
NETWORK = "network" NETWORK = "network"
OS_AGENT = "os_agent" OS_AGENT = "os_agent"
REBOOT = "reboot" REBOOT = "reboot"

View File

@ -3,7 +3,7 @@ from contextlib import suppress
from functools import lru_cache from functools import lru_cache
import logging import logging
from supervisor.host.logs import LogsControl from awesomeversion import AwesomeVersion
from ..const import BusEvent from ..const import BusEvent
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
@ -14,6 +14,7 @@ from .apparmor import AppArmorControl
from .const import HostFeature from .const import HostFeature
from .control import SystemControl from .control import SystemControl
from .info import InfoCenter from .info import InfoCenter
from .logs import LogsControl
from .network import NetworkManager from .network import NetworkManager
from .services import ServiceManager from .services import ServiceManager
from .sound import SoundControl from .sound import SoundControl
@ -110,6 +111,12 @@ class HostManager(CoreSysAttributes):
if self.sys_dbus.udisks2.is_connected: if self.sys_dbus.udisks2.is_connected:
features.append(HostFeature.DISK) 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 return features
async def reload(self): async def reload(self):

View File

@ -19,6 +19,7 @@ class JobCondition(str, Enum):
HOST_NETWORK = "host_network" HOST_NETWORK = "host_network"
INTERNET_HOST = "internet_host" INTERNET_HOST = "internet_host"
INTERNET_SYSTEM = "internet_system" INTERNET_SYSTEM = "internet_system"
MOUNT_AVAILABLE = "mount_available"
OS_AGENT = "os_agent" OS_AGENT = "os_agent"
PLUGINS_UPDATED = "plugins_updated" PLUGINS_UPDATED = "plugins_updated"
RUNNING = "running" 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" 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: async def _acquire_exection_limit(self) -> None:
"""Process exection limits.""" """Process exection limits."""
if self.limit not in ( if self.limit not in (

View File

@ -9,7 +9,10 @@ from pathlib import PurePath
from ..const import ATTR_NAME from ..const import ATTR_NAME
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..dbus.const import UnitActiveState 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 ..resolution.const import SuggestionType
from ..utils.common import FileConfiguration from ..utils.common import FileConfiguration
from ..utils.sentry import capture_exception from ..utils.sentry import capture_exception
@ -102,6 +105,12 @@ class MountManager(FileConfiguration, CoreSysAttributes):
if not self.mounts: if not self.mounts:
return 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") _LOGGER.info("Initializing all user-configured mounts")
await self._mount_errors_to_issues( await self._mount_errors_to_issues(
self.mounts.copy(), [mount.load() for mount in self.mounts] 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: async def reload(self) -> None:
"""Update mounts info via dbus and reload failed mounts.""" """Update mounts info via dbus and reload failed mounts."""
await asyncio.wait( 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: async def create_mount(self, mount: Mount) -> None:
"""Add/update a mount.""" """Add/update a mount."""
if mount.name in self._mounts: if mount.name in self._mounts:
@ -170,6 +181,7 @@ class MountManager(FileConfiguration, CoreSysAttributes):
if mount.usage == MountUsage.MEDIA: if mount.usage == MountUsage.MEDIA:
await self._bind_media(mount) 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: async def remove_mount(self, name: str, *, retain_entry: bool = False) -> None:
"""Remove a mount.""" """Remove a mount."""
if name not in self._mounts: if name not in self._mounts:
@ -192,6 +204,7 @@ class MountManager(FileConfiguration, CoreSysAttributes):
return mount return mount
@Job(conditions=[JobCondition.MOUNT_AVAILABLE], on_condition=MountJobError)
async def reload_mount(self, name: str) -> None: async def reload_mount(self, name: str) -> None:
"""Reload a mount to retry mounting with same config.""" """Reload a mount to retry mounting with same config."""
if name not in self._mounts: 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"] == [] 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): async def test_api_update_mount(api_client: TestClient, coresys: CoreSys, mount):
"""Test updating a mount via API.""" """Test updating a mount via API."""
resp = await api_client.put( 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.agent_datadisk import DataDisk as DataDiskService
from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.base import DBusServiceMock
# pylint: disable=protected-access
@pytest.fixture(name="boards_service") @pytest.fixture(name="boards_service")
async def fixture_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"], ids=["non-existent", "unavailable drive by path", "unavailable drive by id"],
) )
async def test_api_os_datadisk_move_fail( 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.""" """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}) resp = await api_client.post("/os/datadisk/move", json={"device": new_disk})
result = await resp.json() result = await resp.json()
@ -98,11 +97,11 @@ async def test_api_os_datadisk_migrate(
coresys: CoreSys, coresys: CoreSys,
os_agent_services: dict[str, DBusServiceMock], os_agent_services: dict[str, DBusServiceMock],
new_disk: str, new_disk: str,
os_available,
): ):
"""Test migrating datadisk.""" """Test migrating datadisk."""
datadisk_service: DataDiskService = os_agent_services["agent_datadisk"] datadisk_service: DataDiskService = os_agent_services["agent_datadisk"]
datadisk_service.ChangeDevice.calls.clear() datadisk_service.ChangeDevice.calls.clear()
coresys.os._available = True
with patch.object(SystemControl, "reboot") as reboot: with patch.object(SystemControl, "reboot") as reboot:
resp = await api_client.post("/os/datadisk/move", json={"device": new_disk}) 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.manager import DockerAPI
from supervisor.docker.monitor import DockerMonitor from supervisor.docker.monitor import DockerMonitor
from supervisor.host.logs import LogsControl from supervisor.host.logs import LogsControl
from supervisor.os.manager import OSManager
from supervisor.store.addon import AddonStore from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository from supervisor.store.repository import Repository
from supervisor.utils.dt import utcnow from supervisor.utils.dt import utcnow
@ -602,3 +603,17 @@ async def capture_exception() -> Mock:
"supervisor.utils.sentry.sentry_sdk.capture_exception" "supervisor.utils.sentry.sentry_sdk.capture_exception"
) as capture_exception: ) as capture_exception:
yield 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) trigger.append(True)
coresys.scheduler.register_task(test_task, 0.1, 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 assert len(trigger) > 1

View File

@ -10,7 +10,12 @@ import pytest
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.dbus.const import UnitActiveState 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.manager import MountManager
from supervisor.mounts.mount import Mount from supervisor.mounts.mount import Mount
from supervisor.resolution.const import ContextType, IssueType, SuggestionType from supervisor.resolution.const import ContextType, IssueType, SuggestionType
@ -506,3 +511,33 @@ async def test_reload_mounts(
assert mount.state == UnitActiveState.ACTIVE assert mount.state == UnitActiveState.ACTIVE
assert mount.failed_issue not in coresys.resolution.issues assert mount.failed_issue not in coresys.resolution.issues
assert not coresys.resolution.suggestions_for_issue(mount.failed_issue) 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, PartitionTable as PartitionTableService,
) )
# pylint: disable=protected-access
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
async def add_unusable_drive( async def add_unusable_drive(
@ -70,10 +68,8 @@ async def tests_datadisk_current(coresys: CoreSys):
["/dev/sdaaaa", "/dev/mmcblk1", "Generic-Flash-Disk-61BCDDB6"], ["/dev/sdaaaa", "/dev/mmcblk1", "Generic-Flash-Disk-61BCDDB6"],
ids=["non-existent", "unavailable drive by path", "unavailable drive by id"], 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.""" """Test datadisk move to non-existent or invalid devices."""
coresys.os._available = True
with pytest.raises( with pytest.raises(
HassOSDataDiskError, match=f"'{new_disk}' not a valid data disk target!" HassOSDataDiskError, match=f"'{new_disk}' not a valid data disk target!"
): ):
@ -112,13 +108,13 @@ async def test_datadisk_migrate(
coresys: CoreSys, coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
new_disk: str, new_disk: str,
os_available,
): ):
"""Test migrating data disk.""" """Test migrating data disk."""
datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"]
datadisk_service.ChangeDevice.calls.clear() datadisk_service.ChangeDevice.calls.clear()
logind_service: LogindService = all_dbus_services["logind"] logind_service: LogindService = all_dbus_services["logind"]
logind_service.Reboot.calls.clear() logind_service.Reboot.calls.clear()
coresys.os._available = True
with patch.object(Core, "shutdown") as shutdown: with patch.object(Core, "shutdown") as shutdown:
await coresys.os.datadisk.migrate_disk(new_disk) await coresys.os.datadisk.migrate_disk(new_disk)
@ -137,6 +133,7 @@ async def test_datadisk_migrate_mark_data_move(
coresys: CoreSys, coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
new_disk: str, new_disk: str,
os_available,
): ):
"""Test migrating data disk with os agent 1.5.0 or later.""" """Test migrating data disk with os agent 1.5.0 or later."""
datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] 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"}) all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"})
await all_dbus_services["os_agent"].ping() await all_dbus_services["os_agent"].ping()
coresys.os._available = True
with patch.object(Core, "shutdown") as shutdown: with patch.object(Core, "shutdown") as shutdown:
await coresys.os.datadisk.migrate_disk(new_disk) 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( async def test_datadisk_migrate_too_small(
coresys: CoreSys, coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
os_available,
): ):
"""Test migration stops and exits if new partition is too small.""" """Test migration stops and exits if new partition is too small."""
datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] 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"}) all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"})
await all_dbus_services["os_agent"].ping() await all_dbus_services["os_agent"].ping()
coresys.os._available = True
with pytest.raises( with pytest.raises(
HassOSDataDiskError, HassOSDataDiskError,
@ -214,6 +210,7 @@ async def test_datadisk_migrate_too_small(
async def test_datadisk_migrate_multiple_external_data_disks( async def test_datadisk_migrate_multiple_external_data_disks(
coresys: CoreSys, coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
os_available,
): ):
"""Test migration stops when another hassos-data-external partition detected.""" """Test migration stops when another hassos-data-external partition detected."""
datadisk_service: DataDiskService = all_dbus_services["agent_datadisk"] 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 = replace(
sdb1_filesystem_service.fixture, MountPoints=[] sdb1_filesystem_service.fixture, MountPoints=[]
) )
coresys.os._available = True
with pytest.raises( with pytest.raises(
HassOSDataDiskError, HassOSDataDiskError,
@ -241,6 +237,7 @@ async def test_datadisk_migrate_multiple_external_data_disks(
async def test_datadisk_migrate_between_external_renames( async def test_datadisk_migrate_between_external_renames(
coresys: CoreSys, coresys: CoreSys,
all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]], all_dbus_services: dict[str, DBusServiceMock | dict[str, DBusServiceMock]],
os_available,
): ):
"""Test migration from one external data disk to another renames the original.""" """Test migration from one external data disk to another renames the original."""
sdb1_partition_service: PartitionService = all_dbus_services["udisks2_partition"][ 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"}) all_dbus_services["os_agent"].emit_properties_changed({"Version": "1.5.0"})
await all_dbus_services["os_agent"].ping() await all_dbus_services["os_agent"].ping()
coresys.os._available = True
await coresys.os.datadisk.migrate_disk("Generic-Flash-Disk-61BCDDB6") await coresys.os.datadisk.migrate_disk("Generic-Flash-Disk-61BCDDB6")

View File

@ -1,5 +1,5 @@
"""Test evaluation base.""" """Test evaluation base."""
# pylint: disable=import-error,protected-access # pylint: disable=import-error
from unittest.mock import patch from unittest.mock import patch
from supervisor.const import CoreState from supervisor.const import CoreState
@ -24,7 +24,6 @@ async def test_evaluation(coresys: CoreSys):
coresys.resolution.unsupported.clear() coresys.resolution.unsupported.clear()
coresys.docker.info.cgroup = CGROUP_V2_VERSION coresys.docker.info.cgroup = CGROUP_V2_VERSION
coresys.os._available = False
await cgroup_version() await cgroup_version()
assert cgroup_version.reason in coresys.resolution.unsupported assert cgroup_version.reason in coresys.resolution.unsupported
coresys.resolution.unsupported.clear() coresys.resolution.unsupported.clear()
@ -34,12 +33,11 @@ async def test_evaluation(coresys: CoreSys):
assert cgroup_version.reason not in coresys.resolution.unsupported 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.""" """Test evaluation with OS available."""
cgroup_version = EvaluateCGroupVersion(coresys) cgroup_version = EvaluateCGroupVersion(coresys)
coresys.core.state = CoreState.SETUP coresys.core.state = CoreState.SETUP
coresys.os._available = True
coresys.docker.info.cgroup = CGROUP_V2_VERSION coresys.docker.info.cgroup = CGROUP_V2_VERSION
await cgroup_version() await cgroup_version()
assert cgroup_version.reason not in coresys.resolution.unsupported assert cgroup_version.reason not in coresys.resolution.unsupported