From 34c394c3d1055e2f388d2d4f422e28cb41199ad7 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 1 May 2023 02:45:52 -0400 Subject: [PATCH] Add support for network mounts (#4269) * Add support for network mounts * Handle backups and save data * fix pylint issues --- .vscode/launch.json | 7 + supervisor/api/__init__.py | 27 +- supervisor/api/const.py | 2 + supervisor/api/mounts.py | 75 +++ supervisor/backups/backup.py | 6 +- supervisor/bootstrap.py | 14 + supervisor/config.py | 58 +- supervisor/core.py | 2 + supervisor/coresys.py | 21 + supervisor/data/syslog-identifiers.json | 1 + supervisor/dbus/const.py | 17 + supervisor/dbus/systemd.py | 66 ++- supervisor/dbus/udisks2/partition.py | 2 +- supervisor/exceptions.py | 43 +- supervisor/mounts/__init__.py | 1 + supervisor/mounts/const.py | 35 ++ supervisor/mounts/manager.py | 196 +++++++ supervisor/mounts/mount.py | 410 +++++++++++++ supervisor/mounts/validate.py | 89 +++ supervisor/resolution/const.py | 2 + supervisor/resolution/fixups/base.py | 3 +- .../resolution/fixups/mount_execute_reload.py | 46 ++ .../resolution/fixups/mount_execute_remove.py | 46 ++ supervisor/resolution/module.py | 5 + supervisor/utils/dbus.py | 2 +- tests/api/test_mounts.py | 182 ++++++ tests/backups/test_manager.py | 67 +++ tests/conftest.py | 23 + tests/dbus/test_systemd.py | 58 +- tests/dbus_service_mocks/systemd.py | 43 +- tests/dbus_service_mocks/systemd_unit.py | 550 ++++++++++++++++++ tests/mounts/__init__.py | 1 + tests/mounts/test_manager.py | 406 +++++++++++++ tests/mounts/test_mount.py | 459 +++++++++++++++ tests/mounts/test_validate.py | 107 ++++ .../fixup/test_mount_execute_reload.py | 49 ++ .../fixup/test_mount_execute_remove.py | 49 ++ tests/resolution/test_resolution_manager.py | 39 ++ 38 files changed, 3173 insertions(+), 36 deletions(-) create mode 100644 supervisor/api/mounts.py create mode 100644 supervisor/mounts/__init__.py create mode 100644 supervisor/mounts/const.py create mode 100644 supervisor/mounts/manager.py create mode 100644 supervisor/mounts/mount.py create mode 100644 supervisor/mounts/validate.py create mode 100644 supervisor/resolution/fixups/mount_execute_reload.py create mode 100644 supervisor/resolution/fixups/mount_execute_remove.py create mode 100644 tests/api/test_mounts.py create mode 100644 tests/dbus_service_mocks/systemd_unit.py create mode 100644 tests/mounts/__init__.py create mode 100644 tests/mounts/test_manager.py create mode 100644 tests/mounts/test_mount.py create mode 100644 tests/mounts/test_validate.py create mode 100644 tests/resolution/fixup/test_mount_execute_reload.py create mode 100644 tests/resolution/fixup/test_mount_execute_remove.py diff --git a/.vscode/launch.json b/.vscode/launch.json index 0e3f62e4f..dc112b56c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,6 +13,13 @@ "remoteRoot": "/usr/src/supervisor" } ] + }, + { + "name": "Debug Tests", + "type": "python", + "request": "test", + "console": "internalConsole", + "justMyCode": false } ] } diff --git a/supervisor/api/__init__.py b/supervisor/api/__init__.py index b64ba2d3d..334a929c9 100644 --- a/supervisor/api/__init__.py +++ b/supervisor/api/__init__.py @@ -23,6 +23,7 @@ from .host import APIHost from .ingress import APIIngress from .jobs import APIJobs from .middleware.security import SecurityMiddleware +from .mounts import APIMounts from .multicast import APIMulticast from .network import APINetwork from .observer import APIObserver @@ -81,20 +82,21 @@ class RestAPI(CoreSysAttributes): self._register_hardware() self._register_homeassistant() self._register_host() - self._register_root() + self._register_jobs() self._register_ingress() + self._register_mounts() self._register_multicast() self._register_network() self._register_observer() self._register_os() - self._register_jobs() self._register_panel() self._register_proxy() self._register_resolution() - self._register_services() - self._register_supervisor() - self._register_store() + self._register_root() self._register_security() + self._register_services() + self._register_store() + self._register_supervisor() await self.start() @@ -566,6 +568,21 @@ class RestAPI(CoreSysAttributes): ] ) + def _register_mounts(self) -> None: + """Register mounts endpoints.""" + api_mounts = APIMounts() + api_mounts.coresys = self.coresys + + self.webapp.add_routes( + [ + web.get("/mounts", api_mounts.info), + web.post("/mounts", api_mounts.create_mount), + web.put("/mounts/{mount}", api_mounts.update_mount), + web.delete("/mounts/{mount}", api_mounts.delete_mount), + web.post("/mounts/{mount}/reload", api_mounts.reload_mount), + ] + ) + def _register_store(self) -> None: """Register store endpoints.""" api_store = APIStore() diff --git a/supervisor/api/const.py b/supervisor/api/const.py index dc4e9119a..8f3f342e9 100644 --- a/supervisor/api/const.py +++ b/supervisor/api/const.py @@ -37,6 +37,7 @@ ATTR_LLMNR = "llmnr" ATTR_LLMNR_HOSTNAME = "llmnr_hostname" ATTR_MDNS = "mdns" ATTR_MODEL = "model" +ATTR_MOUNTS = "mounts" ATTR_MOUNT_POINTS = "mount_points" ATTR_PANEL_PATH = "panel_path" ATTR_POWER_LED = "power_led" @@ -50,4 +51,5 @@ ATTR_SYSFS = "sysfs" ATTR_TIME_DETECTED = "time_detected" ATTR_UPDATE_TYPE = "update_type" ATTR_USE_NTP = "use_ntp" +ATTR_USAGE = "usage" ATTR_VENDOR = "vendor" diff --git a/supervisor/api/mounts.py b/supervisor/api/mounts.py new file mode 100644 index 000000000..4e83c22ce --- /dev/null +++ b/supervisor/api/mounts.py @@ -0,0 +1,75 @@ +"""Inits file for supervisor mounts REST API.""" + +from typing import Any + +from aiohttp import web +import voluptuous as vol + +from ..const import ATTR_NAME, ATTR_STATE +from ..coresys import CoreSysAttributes +from ..exceptions import APIError +from ..mounts.mount import Mount +from ..mounts.validate import SCHEMA_MOUNT_CONFIG +from .const import ATTR_MOUNTS +from .utils import api_process, api_validate + + +class APIMounts(CoreSysAttributes): + """Handle REST API for mounting options.""" + + @api_process + async def info(self, request: web.Request) -> dict[str, Any]: + """Return MountManager info.""" + return { + ATTR_MOUNTS: [ + mount.to_dict() | {ATTR_STATE: mount.state} + for mount in self.sys_mounts.mounts + ] + } + + @api_process + async def create_mount(self, request: web.Request) -> None: + """Create a new mount in supervisor.""" + body = await api_validate(SCHEMA_MOUNT_CONFIG, request) + + if body[ATTR_NAME] in self.sys_mounts: + raise APIError(f"A mount already exists with name {body[ATTR_NAME]}") + + await self.sys_mounts.create_mount(Mount.from_dict(self.coresys, body)) + self.sys_mounts.save_data() + + @api_process + async def update_mount(self, request: web.Request) -> None: + """Update an existing mount in supervisor.""" + mount = request.match_info.get("mount") + name_schema = vol.Schema( + {vol.Optional(ATTR_NAME, default=mount): mount}, extra=vol.ALLOW_EXTRA + ) + body = await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request) + + if mount not in self.sys_mounts: + raise APIError(f"No mount exists with name {mount}") + + await self.sys_mounts.create_mount(Mount.from_dict(self.coresys, body)) + self.sys_mounts.save_data() + + @api_process + async def delete_mount(self, request: web.Request) -> None: + """Delete an existing mount in supervisor.""" + mount = request.match_info.get("mount") + + if mount not in self.sys_mounts: + raise APIError(f"No mount exists with name {mount}") + + await self.sys_mounts.remove_mount(mount) + self.sys_mounts.save_data() + + @api_process + async def reload_mount(self, request: web.Request) -> None: + """Reload an existing mount in supervisor.""" + mount = request.match_info.get("mount") + + if mount not in self.sys_mounts: + raise APIError(f"No mount exists with name {mount}") + + await self.sys_mounts.reload_mount(mount) diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index 67cb14352..044cc78c2 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -419,7 +419,11 @@ class Backup(CoreSysAttributes): atomic_contents_add( tar_file, origin_dir, - excludes=[], + excludes=[ + bound.bind_mount.local_where.as_posix() + for bound in self.sys_mounts.bound_mounts + if bound.bind_mount.local_where + ], arcname=".", ) diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 67389ab35..8b6c4de9c 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -36,6 +36,7 @@ from .ingress import Ingress from .jobs import JobManager from .misc.scheduler import Scheduler from .misc.tasks import Tasks +from .mounts.manager import MountManager from .os.manager import OSManager from .plugins.manager import PluginManager from .resolution.module import ResolutionManager @@ -80,6 +81,7 @@ async def initialize_coresys() -> CoreSys: coresys.scheduler = Scheduler(coresys) coresys.security = Security(coresys) coresys.bus = Bus(coresys) + coresys.mounts = MountManager(coresys) # diagnostics if coresys.config.diagnostics: @@ -200,6 +202,18 @@ def initialize_system(coresys: CoreSys) -> None: _LOGGER.debug("Creating Supervisor media folder at '%s'", config.path_media) config.path_media.mkdir() + # Mounts folder + if not config.path_mounts.is_dir(): + _LOGGER.debug("Creating Supervisor mounts folder at '%s'", config.path_mounts) + config.path_mounts.mkdir() + + # Emergency folder + if not config.path_emergency.is_dir(): + _LOGGER.debug( + "Creating Supervisor emergency folder at '%s'", config.path_emergency + ) + config.path_emergency.mkdir() + def migrate_system_env(coresys: CoreSys) -> None: """Cleanup some stuff after update.""" diff --git a/supervisor/config.py b/supervisor/config.py index 359ff2051..fd55a6c52 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -45,6 +45,8 @@ APPARMOR_CACHE = PurePath("apparmor/cache") DNS_DATA = PurePath("dns") AUDIO_DATA = PurePath("audio") MEDIA_DATA = PurePath("media") +MOUNTS_FOLDER = PurePath("mounts") +EMERGENCY_DATA = PurePath("emergency") DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat() @@ -186,7 +188,7 @@ class CoreConfig(FileConfiguration): @property def path_homeassistant(self) -> Path: """Return config path inside supervisor.""" - return Path(SUPERVISOR_DATA, HOMEASSISTANT_CONFIG) + return self.path_supervisor / HOMEASSISTANT_CONFIG @property def path_extern_ssl(self) -> str: @@ -196,22 +198,22 @@ class CoreConfig(FileConfiguration): @property def path_ssl(self) -> Path: """Return SSL path inside supervisor.""" - return Path(SUPERVISOR_DATA, HASSIO_SSL) + return self.path_supervisor / HASSIO_SSL @property def path_addons_core(self) -> Path: """Return git path for core Add-ons.""" - return Path(SUPERVISOR_DATA, ADDONS_CORE) + return self.path_supervisor / ADDONS_CORE @property def path_addons_git(self) -> Path: """Return path for Git Add-on.""" - return Path(SUPERVISOR_DATA, ADDONS_GIT) + return self.path_supervisor / ADDONS_GIT @property def path_addons_local(self) -> Path: """Return path for custom Add-ons.""" - return Path(SUPERVISOR_DATA, ADDONS_LOCAL) + return self.path_supervisor / ADDONS_LOCAL @property def path_extern_addons_local(self) -> PurePath: @@ -221,7 +223,7 @@ class CoreConfig(FileConfiguration): @property def path_addons_data(self) -> Path: """Return root Add-on data folder.""" - return Path(SUPERVISOR_DATA, ADDONS_DATA) + return self.path_supervisor / ADDONS_DATA @property def path_extern_addons_data(self) -> PurePath: @@ -231,7 +233,7 @@ class CoreConfig(FileConfiguration): @property def path_audio(self) -> Path: """Return root audio data folder.""" - return Path(SUPERVISOR_DATA, AUDIO_DATA) + return self.path_supervisor / AUDIO_DATA @property def path_extern_audio(self) -> PurePath: @@ -241,7 +243,7 @@ class CoreConfig(FileConfiguration): @property def path_tmp(self) -> Path: """Return Supervisor temp folder.""" - return Path(SUPERVISOR_DATA, TMP_DATA) + return self.path_supervisor / TMP_DATA @property def path_extern_tmp(self) -> PurePath: @@ -251,7 +253,7 @@ class CoreConfig(FileConfiguration): @property def path_backup(self) -> Path: """Return root backup data folder.""" - return Path(SUPERVISOR_DATA, BACKUP_DATA) + return self.path_supervisor / BACKUP_DATA @property def path_extern_backup(self) -> PurePath: @@ -261,17 +263,17 @@ class CoreConfig(FileConfiguration): @property def path_share(self) -> Path: """Return root share data folder.""" - return Path(SUPERVISOR_DATA, SHARE_DATA) + return self.path_supervisor / SHARE_DATA @property def path_apparmor(self) -> Path: """Return root Apparmor profile folder.""" - return Path(SUPERVISOR_DATA, APPARMOR_DATA) + return self.path_supervisor / APPARMOR_DATA @property def path_apparmor_cache(self) -> Path: """Return root Apparmor cache folder.""" - return Path(SUPERVISOR_DATA, APPARMOR_CACHE) + return self.path_supervisor / APPARMOR_CACHE @property def path_extern_apparmor(self) -> Path: @@ -296,12 +298,32 @@ class CoreConfig(FileConfiguration): @property def path_dns(self) -> Path: """Return dns path inside supervisor.""" - return Path(SUPERVISOR_DATA, DNS_DATA) + return self.path_supervisor / DNS_DATA @property def path_media(self) -> Path: """Return root media data folder.""" - return Path(SUPERVISOR_DATA, MEDIA_DATA) + return self.path_supervisor / MEDIA_DATA + + @property + def path_mounts(self) -> Path: + """Return root mounts folder.""" + return self.path_supervisor / MOUNTS_FOLDER + + @property + def path_extern_mounts(self) -> PurePath: + """Return mounts path external for Docker.""" + return self.path_extern_supervisor / MOUNTS_FOLDER + + @property + def path_emergency(self) -> Path: + """Return emergency data folder.""" + return self.path_supervisor / EMERGENCY_DATA + + @property + def path_extern_emergency(self) -> PurePath: + """Return emergency path external for Docker.""" + return self.path_extern_supervisor / EMERGENCY_DATA @property def path_extern_media(self) -> PurePath: @@ -326,3 +348,11 @@ class CoreConfig(FileConfiguration): return self._data[ATTR_ADDONS_CUSTOM_LIST].remove(repo) + + def local_to_extern_path(self, path: PurePath) -> PurePath: + """Translate a path relative to supervisor data in the container to its extern path.""" + return self.path_extern_supervisor / path.relative_to(self.path_supervisor) + + def extern_to_local_path(self, path: PurePath) -> Path: + """Translate a path relative to extern supervisor data to its path in the container.""" + return self.path_supervisor / path.relative_to(self.path_extern_supervisor) diff --git a/supervisor/core.py b/supervisor/core.py index 2b211e405..6ee1848f6 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -117,6 +117,8 @@ class Core(CoreSysAttributes): self.sys_host.load(), # Adjust timezone / time settings self._adjust_system_datetime(), + # Load mounts + self.sys_mounts.load(), # Start docker monitoring self.sys_docker.load(), # Load Plugins container diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 67e0efe06..f5f4f5a9e 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from .jobs import JobManager from .misc.scheduler import Scheduler from .misc.tasks import Tasks + from .mounts.manager import MountManager from .os.manager import OSManager from .plugins.manager import PluginManager from .resolution.module import ResolutionManager @@ -90,6 +91,7 @@ class CoreSys: self._jobs: JobManager | None = None self._security: Security | None = None self._bus: Bus | None = None + self._mounts: MountManager | None = None # Set default header for aiohttp self._websession._default_headers = MappingProxyType( @@ -475,6 +477,20 @@ class CoreSys: raise RuntimeError("job manager already set!") self._jobs = value + @property + def mounts(self) -> MountManager: + """Return mount manager object.""" + if self._mounts is None: + raise RuntimeError("mount manager not set!") + return self._mounts + + @mounts.setter + def mounts(self, value: MountManager) -> None: + """Set a mount manager object.""" + if self._mounts: + raise RuntimeError("mount manager already set!") + self._mounts = value + @property def machine(self) -> str | None: """Return machine type string.""" @@ -674,6 +690,11 @@ class CoreSysAttributes: """Return Job manager object.""" return self.coresys.jobs + @property + def sys_mounts(self) -> MountManager: + """Return mount manager object.""" + return self.coresys.mounts + def now(self) -> datetime: """Return now in local timezone.""" return self.coresys.now() diff --git a/supervisor/data/syslog-identifiers.json b/supervisor/data/syslog-identifiers.json index 49e7fc47e..67d146354 100644 --- a/supervisor/data/syslog-identifiers.json +++ b/supervisor/data/syslog-identifiers.json @@ -15,6 +15,7 @@ "hassos-supervisor", "hassos-zram", "kernel", + "mount", "os-agent", "rauc", "systemd", diff --git a/supervisor/dbus/const.py b/supervisor/dbus/const.py index e2ab1b673..7eef2d97e 100644 --- a/supervisor/dbus/const.py +++ b/supervisor/dbus/const.py @@ -81,6 +81,7 @@ DBUS_ATTR_CURRENT_DNS_SERVER = "CurrentDNSServer" DBUS_ATTR_CURRENT_DNS_SERVER_EX = "CurrentDNSServerEx" DBUS_ATTR_DEFAULT = "Default" DBUS_ATTR_DEPLOYMENT = "Deployment" +DBUS_ATTR_DESCRIPTION = "Description" DBUS_ATTR_DEVICE = "Device" DBUS_ATTR_DEVICE_INTERFACE = "Interface" DBUS_ATTR_DEVICE_NUMBER = "DeviceNumber" @@ -140,6 +141,7 @@ DBUS_ATTR_NUMBER = "Number" DBUS_ATTR_OFFSET = "Offset" DBUS_ATTR_OPERATING_SYSTEM_PRETTY_NAME = "OperatingSystemPrettyName" DBUS_ATTR_OPERATION = "Operation" +DBUS_ATTR_OPTIONS = "Options" DBUS_ATTR_PARSER_VERSION = "ParserVersion" DBUS_ATTR_PARTITIONS = "Partitions" DBUS_ATTR_POWER_LED = "PowerLED" @@ -172,8 +174,11 @@ DBUS_ATTR_UUID = "Uuid" DBUS_ATTR_VARIANT = "Variant" DBUS_ATTR_VENDOR = "Vendor" DBUS_ATTR_VERSION = "Version" +DBUS_ATTR_WHAT = "What" DBUS_ATTR_WWN = "WWN" +DBUS_ERR_SYSTEMD_NO_SUCH_UNIT = "org.freedesktop.systemd1.NoSuchUnit" + class RaucState(str, Enum): """Rauc slot states.""" @@ -334,3 +339,15 @@ class StartUnitMode(str, Enum): IGNORE_DEPENDENCIES = "ignore-dependencies" IGNORE_REQUIREMENTS = "ignore-requirements" ISOLATE = "isolate" + + +class UnitActiveState(str, Enum): + """Active state of a systemd unit.""" + + ACTIVE = "active" + ACTIVATING = "activating" + DEACTIVATING = "deactivating" + FAILED = "failed" + INACTIVE = "inactive" + MAINTENANCE = "maintenance" + RELOADING = "reloading" diff --git a/supervisor/dbus/systemd.py b/supervisor/dbus/systemd.py index 11eb69b1f..4ae5903a7 100644 --- a/supervisor/dbus/systemd.py +++ b/supervisor/dbus/systemd.py @@ -1,28 +1,71 @@ """Interface to Systemd over D-Bus.""" + +from functools import wraps import logging from dbus_fast import Variant from dbus_fast.aio.message_bus import MessageBus -from ..exceptions import DBusError, DBusInterfaceError +from ..exceptions import ( + DBusError, + DBusFatalError, + DBusInterfaceError, + DBusSystemdNoSuchUnit, +) from .const import ( DBUS_ATTR_FINISH_TIMESTAMP, DBUS_ATTR_FIRMWARE_TIMESTAMP_MONOTONIC, DBUS_ATTR_KERNEL_TIMESTAMP_MONOTONIC, DBUS_ATTR_LOADER_TIMESTAMP_MONOTONIC, DBUS_ATTR_USERSPACE_TIMESTAMP_MONOTONIC, + DBUS_ERR_SYSTEMD_NO_SUCH_UNIT, DBUS_IFACE_SYSTEMD_MANAGER, DBUS_NAME_SYSTEMD, DBUS_OBJECT_SYSTEMD, StartUnitMode, StopUnitMode, + UnitActiveState, ) -from .interface import DBusInterfaceProxy, dbus_property +from .interface import DBusInterface, DBusInterfaceProxy, dbus_property from .utils import dbus_connected _LOGGER: logging.Logger = logging.getLogger(__name__) +def systemd_errors(func): + """Wrap systemd dbus methods to handle its specific error types.""" + + @wraps(func) + async def wrapper(*args, **kwds): + try: + return await func(*args, **kwds) + except DBusFatalError as err: + if err.type == DBUS_ERR_SYSTEMD_NO_SUCH_UNIT: + # pylint: disable=raise-missing-from + raise DBusSystemdNoSuchUnit(str(err)) + # pylint: enable=raise-missing-from + raise err + + return wrapper + + +class SystemdUnit(DBusInterface): + """Systemd service unit.""" + + name: str = DBUS_NAME_SYSTEMD + bus_name: str = DBUS_NAME_SYSTEMD + + def __init__(self, object_path: str) -> None: + """Initialize object.""" + super().__init__() + self.object_path = object_path + + @dbus_connected + async def get_active_state(self) -> UnitActiveState: + """Get active state of the unit.""" + return await self.dbus.Unit.get_active_state() + + class Systemd(DBusInterfaceProxy): """Systemd function handler. @@ -76,21 +119,25 @@ class Systemd(DBusInterfaceProxy): await self.dbus.Manager.call_power_off() @dbus_connected + @systemd_errors async def start_unit(self, unit: str, mode: StartUnitMode) -> str: """Start a systemd service unit. Returns object path of job.""" return await self.dbus.Manager.call_start_unit(unit, mode.value) @dbus_connected + @systemd_errors async def stop_unit(self, unit: str, mode: StopUnitMode) -> str: """Stop a systemd service unit. Returns object path of job.""" return await self.dbus.Manager.call_stop_unit(unit, mode.value) @dbus_connected + @systemd_errors async def reload_unit(self, unit: str, mode: StartUnitMode) -> str: """Reload a systemd service unit. Returns object path of job.""" return await self.dbus.Manager.call_reload_or_restart_unit(unit, mode.value) @dbus_connected + @systemd_errors async def restart_unit(self, unit: str, mode: StartUnitMode) -> str: """Restart a systemd service unit. Returns object path of job.""" return await self.dbus.Manager.call_restart_unit(unit, mode.value) @@ -110,3 +157,18 @@ class Systemd(DBusInterfaceProxy): return await self.dbus.Manager.call_start_transient_unit( unit, mode.value, properties, [] ) + + @dbus_connected + @systemd_errors + async def reset_failed_unit(self, unit: str) -> None: + """Reset the failed state of a unit.""" + await self.dbus.Manager.call_reset_failed_unit(unit) + + @dbus_connected + @systemd_errors + async def get_unit(self, unit: str) -> SystemdUnit: + """Return systemd unit for unit name.""" + obj_path = await self.dbus.Manager.call_get_unit(unit) + unit = SystemdUnit(obj_path) + await unit.connect(self.dbus.bus) + return unit diff --git a/supervisor/dbus/udisks2/partition.py b/supervisor/dbus/udisks2/partition.py index fef1d51c0..462d3e34c 100644 --- a/supervisor/dbus/udisks2/partition.py +++ b/supervisor/dbus/udisks2/partition.py @@ -41,7 +41,7 @@ class UDisks2Partition(DBusInterfaceProxy): @property @dbus_property - def type_(self) -> str: + def type(self) -> str: """Partition type.""" return self.properties[DBUS_ATTR_TYPE] diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index c015cc0fd..5a45a0901 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -335,10 +335,6 @@ class DBusInterfaceSignalError(DBusInterfaceError): """D-Bus signal not defined.""" -class DBusFatalError(DBusError): - """D-Bus call going wrong.""" - - class DBusParseError(DBusError): """D-Bus parse error.""" @@ -347,6 +343,30 @@ class DBusTimeoutError(DBusError): """D-Bus call timed out.""" +class DBusFatalError(DBusError): + """D-Bus call going wrong. + + Type field contains specific error from D-Bus for interface specific errors (like Systemd ones). + """ + + def __init__( + self, + message: str | None = None, + logger: Callable[..., None] | None = None, + type_: str | None = None, + ) -> None: + """Initialize object.""" + super().__init__(message, logger) + self.type = type_ + + +# dbus/systemd + + +class DBusSystemdNoSuchUnit(DBusError): + """Systemd unit does not exist.""" + + # util/apparmor @@ -550,3 +570,18 @@ class SecurityError(HassioError): class SecurityJobError(SecurityError, JobException): """Raise on Security job error.""" + + +# Mount + + +class MountError(HassioError): + """Raise on an error related to mounting/unmounting.""" + + +class MountInvalidError(MountError): + """Raise on invalid mount attempt.""" + + +class MountNotFound(MountError): + """Raise on mount not found.""" diff --git a/supervisor/mounts/__init__.py b/supervisor/mounts/__init__.py new file mode 100644 index 000000000..b10b2c390 --- /dev/null +++ b/supervisor/mounts/__init__.py @@ -0,0 +1 @@ +"""Manage user mounts in supervisor.""" diff --git a/supervisor/mounts/const.py b/supervisor/mounts/const.py new file mode 100644 index 000000000..f248b6a70 --- /dev/null +++ b/supervisor/mounts/const.py @@ -0,0 +1,35 @@ +"""Constants for mount manager.""" + +from enum import Enum +from pathlib import PurePath + +FILE_CONFIG_MOUNTS = PurePath("mounts.json") + +ATTR_MOUNTS = "mounts" +ATTR_PATH = "path" +ATTR_SERVER = "server" +ATTR_SHARE = "share" +ATTR_USAGE = "usage" + + +class MountType(str, Enum): + """Mount type.""" + + BIND = "bind" + CIFS = "cifs" + NFS = "nfs" + + +class MountUsage(str, Enum): + """Mount usage.""" + + BACKUP = "backup" + MEDIA = "media" + + +class MountState(str, Enum): + """Mount state.""" + + ACTIVE = "active" + FAILED = "failed" + UNKNOWN = "unknown" diff --git a/supervisor/mounts/manager.py b/supervisor/mounts/manager.py new file mode 100644 index 000000000..1384facf1 --- /dev/null +++ b/supervisor/mounts/manager.py @@ -0,0 +1,196 @@ +"""Supervisor mount manager.""" + +import asyncio +from dataclasses import dataclass +import logging +from pathlib import PurePath + +from ..const import ATTR_NAME +from ..coresys import CoreSys, CoreSysAttributes +from ..dbus.const import UnitActiveState +from ..exceptions import MountError, MountNotFound +from ..resolution.const import ContextType, IssueType, SuggestionType +from ..utils.common import FileConfiguration +from ..utils.sentry import capture_exception +from .const import ATTR_MOUNTS, FILE_CONFIG_MOUNTS, MountUsage +from .mount import BindMount, Mount +from .validate import SCHEMA_MOUNTS_CONFIG + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class BoundMount: + """Mount bound to a directory in one of the shared volumes.""" + + mount: Mount + bind_mount: BindMount + emergency: bool + + +class MountManager(FileConfiguration, CoreSysAttributes): + """Mount manager for supervisor.""" + + def __init__(self, coresys: CoreSys): + """Initialize object.""" + super().__init__( + coresys.config.path_supervisor / FILE_CONFIG_MOUNTS, SCHEMA_MOUNTS_CONFIG + ) + + self.coresys: CoreSys = coresys + self._mounts: dict[str, Mount] = { + mount[ATTR_NAME]: Mount.from_dict(coresys, mount) + for mount in self._data[ATTR_MOUNTS] + } + self._bound_mounts: dict[str, BoundMount] = {} + + @property + def mounts(self) -> list[Mount]: + """Return list of mounts.""" + return list(self._mounts.values()) + + @property + def backup_mounts(self) -> list[Mount]: + """Return list of backup mounts.""" + return [mount for mount in self.mounts if mount.usage == MountUsage.BACKUP] + + @property + def media_mounts(self) -> list[Mount]: + """Return list of media mounts.""" + return [mount for mount in self.mounts if mount.usage == MountUsage.MEDIA] + + @property + def bound_mounts(self) -> list[BoundMount]: + """Return list of bound mounts and where else they have been bind mounted.""" + return list(self._bound_mounts.values()) + + def get(self, name: str) -> Mount: + """Get mount by name.""" + if name not in self._mounts: + raise MountNotFound(f"No mount exists with name '{name}'") + return self._mounts[name] + + def __contains__(self, item: Mount | str) -> bool: + """Return true if specified mount exists.""" + if isinstance(item, str): + return item in self._mounts + return item.name in self._mounts + + async def load(self) -> None: + """Mount all saved mounts.""" + if not self.mounts: + return + + _LOGGER.info("Initializing all user-configured mounts") + mounts = self.mounts + errors = await asyncio.gather( + *[mount.load() for mount in mounts], return_exceptions=True + ) + + for i in range(len(errors)): # pylint: disable=consider-using-enumerate + if not errors[i]: + continue + if not isinstance(errors[i], MountError): + capture_exception(errors[i]) + + self.sys_resolution.create_issue( + IssueType.MOUNT_FAILED, + ContextType.MOUNT, + reference=mounts[i].name, + suggestions=[ + SuggestionType.EXECUTE_RELOAD, + SuggestionType.EXECUTE_REMOVE, + ], + ) + + # Bind all media mounts to directories in media + if self.media_mounts: + await asyncio.wait([self._bind_media(mount) for mount in self.media_mounts]) + + async def create_mount(self, mount: Mount) -> None: + """Add/update a mount.""" + if mount.name in self._mounts: + _LOGGER.debug("Mount '%s' exists, unmounting then mounting from new config") + await self.remove_mount(mount.name) + + _LOGGER.info("Creating or updating mount: %s", mount.name) + self._mounts[mount.name] = mount + await mount.load() + + if mount.usage == MountUsage.MEDIA: + await self._bind_media(mount) + + async def remove_mount(self, name: str) -> None: + """Remove a mount.""" + if name not in self._mounts: + raise MountNotFound( + f"Cannot remove '{name}', no mount exists with that name" + ) + + _LOGGER.info("Removing mount: %s", name) + if name in self._bound_mounts: + await self._bound_mounts[name].bind_mount.unmount() + del self._bound_mounts[name] + + await self._mounts[name].unmount() + del self._mounts[name] + + async def reload_mount(self, name: str) -> None: + """Reload a mount to retry mounting with same config.""" + if name not in self._mounts: + raise MountNotFound( + f"Cannot reload '{name}', no mount exists with that name" + ) + + _LOGGER.info("Reloading mount: %s", name) + await self._mounts[name].reload() + + if (bound_mount := self._bound_mounts.get(name)) and bound_mount.emergency: + await self._bind_mount(bound_mount.mount, bound_mount.bind_mount.where) + + async def _bind_media(self, mount: Mount) -> None: + """Bind a media mount to media directory.""" + await self._bind_mount(mount, self.sys_config.path_extern_media / mount.name) + + async def _bind_mount(self, mount: Mount, where: PurePath) -> None: + """Bind mount to path, falling back on emergency if necessary. + + If where is in supervisor's data path, this will handle the target directory and + translate to a host path prior to mounting. Otherwise it will use where as is. + """ + if mount.name in self._bound_mounts: + await self._bound_mounts[mount.name].bind_mount.unmount() + + emergency = mount.state != UnitActiveState.ACTIVE + if not emergency: + path = mount.where + else: + _LOGGER.warning( + "Mount %s failed to mount, mounting read-only fallback for %s", + mount.name, + where.as_posix(), + ) + path = self.sys_config.path_emergency / mount.name + if not path.exists(): + path.mkdir(mode=0o444) + + path = self.sys_config.local_to_extern_path(path) + + self._bound_mounts[mount.name] = bound_mount = BoundMount( + mount=mount, + bind_mount=BindMount.create( + self.coresys, + name=f"{'emergency' if emergency else 'bind'}_{mount.name}", + path=path, + where=where, + ), + emergency=emergency, + ) + await bound_mount.bind_mount.load() + + def save_data(self) -> None: + """Store data to configuration file.""" + self._data[ATTR_MOUNTS] = [ + mount.to_dict(skip_secrets=False) for mount in self.mounts + ] + super().save_data() diff --git a/supervisor/mounts/mount.py b/supervisor/mounts/mount.py new file mode 100644 index 000000000..27539caab --- /dev/null +++ b/supervisor/mounts/mount.py @@ -0,0 +1,410 @@ +"""Network mounts in supervisor.""" + +from abc import ABC, abstractmethod +import asyncio +import logging +from pathlib import Path, PurePath + +from dbus_fast import Variant +from voluptuous import Coerce + +from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_PORT, ATTR_TYPE, ATTR_USERNAME +from ..coresys import CoreSys, CoreSysAttributes +from ..dbus.const import ( + DBUS_ATTR_DESCRIPTION, + DBUS_ATTR_OPTIONS, + DBUS_ATTR_TYPE, + DBUS_ATTR_WHAT, + StartUnitMode, + StopUnitMode, + UnitActiveState, +) +from ..dbus.systemd import SystemdUnit +from ..exceptions import DBusError, DBusSystemdNoSuchUnit, MountError, MountInvalidError +from ..utils.sentry import capture_exception +from .const import ATTR_PATH, ATTR_SERVER, ATTR_SHARE, ATTR_USAGE, MountType, MountUsage +from .validate import MountData + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +COERCE_MOUNT_TYPE = Coerce(MountType) +COERCE_MOUNT_USAGE = Coerce(MountUsage) + + +class Mount(CoreSysAttributes, ABC): + """A mount.""" + + def __init__(self, coresys: CoreSys, data: MountData) -> None: + """Initialize object.""" + super().__init__() + + self.coresys: CoreSys = coresys + self._data: MountData = data + self._unit: SystemdUnit | None = None + self._state: UnitActiveState | None = None + + @classmethod + def from_dict(cls, coresys: CoreSys, data: MountData) -> "Mount": + """Make dictionary into mount object.""" + if cls not in [Mount, NetworkMount]: + return cls(coresys, data) + + type_ = COERCE_MOUNT_TYPE(data[ATTR_TYPE]) + if type_ == MountType.CIFS: + return CIFSMount(coresys, data) + if type_ == MountType.NFS: + return NFSMount(coresys, data) + return BindMount(coresys, data) + + def to_dict(self, *, skip_secrets: bool = True) -> MountData: + """Return dictionary representation.""" + return MountData(name=self.name, type=self.type.value, usage=self.usage.value) + + @property + def name(self) -> str: + """Get name.""" + return self._data[ATTR_NAME] + + @property + def type(self) -> MountType: + """Get mount type.""" + return COERCE_MOUNT_TYPE(self._data[ATTR_TYPE]) + + @property + def usage(self) -> MountUsage | None: + """Get mount usage.""" + return ( + COERCE_MOUNT_USAGE(self._data[ATTR_USAGE]) + if ATTR_USAGE in self._data + else None + ) + + @property + @abstractmethod + def what(self) -> str: + """What to mount.""" + + @property + @abstractmethod + def where(self) -> PurePath: + """Where to mount (on host).""" + + @property + @abstractmethod + def options(self) -> list[str]: + """List of options to use to mount.""" + + @property + def description(self) -> str: + """Description of mount.""" + return f"Supervisor {self.type.value} mount: {self.name}" + + @property + def unit_name(self) -> str: + """Systemd unit name for mount.""" + return f"{self.where.as_posix()[1:].replace('/', '-')}.mount" + + @property + def unit(self) -> SystemdUnit | None: + """Get Systemd unit object for mount.""" + return self._unit + + @property + def state(self) -> UnitActiveState | None: + """Get state of mount.""" + return self._state + + @property + def local_where(self) -> Path | None: + """Return where this is mounted within supervisor container. + + This returns none if 'where' is not within supervisor's host data directory. + """ + return ( + self.sys_config.extern_to_local_path(self.where) + if self.where.is_relative_to(self.sys_config.path_extern_supervisor) + else None + ) + + async def load(self) -> None: + """Initialize object.""" + await self._update_await_activating() + + # If there's no mount unit, mount it to make one + if not self.unit: + await self.mount() + + # At this point any state besides active is treated as a failed mount, try to reload it + elif self.state != UnitActiveState.ACTIVE: + await self.reload() + + async def update(self) -> None: + """Update info about mount from dbus.""" + try: + self._unit = await self.sys_dbus.systemd.get_unit(self.unit_name) + except DBusSystemdNoSuchUnit: + self._unit = None + self._state = None + return + except DBusError as err: + capture_exception(err) + raise MountError(f"Could not get mount unit due to: {err!s}") from err + + try: + self._state = await self.unit.get_active_state() + except DBusError as err: + capture_exception(err) + raise MountError( + f"Could not get active state of mount due to: {err!s}" + ) from err + + async def _update_await_activating(self): + """Update info about mount from dbus. If 'activating' wait up to 30 seconds.""" + await self.update() + + # If we're still activating, give it up to 30 seconds to finish + if self.state == UnitActiveState.ACTIVATING: + _LOGGER.info( + "Mount %s still activating, waiting up to 30 seconds to complete", + self.name, + ) + for _ in range(3): + await asyncio.sleep(10) + await self.update() + if self.state != UnitActiveState.ACTIVATING: + break + + async def mount(self) -> None: + """Mount using systemd.""" + # If supervisor can see where it will mount, ensure there's an empty folder there + if self.local_where: + if not self.local_where.exists(): + _LOGGER.info( + "Creating folder for mount: %s", self.local_where.as_posix() + ) + self.local_where.mkdir(parents=True) + elif not self.local_where.is_dir(): + raise MountInvalidError( + f"Cannot mount {self.name} at {self.local_where.as_posix()} as it is not a directory", + _LOGGER.error, + ) + elif any(self.local_where.iterdir()): + raise MountInvalidError( + f"Cannot mount {self.name} at {self.local_where.as_posix()} because it is not empty", + _LOGGER.error, + ) + + try: + options = ( + [(DBUS_ATTR_OPTIONS, Variant("s", ",".join(self.options)))] + if self.options + else [] + ) + await self.sys_dbus.systemd.start_transient_unit( + self.unit_name, + StartUnitMode.FAIL, + options + + [ + (DBUS_ATTR_DESCRIPTION, Variant("s", self.description)), + (DBUS_ATTR_WHAT, Variant("s", self.what)), + (DBUS_ATTR_TYPE, Variant("s", self.type.value)), + ], + ) + except DBusError as err: + raise MountError( + f"Could not mount {self.name} due to: {err!s}", _LOGGER.error + ) from err + + await self._update_await_activating() + + if self.state != UnitActiveState.ACTIVE: + raise MountError( + f"Mounting {self.name} did not succeed. Check host logs for errors from mount or systemd unit {self.unit_name} for details.", + _LOGGER.error, + ) + + async def unmount(self) -> None: + """Unmount using systemd.""" + try: + await self.sys_dbus.systemd.stop_unit(self.unit_name, StopUnitMode.FAIL) + except DBusSystemdNoSuchUnit: + _LOGGER.info("Mount %s is not mounted, skipping unmount", self.name) + except DBusError as err: + raise MountError( + f"Could not unmount {self.name} due to: {err!s}", _LOGGER.error + ) from err + + self._unit = None + self._state = None + + async def reload(self) -> None: + """Reload or restart mount unit to re-mount.""" + try: + await self.sys_dbus.systemd.reload_unit(self.unit_name, StartUnitMode.FAIL) + except DBusSystemdNoSuchUnit: + _LOGGER.info( + "Mount %s is not mounted, mounting instead of reloading", self.name + ) + await self.mount() + return + except DBusError as err: + raise MountError( + f"Could not reload mount {self.name} due to: {err!s}", _LOGGER.error + ) from err + + await self._update_await_activating() + + if self.state != UnitActiveState.ACTIVE: + raise MountError( + f"Reloading {self.name} did not succeed. Check host logs for errors from mount or systemd unit {self.unit_name} for details.", + _LOGGER.error, + ) + + +class NetworkMount(Mount, ABC): + """A network mount.""" + + def to_dict(self, *, skip_secrets: bool = True) -> MountData: + """Return dictionary representation.""" + out = MountData(server=self.server, **super().to_dict()) + if self.port is not None: + out[ATTR_PORT] = self.port + return out + + @property + def server(self) -> str: + """Get server.""" + return self._data[ATTR_SERVER] + + @property + def port(self) -> int | None: + """Get port, returns none if using the protocol default.""" + return self._data.get(ATTR_PORT) + + @property + def where(self) -> PurePath: + """Where to mount.""" + return self.sys_config.path_extern_mounts / self.name + + @property + def options(self) -> list[str]: + """Options to use to mount.""" + return [f"port={self.port}"] if self.port else [] + + +class CIFSMount(NetworkMount): + """A CIFS type mount.""" + + def to_dict(self, *, skip_secrets: bool = True) -> MountData: + """Return dictionary representation.""" + out = MountData(share=self.share, **super().to_dict()) + if not skip_secrets and self.username is not None: + out[ATTR_USERNAME] = self.username + out[ATTR_PASSWORD] = self.password + return out + + @property + def share(self) -> str: + """Get share.""" + return self._data[ATTR_SHARE] + + @property + def username(self) -> str | None: + """Get username, returns none if auth is not used.""" + return self._data.get(ATTR_USERNAME) + + @property + def password(self) -> str | None: + """Get password, returns none if auth is not used.""" + return self._data.get(ATTR_PASSWORD) + + @property + def what(self) -> str: + """What to mount.""" + return f"//{self.server}/{self.share}" + + @property + def options(self) -> list[str]: + """Options to use to mount.""" + return ( + super().options + [f"username={self.username}", f"password={self.password}"] + if self.username + else [] + ) + + +class NFSMount(NetworkMount): + """An NFS type mount.""" + + def to_dict(self, *, skip_secrets: bool = True) -> MountData: + """Return dictionary representation.""" + return MountData(path=self.path.as_posix(), **super().to_dict()) + + @property + def path(self) -> PurePath: + """Get path.""" + return PurePath(self._data[ATTR_PATH]) + + @property + def what(self) -> str: + """What to mount.""" + return f"{self.server}:{self.path.as_posix()}" + + +class BindMount(Mount): + """A bind type mount.""" + + def __init__( + self, coresys: CoreSys, data: MountData, *, where: PurePath | None = None + ) -> None: + """Initialize object.""" + super().__init__(coresys, data) + self._where = where + + @staticmethod + def create( + coresys: CoreSys, + name: str, + path: Path, + usage: MountUsage | None = None, + where: PurePath | None = None, + ) -> "BindMount": + """Create a new bind mount instance.""" + return BindMount( + coresys, + MountData( + name=name, + type=MountType.BIND.value, + path=path.as_posix(), + usage=usage and usage.value, + ), + where=where, + ) + + def to_dict(self, *, skip_secrets: bool = True) -> MountData: + """Return dictionary representation.""" + return MountData(path=self.path.as_posix(), **super().to_dict()) + + @property + def path(self) -> PurePath: + """Get path.""" + return PurePath(self._data[ATTR_PATH]) + + @property + def what(self) -> str: + """What to mount.""" + return self.path.as_posix() + + @property + def where(self) -> PurePath: + """Where to mount.""" + return ( + self._where + if self._where + else self.sys_config.path_extern_mounts / self.name + ) + + @property + def options(self) -> list[str]: + """List of options to use to mount.""" + return [] diff --git a/supervisor/mounts/validate.py b/supervisor/mounts/validate.py new file mode 100644 index 000000000..945b398b6 --- /dev/null +++ b/supervisor/mounts/validate.py @@ -0,0 +1,89 @@ +"""Validation for mount manager.""" + +import re +from typing import TypedDict + +from typing_extensions import NotRequired +import voluptuous as vol + +from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_PORT, ATTR_TYPE, ATTR_USERNAME +from ..validate import network_port +from .const import ( + ATTR_MOUNTS, + ATTR_PATH, + ATTR_SERVER, + ATTR_SHARE, + ATTR_USAGE, + MountType, + MountUsage, +) + +RE_MOUNT_NAME = re.compile(r"^\w+$") +RE_PATH_PART = re.compile(r"^[^\\\/]+") +RE_MOUNT_OPTION = re.compile(r"^[^,=]+$") + +VALIDATE_NAME = vol.Match(RE_MOUNT_NAME) +VALIDATE_SERVER = vol.Match(RE_PATH_PART) +VALIDATE_SHARE = vol.Match(RE_PATH_PART) +VALIDATE_USERNAME = vol.Match(RE_MOUNT_OPTION) +VALIDATE_PASSWORD = vol.Match(RE_MOUNT_OPTION) + +_SCHEMA_BASE_MOUNT_CONFIG = vol.Schema( + { + vol.Required(ATTR_NAME): VALIDATE_NAME, + vol.Required(ATTR_TYPE): vol.In([MountType.CIFS.value, MountType.NFS.value]), + vol.Required(ATTR_USAGE): vol.In([u.value for u in MountUsage]), + }, + extra=vol.REMOVE_EXTRA, +) + +_SCHEMA_MOUNT_NETWORK = _SCHEMA_BASE_MOUNT_CONFIG.extend( + { + vol.Required(ATTR_SERVER): VALIDATE_SERVER, + vol.Optional(ATTR_PORT): network_port, + } +) + +SCHEMA_MOUNT_CIFS = _SCHEMA_MOUNT_NETWORK.extend( + { + vol.Required(ATTR_TYPE): MountType.CIFS.value, + vol.Required(ATTR_SHARE): VALIDATE_SHARE, + vol.Inclusive(ATTR_USERNAME, "basic_auth"): VALIDATE_USERNAME, + vol.Inclusive(ATTR_PASSWORD, "basic_auth"): VALIDATE_PASSWORD, + } +) + +SCHEMA_MOUNT_NFS = _SCHEMA_MOUNT_NETWORK.extend( + { + vol.Required(ATTR_TYPE): MountType.NFS.value, + vol.Required(ATTR_PATH): str, + } +) + +SCHEMA_MOUNT_CONFIG = vol.Any(SCHEMA_MOUNT_CIFS, SCHEMA_MOUNT_NFS) + +SCHEMA_MOUNTS_CONFIG = vol.Schema( + { + vol.Required(ATTR_MOUNTS, default=[]): [SCHEMA_MOUNT_CONFIG], + } +) + + +class MountData(TypedDict): + """Dictionary representation of mount.""" + + name: str + type: str + usage: NotRequired[str] + + # CIFS and NFS fields + server: NotRequired[str] + port: NotRequired[int] + + # CIFS fields + share: NotRequired[str] + username: NotRequired[str] + password: NotRequired[str] + + # NFS and Bind fields + path: NotRequired[str] diff --git a/supervisor/resolution/const.py b/supervisor/resolution/const.py index c5124ccd8..e52df08f5 100644 --- a/supervisor/resolution/const.py +++ b/supervisor/resolution/const.py @@ -21,6 +21,7 @@ class ContextType(str, Enum): ADDON = "addon" CORE = "core" DNS_SERVER = "dns_server" + MOUNT = "mount" OS = "os" PLUGIN = "plugin" SUPERVISOR = "supervisor" @@ -78,6 +79,7 @@ class IssueType(str, Enum): FREE_SPACE = "free_space" IPV4_CONNECTION_PROBLEM = "ipv4_connection_problem" MISSING_IMAGE = "missing_image" + MOUNT_FAILED = "mount_failed" MULTIPLE_DATA_DISKS = "multiple_data_disks" NO_CURRENT_BACKUP = "no_current_backup" PWNED = "pwned" diff --git a/supervisor/resolution/fixups/base.py b/supervisor/resolution/fixups/base.py index 085ee4ba1..560998fa8 100644 --- a/supervisor/resolution/fixups/base.py +++ b/supervisor/resolution/fixups/base.py @@ -40,7 +40,8 @@ class FixupBase(ABC, CoreSysAttributes): for issue in self.sys_resolution.issues_for_suggestion(fixing_suggestion): self.sys_resolution.dismiss_issue(issue) - self.sys_resolution.dismiss_suggestion(fixing_suggestion) + if fixing_suggestion in self.sys_resolution.suggestions: + self.sys_resolution.dismiss_suggestion(fixing_suggestion) @abstractmethod async def process_fixup(self, reference: str | None = None) -> None: diff --git a/supervisor/resolution/fixups/mount_execute_reload.py b/supervisor/resolution/fixups/mount_execute_reload.py new file mode 100644 index 000000000..f7621ea69 --- /dev/null +++ b/supervisor/resolution/fixups/mount_execute_reload.py @@ -0,0 +1,46 @@ +"""Helper to fix an issue with a mount by retrying it.""" + +import logging + +from ...coresys import CoreSys +from ...exceptions import MountNotFound +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> FixupBase: + """Check setup function.""" + return FixupMountExecuteReload(coresys) + + +class FixupMountExecuteReload(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: str | None = None) -> None: + """Attempt to remount using the same config to fix failure.""" + try: + await self.sys_mounts.reload_mount(reference) + except MountNotFound: + _LOGGER.warning("Can't find mount %s for fixup", reference) + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.EXECUTE_RELOAD + + @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 diff --git a/supervisor/resolution/fixups/mount_execute_remove.py b/supervisor/resolution/fixups/mount_execute_remove.py new file mode 100644 index 000000000..6d826d630 --- /dev/null +++ b/supervisor/resolution/fixups/mount_execute_remove.py @@ -0,0 +1,46 @@ +"""Helper to fix an issue with a mount by removing it.""" + +import logging + +from ...coresys import CoreSys +from ...exceptions import MountNotFound +from ..const import ContextType, IssueType, SuggestionType +from .base import FixupBase + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +def setup(coresys: CoreSys) -> FixupBase: + """Check setup function.""" + return FixupMountExecuteRemove(coresys) + + +class FixupMountExecuteRemove(FixupBase): + """Storage class for fixup.""" + + async def process_fixup(self, reference: str | None = None) -> None: + """Remove the failed mount.""" + try: + await self.sys_mounts.remove_mount(reference) + except MountNotFound: + _LOGGER.warning("Can't find mount %s for fixup", reference) + + @property + def suggestion(self) -> SuggestionType: + """Return a SuggestionType enum.""" + return SuggestionType.EXECUTE_REMOVE + + @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 diff --git a/supervisor/resolution/module.py b/supervisor/resolution/module.py index 441c5538e..e29a387f4 100644 --- a/supervisor/resolution/module.py +++ b/supervisor/resolution/module.py @@ -240,6 +240,11 @@ class ResolutionManager(FileConfiguration, CoreSysAttributes): WSEvent.ISSUE_REMOVED, attr.asdict(issue) ) + # Clean up any orphaned suggestions + for suggestion in self.suggestions_for_issue(issue): + if not self.issues_for_suggestion(suggestion): + self.dismiss_suggestion(suggestion) + def dismiss_unsupported(self, reason: Issue) -> None: """Dismiss a reason for unsupported.""" if reason not in self._unsupported: diff --git a/supervisor/utils/dbus.py b/supervisor/utils/dbus.py index e25832fc4..05bc70fa0 100644 --- a/supervisor/utils/dbus.py +++ b/supervisor/utils/dbus.py @@ -80,7 +80,7 @@ class DBus: return DBusNotConnectedError(err.text) if err.type == ErrorType.TIMEOUT: return DBusTimeoutError(err.text) - return DBusFatalError(err.text) + return DBusFatalError(err.text, type_=err.type) @staticmethod async def call_dbus( diff --git a/tests/api/test_mounts.py b/tests/api/test_mounts.py new file mode 100644 index 000000000..41cc587b4 --- /dev/null +++ b/tests/api/test_mounts.py @@ -0,0 +1,182 @@ +"""Test mounts API.""" + +from aiohttp.test_utils import TestClient +import pytest + +from supervisor.coresys import CoreSys +from supervisor.mounts.mount import Mount + +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService + + +@pytest.fixture(name="mount") +async def fixture_mount(coresys: CoreSys, tmp_supervisor_data, path_extern) -> Mount: + """Add an initial mount and load mounts.""" + mount = Mount.from_dict( + coresys, + { + "name": "backup_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "backups", + }, + ) + coresys.mounts._mounts = {"backup_test": mount} # pylint: disable=protected-access + await coresys.mounts.load() + yield mount + + +async def test_api_mounts_info(api_client: TestClient): + """Test mounts info api.""" + resp = await api_client.get("/mounts") + result = await resp.json() + + assert result["data"]["mounts"] == [] + + +async def test_api_create_mount( + api_client: TestClient, coresys: CoreSys, tmp_supervisor_data, path_extern +): + """Test creating a mount via API.""" + resp = await api_client.post( + "/mounts", + json={ + "name": "backup_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "backups", + }, + ) + result = await resp.json() + assert result["result"] == "ok" + + resp = await api_client.get("/mounts") + result = await resp.json() + + assert result["data"]["mounts"] == [ + { + "name": "backup_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "backups", + "state": "active", + } + ] + coresys.mounts.save_data.assert_called_once() + + +async def test_api_create_error_mount_exists(api_client: TestClient, mount): + """Test create mount API errors when mount exists.""" + 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"] == "A mount already exists with name backup_test" + + +async def test_api_update_mount(api_client: TestClient, coresys: CoreSys, mount): + """Test updating a mount via API.""" + resp = await api_client.put( + "/mounts/backup_test", + json={ + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "new_backups", + }, + ) + result = await resp.json() + assert result["result"] == "ok" + + resp = await api_client.get("/mounts") + result = await resp.json() + + assert result["data"]["mounts"] == [ + { + "name": "backup_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "new_backups", + "state": "active", + } + ] + coresys.mounts.save_data.assert_called_once() + + +async def test_api_update_error_mount_missing(api_client: TestClient): + """Test update mount API errors when mount does not exist.""" + resp = await api_client.put( + "/mounts/backup_test", + json={ + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "new_backups", + }, + ) + assert resp.status == 400 + result = await resp.json() + assert result["result"] == "error" + assert result["message"] == "No mount exists with name backup_test" + + +async def test_api_reload_mount( + api_client: TestClient, all_dbus_services: dict[str, DBusServiceMock], mount +): + """Test reloading a mount via API.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.ReloadOrRestartUnit.calls.clear() + + resp = await api_client.post("/mounts/backup_test/reload") + result = await resp.json() + assert result["result"] == "ok" + + assert systemd_service.ReloadOrRestartUnit.calls == [ + ("mnt-data-supervisor-mounts-backup_test.mount", "fail") + ] + + +async def test_api_reload_error_mount_missing(api_client: TestClient): + """Test reload mount API errors when mount does not exist.""" + resp = await api_client.post("/mounts/backup_test/reload") + assert resp.status == 400 + result = await resp.json() + assert result["result"] == "error" + assert result["message"] == "No mount exists with name backup_test" + + +async def test_api_delete_mount(api_client: TestClient, coresys: CoreSys, mount): + """Test deleting a mount via API.""" + resp = await api_client.delete("/mounts/backup_test") + result = await resp.json() + assert result["result"] == "ok" + + resp = await api_client.get("/mounts") + result = await resp.json() + + assert result["data"]["mounts"] == [] + + coresys.mounts.save_data.assert_called_once() + + +async def test_api_delete_error_mount_missing(api_client: TestClient): + """Test delete mount API errors when mount does not exist.""" + resp = await api_client.delete("/mounts/backup_test") + assert resp.status == 400 + result = await resp.json() + assert result["result"] == "error" + assert result["message"] == "No mount exists with name backup_test" diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index 74f91280f..bdf74bd1a 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -1,15 +1,23 @@ """Test BackupManager class.""" +from shutil import rmtree from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch +from dbus_fast import DBusError + from supervisor.addons.addon import Addon +from supervisor.backups.backup import Backup from supervisor.backups.const import BackupType from supervisor.backups.manager import BackupManager from supervisor.const import FOLDER_HOMEASSISTANT, FOLDER_SHARE, CoreState from supervisor.coresys import CoreSys from supervisor.exceptions import AddonsError, DockerError +from supervisor.homeassistant.core import HomeAssistantCore +from supervisor.mounts.mount import Mount from tests.const import TEST_ADDON_SLUG +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService async def test_do_backup_full(coresys: CoreSys, backup_mock, install_addon_ssh): @@ -354,3 +362,62 @@ async def test_restore_error( await coresys.backups.do_restore_full(backup_instance) capture_exception.assert_called_once_with(err) + + +async def test_backup_media_with_mounts( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data, + path_extern, +): + """Test backing up media folder with mounts.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.response_get_unit = [ + DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"), + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + DBusError("org.freedesktop.systemd1.NoSuchUnit", "error"), + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ] + + # Make some normal test files + (test_file_1 := coresys.config.path_media / "test.txt").touch() + (test_dir := coresys.config.path_media / "test").mkdir() + (test_file_2 := coresys.config.path_media / "test" / "inner.txt").touch() + + # Add a media mount + await coresys.mounts.load() + await coresys.mounts.create_mount( + Mount.from_dict( + coresys, + { + "name": "media_test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "test", + }, + ) + ) + assert (mount_dir := coresys.config.path_media / "media_test").is_dir() + + # Make a partial backup + coresys.core.state = CoreState.RUNNING + coresys.hardware.disk.get_disk_free_space = lambda x: 5000 + backup: Backup = await coresys.backups.do_backup_partial("test", folders=["media"]) + + # Remove the mount and wipe the media folder + await coresys.mounts.remove_mount("media_test") + rmtree(coresys.config.path_media) + coresys.config.path_media.mkdir() + + # Restore the backup and check that only the test files we made returned + async def mock_async_true(*args, **kwargs): + return True + + with patch.object(HomeAssistantCore, "is_running", new=mock_async_true): + await coresys.backups.do_restore_partial(backup, folders=["media"]) + + assert test_file_1.exists() + assert test_dir.is_dir() + assert test_file_2.exists() + assert not mount_dir.exists() diff --git a/tests/conftest.py b/tests/conftest.py index dbe08cd22..b76d2e3a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,6 +67,13 @@ async def mock_async_return_true() -> bool: return True +@pytest.fixture +async def path_extern() -> None: + """Set external path env for tests.""" + os.environ["SUPERVISOR_SHARE"] = "/mnt/data/supervisor" + yield + + @pytest.fixture def docker() -> DockerAPI: """Mock DockerAPI.""" @@ -272,6 +279,7 @@ async def fixture_all_dbus_services( "rauc": None, "resolved": None, "systemd": None, + "systemd_unit": None, "timedate": None, }, dbus_session_bus, @@ -298,6 +306,7 @@ async def coresys( coresys_obj._resolution.save_data = MagicMock() coresys_obj._addons.data.save_data = MagicMock() coresys_obj._store.save_data = MagicMock() + coresys_obj._mounts.save_data = MagicMock() # Mock test client coresys_obj.arch._default_arch = "amd64" @@ -352,6 +361,20 @@ async def coresys( await coresys_obj.websession.close() +@pytest.fixture +async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path: + """Patch supervisor data to be tmp_path.""" + with patch.object( + su_config.CoreConfig, "path_supervisor", new=PropertyMock(return_value=tmp_path) + ): + coresys.config.path_emergency.mkdir() + coresys.config.path_media.mkdir() + coresys.config.path_mounts.mkdir() + coresys.config.path_backup.mkdir() + coresys.config.path_tmp.mkdir() + yield tmp_path + + @pytest.fixture async def journald_gateway() -> MagicMock: """Mock logs control.""" diff --git a/tests/dbus/test_systemd.py b/tests/dbus/test_systemd.py index 908519160..3a7b1bac2 100644 --- a/tests/dbus/test_systemd.py +++ b/tests/dbus/test_systemd.py @@ -1,12 +1,12 @@ """Test hostname dbus interface.""" # pylint: disable=import-error -from dbus_fast import Variant +from dbus_fast import DBusError, Variant from dbus_fast.aio.message_bus import MessageBus import pytest -from supervisor.dbus.const import StartUnitMode, StopUnitMode +from supervisor.dbus.const import StartUnitMode, StopUnitMode, UnitActiveState from supervisor.dbus.systemd import Systemd -from supervisor.exceptions import DBusNotConnectedError +from supervisor.exceptions import DBusNotConnectedError, DBusSystemdNoSuchUnit from tests.common import mock_dbus_services from tests.dbus_service_mocks.systemd import Systemd as SystemdService @@ -192,3 +192,55 @@ async def test_start_transient_unit( [], ) ] + + +async def test_reset_failed_unit( + systemd_service: SystemdService, dbus_session_bus: MessageBus +): + """Test resetting a failed unit.""" + systemd_service.ResetFailedUnit.calls.clear() + systemd = Systemd() + + with pytest.raises(DBusNotConnectedError): + await systemd.reset_failed_unit("tmp-test.mount") + + await systemd.connect(dbus_session_bus) + + assert await systemd.reset_failed_unit("tmp-test.mount") is None + assert systemd_service.ResetFailedUnit.calls == [("tmp-test.mount",)] + + +async def test_get_unit(systemd_service: SystemdService, dbus_session_bus: MessageBus): + """Test getting job ID for unit.""" + await mock_dbus_services( + {"systemd_unit": "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount"}, + dbus_session_bus, + ) + systemd_service.GetUnit.calls.clear() + systemd = Systemd() + + with pytest.raises(DBusNotConnectedError): + await systemd.get_unit("tmp-test.mount") + + await systemd.connect(dbus_session_bus) + + unit = await systemd.get_unit("tmp-test.mount") + assert unit.bus_name == "org.freedesktop.systemd1" + assert unit.object_path == "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + assert await unit.get_active_state() == UnitActiveState.ACTIVE + assert systemd_service.GetUnit.calls == [("tmp-test.mount",)] + + +async def test_get_unit_not_found( + systemd_service: SystemdService, dbus_session_bus: MessageBus +): + """Test error for non-existent unit name.""" + systemd_service.response_get_unit = DBusError( + "org.freedesktop.systemd1.NoSuchUnit", "error" + ) + + systemd = Systemd() + await systemd.connect(dbus_session_bus) + + with pytest.raises(DBusSystemdNoSuchUnit): + await systemd.get_unit("error.mount") diff --git a/tests/dbus_service_mocks/systemd.py b/tests/dbus_service_mocks/systemd.py index 2e8526c96..a70097be2 100644 --- a/tests/dbus_service_mocks/systemd.py +++ b/tests/dbus_service_mocks/systemd.py @@ -1,5 +1,6 @@ """Mock of systemd dbus service.""" +from dbus_fast import DBusError from dbus_fast.service import PropertyAccess, dbus_property from .base import DBusServiceMock, dbus_method @@ -12,7 +13,7 @@ def setup(object_path: str | None = None) -> DBusServiceMock: return Systemd() -# pylint: disable=invalid-name,missing-function-docstring +# pylint: disable=invalid-name,missing-function-docstring,raising-bad-type class Systemd(DBusServiceMock): @@ -29,6 +30,16 @@ class Systemd(DBusServiceMock): reboot_watchdog_usec = 600000000 kexec_watchdog_usec = 0 service_watchdogs = True + response_get_unit: dict[str, list[str | DBusError]] | list[ + str | DBusError + ] | str | DBusError = "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + response_stop_unit: str | DBusError = "/org/freedesktop/systemd1/job/7623" + response_reload_or_restart_unit: str | DBusError = ( + "/org/freedesktop/systemd1/job/7623" + ) + response_start_transient_unit: str | DBusError = ( + "/org/freedesktop/systemd1/job/7623" + ) @dbus_property(access=PropertyAccess.READ) def Version(self) -> "s": @@ -652,12 +663,16 @@ class Systemd(DBusServiceMock): @dbus_method() def StopUnit(self, name: "s", mode: "s") -> "o": """Stop a service unit.""" - return "/org/freedesktop/systemd1/job/7623" + if isinstance(self.response_stop_unit, DBusError): + raise self.response_stop_unit + return self.response_stop_unit @dbus_method() def ReloadOrRestartUnit(self, name: "s", mode: "s") -> "o": """Reload or restart a service unit.""" - return "/org/freedesktop/systemd1/job/7623" + if isinstance(self.response_reload_or_restart_unit, DBusError): + raise self.response_reload_or_restart_unit + return self.response_reload_or_restart_unit @dbus_method() def RestartUnit(self, name: "s", mode: "s") -> "o": @@ -669,7 +684,27 @@ class Systemd(DBusServiceMock): self, name: "s", mode: "s", properties: "a(sv)", aux: "a(sa(sv))" ) -> "o": """Start a transient service unit.""" - return "/org/freedesktop/systemd1/job/7623" + if isinstance(self.response_start_transient_unit, DBusError): + raise self.response_start_transient_unit + return self.response_start_transient_unit + + @dbus_method() + def ResetFailedUnit(self, name: "s") -> None: + """Reset a failed unit.""" + + @dbus_method() + def GetUnit(self, name: "s") -> "s": + """Get unit.""" + if isinstance(self.response_get_unit, dict): + unit = self.response_get_unit[name].pop(0) + elif isinstance(self.response_get_unit, list): + unit = self.response_get_unit.pop(0) + else: + unit = self.response_get_unit + + if isinstance(unit, DBusError): + raise unit + return unit @dbus_method() def ListUnits( diff --git a/tests/dbus_service_mocks/systemd_unit.py b/tests/dbus_service_mocks/systemd_unit.py new file mode 100644 index 000000000..247f07d70 --- /dev/null +++ b/tests/dbus_service_mocks/systemd_unit.py @@ -0,0 +1,550 @@ +"""Mock of systemd unit dbus service.""" + +from dbus_fast.service import PropertyAccess, dbus_property + +from .base import DBusServiceMock + +BUS_NAME = "org.freedesktop.systemd1" +DEFAULT_OBJECT_PATH = "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + + +def setup(object_path: str | None = None) -> DBusServiceMock: + """Create dbus mock object.""" + return SystemdUnit(object_path or DEFAULT_OBJECT_PATH) + + +# pylint: disable=invalid-name,missing-function-docstring + + +class SystemdUnit(DBusServiceMock): + """Systemd Unit mock. + + gdbus introspect --system --dest org.freedesktop.systemd1 --object-path /org/freedesktop/systemd1/unit/tmp_2dyellow_2emount + """ + + interface = "org.freedesktop.systemd1.Unit" + active_state: list[str] | str = "active" + + def __init__(self, object_path: str): + """Initialize object.""" + super().__init__() + self.object_path = object_path + + @dbus_property(access=PropertyAccess.READ) + def Id(self) -> "s": + """Get Id.""" + return "tmp-yellow.mount" + + @dbus_property(access=PropertyAccess.READ) + def Names(self) -> "as": + """Get Names.""" + return ["tmp-yellow.mount"] + + @dbus_property(access=PropertyAccess.READ) + def Following(self) -> "s": + """Get Following.""" + return "" + + @dbus_property(access=PropertyAccess.READ) + def Requires(self) -> "as": + """Get Requires.""" + return ["system.slice", "tmp.mount"] + + @dbus_property(access=PropertyAccess.READ) + def Requisite(self) -> "as": + """Get Requisite.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def Wants(self) -> "as": + """Get Wants.""" + return ["network-online.target"] + + @dbus_property(access=PropertyAccess.READ) + def BindsTo(self) -> "as": + """Get BindsTo.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def PartOf(self) -> "as": + """Get PartOf.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def Upholds(self) -> "as": + """Get Upholds.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def RequiredBy(self) -> "as": + """Get RequiredBy.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def RequisiteOf(self) -> "as": + """Get RequisiteOf.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def WantedBy(self) -> "as": + """Get WantedBy.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def BoundBy(self) -> "as": + """Get BoundBy.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def UpheldBy(self) -> "as": + """Get UpheldBy.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def ConsistsOf(self) -> "as": + """Get ConsistsOf.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def Conflicts(self) -> "as": + """Get Conflicts.""" + return ["umount.target"] + + @dbus_property(access=PropertyAccess.READ) + def ConflictedBy(self) -> "as": + """Get ConflictedBy.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def Before(self) -> "as": + """Get Before.""" + return ["umount.target", "remote-fs.target"] + + @dbus_property(access=PropertyAccess.READ) + def After(self) -> "as": + """Get After.""" + return [ + "systemd-journald.socket", + "system.slice", + "remote-fs-pre.target", + "network-online.target", + "-.mount", + "network.target", + "tmp.mount", + ] + + @dbus_property(access=PropertyAccess.READ) + def OnSuccess(self) -> "as": + """Get OnSuccess.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def OnSuccessOf(self) -> "as": + """Get OnSuccessOf.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def OnFailure(self) -> "as": + """Get OnFailure.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def OnFailureOf(self) -> "as": + """Get OnFailureOf.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def Triggers(self) -> "as": + """Get Triggers.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def TriggeredBy(self) -> "as": + """Get TriggeredBy.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def PropagatesReloadTo(self) -> "as": + """Get PropagatesReloadTo.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def ReloadPropagatedFrom(self) -> "as": + """Get ReloadPropagatedFrom.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def PropagatesStopTo(self) -> "as": + """Get PropagatesStopTo.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def StopPropagatedFrom(self) -> "as": + """Get StopPropagatedFrom.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def JoinsNamespaceOf(self) -> "as": + """Get JoinsNamespaceOf.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def SliceOf(self) -> "as": + """Get SliceOf.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def RequiresMountsFor(self) -> "as": + """Get RequiresMountsFor.""" + return ["/tmp"] + + @dbus_property(access=PropertyAccess.READ) + def Documentation(self) -> "as": + """Get Documentation.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def Description(self) -> "s": + """Get Description.""" + return "/tmp/yellow" + + @dbus_property(access=PropertyAccess.READ) + def AccessSELinuxContext(self) -> "s": + """Get AccessSELinuxContext.""" + return "" + + @dbus_property(access=PropertyAccess.READ) + def LoadState(self) -> "s": + """Get LoadState.""" + return "loaded" + + @dbus_property(access=PropertyAccess.READ) + def ActiveState(self) -> "s": + """Get ActiveState.""" + if isinstance(self.active_state, list): + return self.active_state.pop(0) + return self.active_state + + @dbus_property(access=PropertyAccess.READ) + def FreezerState(self) -> "s": + """Get FreezerState.""" + return "running" + + @dbus_property(access=PropertyAccess.READ) + def SubState(self) -> "s": + """Get SubState.""" + return "mounted" + + @dbus_property(access=PropertyAccess.READ) + def FragmentPath(self) -> "s": + """Get FragmentPath.""" + return "/run/systemd/transient/tmp-yellow.mount" + + @dbus_property(access=PropertyAccess.READ) + def SourcePath(self) -> "s": + """Get SourcePath.""" + return "" + + @dbus_property(access=PropertyAccess.READ) + def DropInPaths(self) -> "as": + """Get DropInPaths.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def UnitFileState(self) -> "s": + """Get UnitFileState.""" + return "transient" + + @dbus_property(access=PropertyAccess.READ) + def UnitFilePreset(self) -> "s": + """Get UnitFilePreset.""" + return "enabled" + + @dbus_property(access=PropertyAccess.READ) + def StateChangeTimestamp(self) -> "t": + """Get StateChangeTimestamp.""" + return 1682012447583854 + + @dbus_property(access=PropertyAccess.READ) + def StateChangeTimestampMonotonic(self) -> "t": + """Get StateChangeTimestampMonotonic.""" + return 411597359174 + + @dbus_property(access=PropertyAccess.READ) + def InactiveExitTimestamp(self) -> "t": + """Get InactiveExitTimestamp.""" + return 1682010434373271 + + @dbus_property(access=PropertyAccess.READ) + def InactiveExitTimestampMonotonic(self) -> "t": + """Get InactiveExitTimestampMonotonic.""" + return 409584148592 + + @dbus_property(access=PropertyAccess.READ) + def ActiveEnterTimestamp(self) -> "t": + """Get ActiveEnterTimestamp.""" + return 1682010434467137 + + @dbus_property(access=PropertyAccess.READ) + def ActiveEnterTimestampMonotonic(self) -> "t": + """Get ActiveEnterTimestampMonotonic.""" + return 409584242457 + + @dbus_property(access=PropertyAccess.READ) + def ActiveExitTimestamp(self) -> "t": + """Get ActiveExitTimestamp.""" + return 0 + + @dbus_property(access=PropertyAccess.READ) + def ActiveExitTimestampMonotonic(self) -> "t": + """Get ActiveExitTimestampMonotonic.""" + return 0 + + @dbus_property(access=PropertyAccess.READ) + def InactiveEnterTimestamp(self) -> "t": + """Get InactiveEnterTimestamp.""" + return 1682010285903114 + + @dbus_property(access=PropertyAccess.READ) + def InactiveEnterTimestampMonotonic(self) -> "t": + """Get InactiveEnterTimestampMonotonic.""" + return 409435678436 + + @dbus_property(access=PropertyAccess.READ) + def CanStart(self) -> "b": + """Get CanStart.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def CanStop(self) -> "b": + """Get CanStop.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def CanReload(self) -> "b": + """Get CanReload.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def CanIsolate(self) -> "b": + """Get CanIsolate.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def CanClean(self) -> "as": + """Get CanClean.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def CanFreeze(self) -> "b": + """Get CanFreeze.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def Job(self) -> "(uo)": + """Get Job.""" + return (0, "/") + + @dbus_property(access=PropertyAccess.READ) + def StopWhenUnneeded(self) -> "b": + """Get StopWhenUnneeded.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def RefuseManualStart(self) -> "b": + """Get RefuseManualStart.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def RefuseManualStop(self) -> "b": + """Get RefuseManualStop.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def AllowIsolate(self) -> "b": + """Get AllowIsolate.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def DefaultDependencies(self) -> "b": + """Get DefaultDependencies.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def OnSuccessJobMode(self) -> "s": + """Get OnSuccessJobMode.""" + return "fail" + + @dbus_property(access=PropertyAccess.READ) + def OnFailureJobMode(self) -> "s": + """Get OnFailureJobMode.""" + return "replace" + + @dbus_property(access=PropertyAccess.READ) + def IgnoreOnIsolate(self) -> "b": + """Get IgnoreOnIsolate.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def NeedDaemonReload(self) -> "b": + """Get NeedDaemonReload.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def Markers(self) -> "as": + """Get Markers.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def JobTimeoutUSec(self) -> "t": + """Get JobTimeoutUSec.""" + return 18446744073709551615 + + @dbus_property(access=PropertyAccess.READ) + def JobRunningTimeoutUSec(self) -> "t": + """Get JobRunningTimeoutUSec.""" + return 18446744073709551615 + + @dbus_property(access=PropertyAccess.READ) + def JobTimeoutAction(self) -> "s": + """Get JobTimeoutAction.""" + return "none" + + @dbus_property(access=PropertyAccess.READ) + def JobTimeoutRebootArgument(self) -> "s": + """Get JobTimeoutRebootArgument.""" + return "" + + @dbus_property(access=PropertyAccess.READ) + def ConditionResult(self) -> "b": + """Get ConditionResult.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def AssertResult(self) -> "b": + """Get AssertResult.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def ConditionTimestamp(self) -> "t": + """Get ConditionTimestamp.""" + return 1682010434333557 + + @dbus_property(access=PropertyAccess.READ) + def ConditionTimestampMonotonic(self) -> "t": + """Get ConditionTimestampMonotonic.""" + return 409584108878 + + @dbus_property(access=PropertyAccess.READ) + def AssertTimestamp(self) -> "t": + """Get AssertTimestamp.""" + return 1682010434333562 + + @dbus_property(access=PropertyAccess.READ) + def AssertTimestampMonotonic(self) -> "t": + """Get AssertTimestampMonotonic.""" + return 409584108882 + + @dbus_property(access=PropertyAccess.READ) + def Conditions(self) -> "a(sbbsi)": + """Get Conditions.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def Asserts(self) -> "a(sbbsi)": + """Get Asserts.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def LoadError(self) -> "(ss)": + """Get LoadError.""" + return ("", "") + + @dbus_property(access=PropertyAccess.READ) + def Transient(self) -> "b": + """Get Transient.""" + return True + + @dbus_property(access=PropertyAccess.READ) + def Perpetual(self) -> "b": + """Get Perpetual.""" + return False + + @dbus_property(access=PropertyAccess.READ) + def StartLimitIntervalUSec(self) -> "t": + """Get StartLimitIntervalUSec.""" + return 10000000 + + @dbus_property(access=PropertyAccess.READ) + def StartLimitBurst(self) -> "u": + """Get StartLimitBurst.""" + return 5 + + @dbus_property(access=PropertyAccess.READ) + def StartLimitAction(self) -> "s": + """Get StartLimitAction.""" + return "none" + + @dbus_property(access=PropertyAccess.READ) + def FailureAction(self) -> "s": + """Get FailureAction.""" + return "none" + + @dbus_property(access=PropertyAccess.READ) + def FailureActionExitStatus(self) -> "i": + """Get FailureActionExitStatus.""" + return -1 + + @dbus_property(access=PropertyAccess.READ) + def SuccessAction(self) -> "s": + """Get SuccessAction.""" + return "none" + + @dbus_property(access=PropertyAccess.READ) + def SuccessActionExitStatus(self) -> "i": + """Get SuccessActionExitStatus.""" + return -1 + + @dbus_property(access=PropertyAccess.READ) + def RebootArgument(self) -> "s": + """Get RebootArgument.""" + return "" + + @dbus_property(access=PropertyAccess.READ) + def InvocationID(self) -> "ay": + """Get InvocationID.""" + return bytes( + [ + 0xA6, + 0xE5, + 0x0F, + 0x64, + 0x3F, + 0x1E, + 0x45, + 0x97, + 0xA7, + 0x2B, + 0x21, + 0xA3, + 0x34, + 0xC0, + 0x66, + 0x86, + ] + ) + + @dbus_property(access=PropertyAccess.READ) + def CollectMode(self) -> "s": + """Get CollectMode.""" + return "inactive" + + @dbus_property(access=PropertyAccess.READ) + def Refs(self) -> "as": + """Get Refs.""" + return [] + + @dbus_property(access=PropertyAccess.READ) + def ActivationDetails(self) -> "a(ss)": + """Get ActivationDetails.""" + return [] diff --git a/tests/mounts/__init__.py b/tests/mounts/__init__.py new file mode 100644 index 000000000..f0bb43dfd --- /dev/null +++ b/tests/mounts/__init__.py @@ -0,0 +1 @@ +"""Test files for mounts.""" diff --git a/tests/mounts/test_manager.py b/tests/mounts/test_manager.py new file mode 100644 index 000000000..46b448811 --- /dev/null +++ b/tests/mounts/test_manager.py @@ -0,0 +1,406 @@ +"""Tests for mount manager.""" + +import json +import os +from pathlib import Path + +from dbus_fast import DBusError, Variant +from dbus_fast.aio.message_bus import MessageBus +import pytest + +from supervisor.coresys import CoreSys +from supervisor.dbus.const import UnitActiveState +from supervisor.exceptions import MountNotFound +from supervisor.mounts.manager import MountManager +from supervisor.mounts.mount import Mount +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.data import Issue, Suggestion + +from tests.common import mock_dbus_services +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService +from tests.dbus_service_mocks.systemd_unit import SystemdUnit as SystemdUnitService + +ERROR_NO_UNIT = DBusError("org.freedesktop.systemd1.NoSuchUnit", "error") +BACKUP_TEST_DATA = { + "name": "backup_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "backups", +} +MEDIA_TEST_DATA = { + "name": "media_test", + "type": "nfs", + "usage": "media", + "server": "media.local", + "path": "/media", +} + + +@pytest.fixture(name="mount") +async def fixture_mount(coresys: CoreSys, tmp_supervisor_data, path_extern) -> Mount: + """Add an initial mount and load mounts.""" + mount = Mount.from_dict(coresys, MEDIA_TEST_DATA) + coresys.mounts._mounts = {"media_test": mount} # pylint: disable=protected-access + await coresys.mounts.load() + yield mount + + +async def test_load( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data, + path_extern, +): + """Test mount manager loading.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StartTransientUnit.calls.clear() + + backup_test = Mount.from_dict(coresys, BACKUP_TEST_DATA) + media_test = Mount.from_dict(coresys, MEDIA_TEST_DATA) + # pylint: disable=protected-access + coresys.mounts._mounts = { + "backup_test": backup_test, + "media_test": media_test, + } + # pylint: enable=protected-access + assert coresys.mounts.backup_mounts == [backup_test] + assert coresys.mounts.media_mounts == [media_test] + + assert backup_test.state is None + assert media_test.state is None + assert not backup_test.local_where.exists() + assert not media_test.local_where.exists() + assert not any(coresys.config.path_media.iterdir()) + + systemd_service.response_get_unit = { + "mnt-data-supervisor-mounts-backup_test.mount": [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ], + "mnt-data-supervisor-mounts-media_test.mount": [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ], + "mnt-data-supervisor-media-media_test.mount": [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ], + } + await coresys.mounts.load() + + assert backup_test.state == UnitActiveState.ACTIVE + assert media_test.state == UnitActiveState.ACTIVE + assert backup_test.local_where.is_dir() + assert media_test.local_where.is_dir() + assert (coresys.config.path_media / "media_test").is_dir() + + assert systemd_service.StartTransientUnit.calls == [ + ( + "mnt-data-supervisor-mounts-backup_test.mount", + "fail", + [ + ["Description", Variant("s", "Supervisor cifs mount: backup_test")], + ["What", Variant("s", "//backup.local/backups")], + ["Type", Variant("s", "cifs")], + ], + [], + ), + ( + "mnt-data-supervisor-mounts-media_test.mount", + "fail", + [ + ["Description", Variant("s", "Supervisor nfs mount: media_test")], + ["What", Variant("s", "media.local:/media")], + ["Type", Variant("s", "nfs")], + ], + [], + ), + ( + "mnt-data-supervisor-media-media_test.mount", + "fail", + [ + ["Description", Variant("s", "Supervisor bind mount: bind_media_test")], + ["What", Variant("s", "/mnt/data/supervisor/mounts/media_test")], + ["Type", Variant("s", "bind")], + ], + [], + ), + ] + + +async def test_mount_failed_during_load( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + dbus_session_bus: MessageBus, + tmp_supervisor_data, + path_extern, +): + """Test mount failed during load.""" + await mock_dbus_services( + {"systemd_unit": "/org/freedesktop/systemd1/unit/tmp_test"}, dbus_session_bus + ) + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"] + systemd_service.StartTransientUnit.calls.clear() + + backup_test = Mount.from_dict(coresys, BACKUP_TEST_DATA) + media_test = Mount.from_dict(coresys, MEDIA_TEST_DATA) + # pylint: disable=protected-access + coresys.mounts._mounts = { + "backup_test": backup_test, + "media_test": media_test, + } + # pylint: enable=protected-access + + assert backup_test.state is None + assert media_test.state is None + assert not backup_test.local_where.exists() + assert not media_test.local_where.exists() + assert not any(coresys.config.path_emergency.iterdir()) + assert not any(coresys.config.path_media.iterdir()) + + assert coresys.resolution.issues == [] + assert coresys.resolution.suggestions == [] + + systemd_service.response_get_unit = { + "mnt-data-supervisor-mounts-backup_test.mount": [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ], + "mnt-data-supervisor-mounts-media_test.mount": [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ], + "mnt-data-supervisor-media-media_test.mount": [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_test", + ], + } + systemd_unit_service.active_state = "failed" + await coresys.mounts.load() + + assert backup_test.state == UnitActiveState.FAILED + assert media_test.state == UnitActiveState.FAILED + assert backup_test.local_where.is_dir() + assert media_test.local_where.is_dir() + assert (coresys.config.path_media / "media_test").is_dir() + emergency_dir = coresys.config.path_emergency / "media_test" + assert emergency_dir.is_dir() + assert os.access(emergency_dir, os.R_OK) + assert not os.access(emergency_dir, os.W_OK) + + assert ( + Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference="backup_test") + in coresys.resolution.issues + ) + assert ( + Suggestion( + SuggestionType.EXECUTE_RELOAD, ContextType.MOUNT, reference="backup_test" + ) + in coresys.resolution.suggestions + ) + assert ( + Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.MOUNT, reference="backup_test" + ) + in coresys.resolution.suggestions + ) + assert ( + Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference="media_test") + in coresys.resolution.issues + ) + assert ( + Suggestion( + SuggestionType.EXECUTE_RELOAD, ContextType.MOUNT, reference="media_test" + ) + in coresys.resolution.suggestions + ) + assert ( + Suggestion( + SuggestionType.EXECUTE_REMOVE, ContextType.MOUNT, reference="media_test" + ) + in coresys.resolution.suggestions + ) + + assert len(systemd_service.StartTransientUnit.calls) == 3 + assert systemd_service.StartTransientUnit.calls[2] == ( + "mnt-data-supervisor-media-media_test.mount", + "fail", + [ + [ + "Description", + Variant("s", "Supervisor bind mount: emergency_media_test"), + ], + ["What", Variant("s", "/mnt/data/supervisor/emergency/media_test")], + ["Type", Variant("s", "bind")], + ], + [], + ) + + +async def test_create_mount( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data, + path_extern, +): + """Test creating a mount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StartTransientUnit.calls.clear() + + await coresys.mounts.load() + + mount = Mount.from_dict(coresys, MEDIA_TEST_DATA) + + assert mount.state is None + assert mount not in coresys.mounts + assert "media_test" not in coresys.mounts + assert not mount.local_where.exists() + assert not any(coresys.config.path_media.iterdir()) + + # Create the mount + systemd_service.response_get_unit = [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ] + await coresys.mounts.create_mount(mount) + + assert mount.state == UnitActiveState.ACTIVE + assert mount in coresys.mounts + assert "media_test" in coresys.mounts + assert mount.local_where.exists() + assert (coresys.config.path_media / "media_test").exists() + + assert [call[0] for call in systemd_service.StartTransientUnit.calls] == [ + "mnt-data-supervisor-mounts-media_test.mount", + "mnt-data-supervisor-media-media_test.mount", + ] + + +async def test_update_mount( + coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], mount: Mount +): + """Test updating a mount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StartTransientUnit.calls.clear() + systemd_service.StopUnit.calls.clear() + + # Update the mount. Should be unmounted then remounted + mount_new = Mount.from_dict(coresys, MEDIA_TEST_DATA) + assert mount.state == UnitActiveState.ACTIVE + assert mount_new.state is None + + systemd_service.response_get_unit = [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ] + await coresys.mounts.create_mount(mount_new) + + assert mount.state is None + assert mount_new.state == UnitActiveState.ACTIVE + + assert [call[0] for call in systemd_service.StartTransientUnit.calls] == [ + "mnt-data-supervisor-mounts-media_test.mount", + "mnt-data-supervisor-media-media_test.mount", + ] + assert [call[0] for call in systemd_service.StopUnit.calls] == [ + "mnt-data-supervisor-media-media_test.mount", + "mnt-data-supervisor-mounts-media_test.mount", + ] + + +async def test_reload_mount( + coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], mount: Mount +): + """Test reloading a mount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.ReloadOrRestartUnit.calls.clear() + + # Reload the mount + systemd_service.response_get_unit = [ + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + ] + await coresys.mounts.reload_mount(mount.name) + + assert len(systemd_service.ReloadOrRestartUnit.calls) == 1 + assert ( + systemd_service.ReloadOrRestartUnit.calls[0][0] + == "mnt-data-supervisor-mounts-media_test.mount" + ) + + +async def test_remove_mount( + coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], mount: Mount +): + """Test removing a mount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StopUnit.calls.clear() + + # Remove the mount + await coresys.mounts.remove_mount(mount.name) + + assert mount.state is None + assert mount not in coresys.mounts + + assert [call[0] for call in systemd_service.StopUnit.calls] == [ + "mnt-data-supervisor-media-media_test.mount", + "mnt-data-supervisor-mounts-media_test.mount", + ] + + +async def test_remove_reload_mount_missing(coresys: CoreSys): + """Test removing or reloading a non existent mount errors.""" + await coresys.mounts.load() + + with pytest.raises(MountNotFound): + await coresys.mounts.remove_mount("does_not_exist") + + with pytest.raises(MountNotFound): + await coresys.mounts.reload_mount("does_not_exist") + + +async def test_save_data(coresys: CoreSys, tmp_supervisor_data: Path, path_extern): + """Test saving mount config data.""" + # Replace mount manager with one that doesn't have save_data mocked + coresys._mounts = MountManager(coresys) # pylint: disable=protected-access + + path = tmp_supervisor_data / "mounts.json" + assert not path.exists() + + await coresys.mounts.load() + await coresys.mounts.create_mount( + Mount.from_dict( + coresys, + { + "name": "auth_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "backups", + "username": "admin", + "password": "password", + }, + ) + ) + coresys.mounts.save_data() + + assert path.exists() + with path.open() as file: + config = json.load(file) + assert config["mounts"] == [ + { + "name": "auth_test", + "type": "cifs", + "usage": "backup", + "server": "backup.local", + "share": "backups", + "username": "admin", + "password": "password", + } + ] diff --git a/tests/mounts/test_mount.py b/tests/mounts/test_mount.py new file mode 100644 index 000000000..8ce070e5c --- /dev/null +++ b/tests/mounts/test_mount.py @@ -0,0 +1,459 @@ +"""Tests for mounts.""" + +import os +from pathlib import Path +from unittest.mock import patch + +from dbus_fast import DBusError, ErrorType, Variant +import pytest + +from supervisor.coresys import CoreSys +from supervisor.dbus.const import UnitActiveState +from supervisor.exceptions import MountError, MountInvalidError +from supervisor.mounts.const import MountType, MountUsage +from supervisor.mounts.mount import CIFSMount, Mount, NFSMount + +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService +from tests.dbus_service_mocks.systemd_unit import SystemdUnit as SystemdUnitService + +ERROR_FAILURE = DBusError(ErrorType.FAILED, "error") +ERROR_NO_UNIT = DBusError("org.freedesktop.systemd1.NoSuchUnit", "error") + + +async def test_cifs_mount( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data: Path, + path_extern, +): + """Test CIFS mount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StartTransientUnit.calls.clear() + + mount_data = { + "name": "test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "camera", + "username": "admin", + "password": "password", + } + mount: CIFSMount = Mount.from_dict(coresys, mount_data) + + assert isinstance(mount, CIFSMount) + assert mount.name == "test" + assert mount.type == MountType.CIFS + assert mount.usage == MountUsage.MEDIA + assert mount.port is None + assert mount.state is None + assert mount.unit is None + + assert mount.what == "//test.local/camera" + assert mount.where == Path("/mnt/data/supervisor/mounts/test") + assert mount.local_where == tmp_supervisor_data / "mounts" / "test" + assert mount.options == ["username=admin", "password=password"] + + assert not mount.local_where.exists() + assert mount.to_dict(skip_secrets=False) == mount_data + assert mount.to_dict() == { + k: v for k, v in mount_data.items() if k not in ["username", "password"] + } + + await mount.mount() + + assert mount.state == UnitActiveState.ACTIVE + assert mount.local_where.exists() + assert mount.local_where.is_dir() + + assert systemd_service.StartTransientUnit.calls == [ + ( + "mnt-data-supervisor-mounts-test.mount", + "fail", + [ + ["Options", Variant("s", "username=admin,password=password")], + ["Description", Variant("s", "Supervisor cifs mount: test")], + ["What", Variant("s", "//test.local/camera")], + ["Type", Variant("s", "cifs")], + ], + [], + ) + ] + + +async def test_nfs_mount( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data: Path, + path_extern, +): + """Test NFS mount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StartTransientUnit.calls.clear() + + mount_data = { + "name": "test", + "usage": "media", + "type": "nfs", + "server": "test.local", + "path": "/media/camera", + "port": 1234, + } + mount: NFSMount = Mount.from_dict(coresys, mount_data) + + assert isinstance(mount, NFSMount) + assert mount.name == "test" + assert mount.type == MountType.NFS + assert mount.usage == MountUsage.MEDIA + assert mount.port == 1234 + assert mount.state is None + assert mount.unit is None + + assert mount.what == "test.local:/media/camera" + assert mount.where == Path("/mnt/data/supervisor/mounts/test") + assert mount.local_where == tmp_supervisor_data / "mounts" / "test" + assert mount.options == ["port=1234"] + + assert not mount.local_where.exists() + assert mount.to_dict() == mount_data + + await mount.mount() + + assert mount.state == UnitActiveState.ACTIVE + assert mount.local_where.exists() + assert mount.local_where.is_dir() + + assert systemd_service.StartTransientUnit.calls == [ + ( + "mnt-data-supervisor-mounts-test.mount", + "fail", + [ + ["Options", Variant("s", "port=1234")], + ["Description", Variant("s", "Supervisor nfs mount: test")], + ["What", Variant("s", "test.local:/media/camera")], + ["Type", Variant("s", "nfs")], + ], + [], + ) + ] + + +async def test_load( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data, + path_extern, +): + """Test mount loading.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"] + systemd_service.StartTransientUnit.calls.clear() + systemd_service.ReloadOrRestartUnit.calls.clear() + + mount_data = { + "name": "test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "share", + } + + # Load mounts it if the unit does not exist + systemd_service.response_get_unit = [ + ERROR_NO_UNIT, + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount", + ] + mount = Mount.from_dict(coresys, mount_data) + await mount.load() + + assert ( + mount.unit.object_path == "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + ) + assert mount.state == UnitActiveState.ACTIVE + assert systemd_service.StartTransientUnit.calls == [ + ( + "mnt-data-supervisor-mounts-test.mount", + "fail", + [ + ["Description", Variant("s", "Supervisor cifs mount: test")], + ["What", Variant("s", "//test.local/share")], + ["Type", Variant("s", "cifs")], + ], + [], + ) + ] + assert systemd_service.ReloadOrRestartUnit.calls == [] + + # Load does nothing except cache state and unit if it finds an active unit already + systemd_service.StartTransientUnit.calls.clear() + systemd_service.response_get_unit = ( + "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + ) + mount = Mount.from_dict(coresys, mount_data) + await mount.load() + + assert ( + mount.unit.object_path == "/org/freedesktop/systemd1/unit/tmp_2dyellow_2emount" + ) + assert mount.state == UnitActiveState.ACTIVE + assert systemd_service.StartTransientUnit.calls == [] + assert systemd_service.ReloadOrRestartUnit.calls == [] + + # Load restarts the unit if it finds it in a failed state + systemd_unit_service.active_state = ["failed", "active"] + mount = Mount.from_dict(coresys, mount_data) + await mount.load() + + assert mount.state == UnitActiveState.ACTIVE + assert systemd_service.StartTransientUnit.calls == [] + assert systemd_service.ReloadOrRestartUnit.calls == [ + ("mnt-data-supervisor-mounts-test.mount", "fail") + ] + + # Load waits up to 30 seconds if it finds a unit in the activating state + systemd_service.ReloadOrRestartUnit.calls.clear() + systemd_unit_service.active_state = "activating" + mount = Mount.from_dict(coresys, mount_data) + + async def mock_activation_finished(*_): + assert mount.state == UnitActiveState.ACTIVATING + assert systemd_service.ReloadOrRestartUnit.calls == [] + systemd_unit_service.active_state = ["failed", "active"] + + with patch("supervisor.mounts.mount.asyncio.sleep", new=mock_activation_finished): + await mount.load() + + assert mount.state == UnitActiveState.ACTIVE + assert systemd_service.StartTransientUnit.calls == [] + assert systemd_service.ReloadOrRestartUnit.calls == [ + ("mnt-data-supervisor-mounts-test.mount", "fail") + ] + + +async def test_unmount( + coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], path_extern +): + """Test unmounting.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StopUnit.calls.clear() + + mount = Mount.from_dict( + coresys, + { + "name": "test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "share", + }, + ) + await mount.load() + + assert mount.unit is not None + assert mount.state == UnitActiveState.ACTIVE + + await mount.unmount() + + assert mount.unit is None + assert mount.state is None + assert systemd_service.StopUnit.calls == [ + ("mnt-data-supervisor-mounts-test.mount", "fail") + ] + + +async def test_mount_failure( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data, + path_extern, +): + """Test failure to mount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"] + systemd_service.StartTransientUnit.calls.clear() + systemd_service.GetUnit.calls.clear() + + mount = Mount.from_dict( + coresys, + { + "name": "test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "share", + }, + ) + + # Raise error on StartTransientUnit error + systemd_service.response_start_transient_unit = ERROR_FAILURE + with pytest.raises(MountError): + await mount.mount() + + assert mount.state is None + assert len(systemd_service.StartTransientUnit.calls) == 1 + assert systemd_service.GetUnit.calls == [] + + # Raise error if state is not "active" after mount + systemd_service.StartTransientUnit.calls.clear() + systemd_service.response_start_transient_unit = "/org/freedesktop/systemd1/job/7623" + systemd_unit_service.active_state = "failed" + with pytest.raises(MountError): + await mount.mount() + + assert mount.state == UnitActiveState.FAILED + assert len(systemd_service.StartTransientUnit.calls) == 1 + assert len(systemd_service.GetUnit.calls) == 1 + + # If state is 'activating', wait it out and raise error if it does not become 'active' + systemd_service.StartTransientUnit.calls.clear() + systemd_service.GetUnit.calls.clear() + systemd_unit_service.active_state = "activating" + + async def mock_activation_finished(*_): + assert mount.state == UnitActiveState.ACTIVATING + systemd_unit_service.active_state = "failed" + + with patch( + "supervisor.mounts.mount.asyncio.sleep", new=mock_activation_finished + ), pytest.raises(MountError): + await mount.mount() + + assert mount.state == UnitActiveState.FAILED + assert len(systemd_service.StartTransientUnit.calls) == 1 + assert len(systemd_service.GetUnit.calls) == 2 + + +async def test_unmount_failure( + coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], path_extern +): + """Test failure to unmount.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StopUnit.calls.clear() + + mount = Mount.from_dict( + coresys, + { + "name": "test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "share", + }, + ) + + # Raise error on StopUnit failure + systemd_service.response_stop_unit = ERROR_FAILURE + with pytest.raises(MountError): + await mount.unmount() + + assert len(systemd_service.StopUnit.calls) == 1 + + # If error is NoSuchUnit then ignore, it has already been unmounted + systemd_service.StopUnit.calls.clear() + systemd_service.response_stop_unit = ERROR_NO_UNIT + await mount.unmount() + assert len(systemd_service.StopUnit.calls) == 1 + + +async def test_reload_failure( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data, + path_extern, +): + """Test failure to reload.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_unit_service: SystemdUnitService = all_dbus_services["systemd_unit"] + systemd_service.StartTransientUnit.calls.clear() + systemd_service.ReloadOrRestartUnit.calls.clear() + systemd_service.GetUnit.calls.clear() + + mount = Mount.from_dict( + coresys, + { + "name": "test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "share", + }, + ) + + # Raise error on ReloadOrRestartUnit error + systemd_service.response_reload_or_restart_unit = ERROR_FAILURE + with pytest.raises(MountError): + await mount.reload() + + assert mount.state is None + assert len(systemd_service.ReloadOrRestartUnit.calls) == 1 + assert systemd_service.GetUnit.calls == [] + assert systemd_service.StartTransientUnit.calls == [] + + # Raise error if state is not "active" after reload + systemd_service.ReloadOrRestartUnit.calls.clear() + systemd_service.response_reload_or_restart_unit = ( + "/org/freedesktop/systemd1/job/7623" + ) + systemd_unit_service.active_state = "failed" + with pytest.raises(MountError): + await mount.reload() + + assert mount.state == UnitActiveState.FAILED + assert len(systemd_service.ReloadOrRestartUnit.calls) == 1 + assert len(systemd_service.GetUnit.calls) == 1 + assert systemd_service.StartTransientUnit.calls == [] + + # If error is NoSuchUnit then don't raise just mount instead as its not mounted + systemd_service.ReloadOrRestartUnit.calls.clear() + systemd_service.GetUnit.calls.clear() + systemd_service.response_reload_or_restart_unit = ERROR_NO_UNIT + systemd_unit_service.active_state = "active" + + await mount.reload() + + assert mount.state == UnitActiveState.ACTIVE + assert len(systemd_service.ReloadOrRestartUnit.calls) == 1 + assert len(systemd_service.StartTransientUnit.calls) == 1 + assert len(systemd_service.GetUnit.calls) == 1 + + +async def test_mount_local_where_invalid( + coresys: CoreSys, + all_dbus_services: dict[str, DBusServiceMock], + tmp_supervisor_data: Path, + path_extern, +): + """Test mount errors because local where exists and is invalid.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StartTransientUnit.calls.clear() + + mount = Mount.from_dict( + coresys, + { + "name": "test", + "usage": "media", + "type": "cifs", + "server": "test.local", + "share": "share", + }, + ) + + mount_path = tmp_supervisor_data / "mounts" / "test" + assert not mount_path.exists() + + # Cannot mount on top of a non-directory + mount_path.touch() + + with pytest.raises(MountInvalidError): + await mount.mount() + + # Cannot mount on top of a non-empty directory + os.remove(mount_path) + mount_path.mkdir() + (mount_path / "test").touch() + + with pytest.raises(MountInvalidError): + await mount.mount() + + assert systemd_service.StartTransientUnit.calls == [] diff --git a/tests/mounts/test_validate.py b/tests/mounts/test_validate.py new file mode 100644 index 000000000..46773af5d --- /dev/null +++ b/tests/mounts/test_validate.py @@ -0,0 +1,107 @@ +"""Tests for mount manager validation.""" + +import pytest +from voluptuous import Invalid + +from supervisor.mounts.validate import SCHEMA_MOUNT_CONFIG + + +async def test_valid_mounts(): + """Test valid mounts.""" + assert SCHEMA_MOUNT_CONFIG( + { + "name": "cifs_test", + "usage": "backup", + "type": "cifs", + "server": "test.local", + "share": "test", + } + ) + + assert SCHEMA_MOUNT_CONFIG( + { + "name": "nfs_test", + "usage": "media", + "type": "nfs", + "server": "192.168.1.10", + "path": "/data/media", + } + ) + + +async def test_invalid_name(): + """Test name not a valid filename.""" + base = { + "usage": "backup", + "type": "cifs", + "server": "test.local", + "share": "test", + } + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"name": "no spaces"} | base) + + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"name": "no_special_chars_@#"} | base) + + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"name": "no-dashes"} | base) + + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"name": "no/slashes"} | base) + + +async def test_no_bind_mounts(): + """Bind mount not a valid type.""" + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG( + { + "name": "test", + "usage": " backup", + "type": "bind", + "path": "/etc/ssl", + } + ) + + +async def test_invalid_cifs(): + """Test invalid cifs mounts.""" + base = { + "name": "test", + "usage": "backup", + "type": "cifs", + "server": "test.local", + } + + # Missing share + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG(base) + + # Path is for NFS + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"path": "backups"}) + + # Username and password must be together + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"username": "admin"}) + + +async def test_invalid_nfs(): + """Test invalid nfs mounts.""" + base = { + "name": "test", + "usage": "backup", + "type": "nfs", + "server": "test.local", + } + + # Missing path + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG(base) + + # Share is for CIFS + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"share": "backups"}) + + # Auth is for CIFS + with pytest.raises(Invalid): + SCHEMA_MOUNT_CONFIG({"username": "admin", "password": "password"}) diff --git a/tests/resolution/fixup/test_mount_execute_reload.py b/tests/resolution/fixup/test_mount_execute_reload.py new file mode 100644 index 000000000..01069b715 --- /dev/null +++ b/tests/resolution/fixup/test_mount_execute_reload.py @@ -0,0 +1,49 @@ +"""Test fixup mount reload.""" + +from supervisor.coresys import CoreSys +from supervisor.mounts.mount import Mount +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.fixups.mount_execute_reload import FixupMountExecuteReload + +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService + + +async def test_fixup( + coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], path_extern +): + """Test fixup.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.ReloadOrRestartUnit.calls.clear() + + mount_execute_reload = FixupMountExecuteReload(coresys) + + assert mount_execute_reload.auto is False + + await coresys.mounts.create_mount( + Mount.from_dict( + coresys, + { + "name": "test", + "usage": "backup", + "type": "cifs", + "server": "test.local", + "share": "test", + }, + ) + ) + + coresys.resolution.create_issue( + IssueType.MOUNT_FAILED, + ContextType.MOUNT, + reference="test", + suggestions=[SuggestionType.EXECUTE_RELOAD, SuggestionType.EXECUTE_REMOVE], + ) + await mount_execute_reload() + + assert coresys.resolution.issues == [] + assert coresys.resolution.suggestions == [] + assert "test" in coresys.mounts + assert systemd_service.ReloadOrRestartUnit.calls == [ + ("mnt-data-supervisor-mounts-test.mount", "fail") + ] diff --git a/tests/resolution/fixup/test_mount_execute_remove.py b/tests/resolution/fixup/test_mount_execute_remove.py new file mode 100644 index 000000000..471143b9c --- /dev/null +++ b/tests/resolution/fixup/test_mount_execute_remove.py @@ -0,0 +1,49 @@ +"""Test fixup mount remove.""" + +from supervisor.coresys import CoreSys +from supervisor.mounts.mount import Mount +from supervisor.resolution.const import ContextType, IssueType, SuggestionType +from supervisor.resolution.fixups.mount_execute_remove import FixupMountExecuteRemove + +from tests.dbus_service_mocks.base import DBusServiceMock +from tests.dbus_service_mocks.systemd import Systemd as SystemdService + + +async def test_fixup( + coresys: CoreSys, all_dbus_services: dict[str, DBusServiceMock], path_extern +): + """Test fixup.""" + systemd_service: SystemdService = all_dbus_services["systemd"] + systemd_service.StopUnit.calls.clear() + + mount_execute_remove = FixupMountExecuteRemove(coresys) + + assert mount_execute_remove.auto is False + + await coresys.mounts.create_mount( + Mount.from_dict( + coresys, + { + "name": "test", + "usage": "backup", + "type": "cifs", + "server": "test.local", + "share": "test", + }, + ) + ) + + coresys.resolution.create_issue( + IssueType.MOUNT_FAILED, + ContextType.MOUNT, + reference="test", + suggestions=[SuggestionType.EXECUTE_RELOAD, SuggestionType.EXECUTE_REMOVE], + ) + await mount_execute_remove() + + assert coresys.resolution.issues == [] + assert coresys.resolution.suggestions == [] + assert coresys.mounts.mounts == [] + assert systemd_service.StopUnit.calls == [ + ("mnt-data-supervisor-mounts-test.mount", "fail") + ] diff --git a/tests/resolution/test_resolution_manager.py b/tests/resolution/test_resolution_manager.py index 6d523d7df..7c298b3cb 100644 --- a/tests/resolution/test_resolution_manager.py +++ b/tests/resolution/test_resolution_manager.py @@ -389,3 +389,42 @@ async def test_events_on_unhealthy_changed(coresys: CoreSys): {"healthy": False, "unhealthy_reasons": ["docker", "untrusted"]}, ) ) + + +async def test_dismiss_issue_removes_orphaned_suggestions(coresys: CoreSys): + """Test dismissing an issue also removes any suggestions which have been orphaned.""" + with patch.object( + type(coresys.homeassistant.websocket), "async_send_message" + ) as send_message: + coresys.resolution.create_issue( + IssueType.MOUNT_FAILED, + ContextType.MOUNT, + "test", + [SuggestionType.EXECUTE_RELOAD, SuggestionType.EXECUTE_REMOVE], + ) + await asyncio.sleep(0) + assert len(coresys.resolution.issues) == 1 + assert len(coresys.resolution.suggestions) == 2 + send_message.assert_called_once() + send_message.reset_mock() + + issue = coresys.resolution.issues[0] + coresys.resolution.dismiss_issue(issue) + await asyncio.sleep(0) + + # The issue and both suggestions should be dismissed as they are now orphaned + assert coresys.resolution.issues == [] + assert coresys.resolution.suggestions == [] + + # Only one message should fire to tell HA the issue was removed + send_message.assert_called_once_with( + _supervisor_event_message( + "issue_removed", + { + "type": "mount_failed", + "context": "mount", + "reference": "test", + "uuid": issue.uuid, + }, + ) + )