Add support for network mounts (#4269)

* Add support for network mounts

* Handle backups and save data

* fix pylint issues
This commit is contained in:
Mike Degatano 2023-05-01 02:45:52 -04:00 committed by GitHub
parent ebe9c32092
commit 34c394c3d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 3173 additions and 36 deletions

7
.vscode/launch.json vendored
View File

@ -13,6 +13,13 @@
"remoteRoot": "/usr/src/supervisor"
}
]
},
{
"name": "Debug Tests",
"type": "python",
"request": "test",
"console": "internalConsole",
"justMyCode": false
}
]
}

View File

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

View File

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

75
supervisor/api/mounts.py Normal file
View File

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

View File

@ -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=".",
)

View File

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

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@
"hassos-supervisor",
"hassos-zram",
"kernel",
"mount",
"os-agent",
"rauc",
"systemd",

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
"""Manage user mounts in supervisor."""

View File

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

View File

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

410
supervisor/mounts/mount.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

182
tests/api/test_mounts.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
tests/mounts/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Test files for mounts."""

View File

@ -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",
}
]

459
tests/mounts/test_mount.py Normal file
View File

@ -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 == []

View File

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

View File

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

View File

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

View File

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