Add support for network mounts (#4269)
* Add support for network mounts * Handle backups and save data * fix pylint issues
This commit is contained in:
parent
ebe9c32092
commit
34c394c3d1
|
@ -13,6 +13,13 @@
|
|||
"remoteRoot": "/usr/src/supervisor"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Debug Tests",
|
||||
"type": "python",
|
||||
"request": "test",
|
||||
"console": "internalConsole",
|
||||
"justMyCode": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
|
@ -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=".",
|
||||
)
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
"hassos-supervisor",
|
||||
"hassos-zram",
|
||||
"kernel",
|
||||
"mount",
|
||||
"os-agent",
|
||||
"rauc",
|
||||
"systemd",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Manage user mounts in supervisor."""
|
|
@ -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"
|
|
@ -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()
|
|
@ -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 []
|
|
@ -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]
|
|
@ -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"
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"
|
|
@ -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()
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 []
|
|
@ -0,0 +1 @@
|
|||
"""Test files for mounts."""
|
|
@ -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",
|
||||
}
|
||||
]
|
|
@ -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 == []
|
|
@ -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"})
|
|
@ -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")
|
||||
]
|
|
@ -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")
|
||||
]
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue