569 lines
18 KiB
Python
569 lines
18 KiB
Python
"""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,
|
|
ATTR_VERSION,
|
|
)
|
|
from ..coresys import CoreSys, CoreSysAttributes
|
|
from ..dbus.const import (
|
|
DBUS_ATTR_ACTIVE_STATE,
|
|
DBUS_ATTR_DESCRIPTION,
|
|
DBUS_ATTR_OPTIONS,
|
|
DBUS_ATTR_TYPE,
|
|
DBUS_ATTR_WHAT,
|
|
DBUS_IFACE_SYSTEMD_UNIT,
|
|
StartUnitMode,
|
|
StopUnitMode,
|
|
UnitActiveState,
|
|
)
|
|
from ..dbus.systemd import SystemdUnit
|
|
from ..exceptions import (
|
|
DBusError,
|
|
DBusSystemdNoSuchUnit,
|
|
MountActivationError,
|
|
MountError,
|
|
MountInvalidError,
|
|
)
|
|
from ..resolution.const import ContextType, IssueType
|
|
from ..resolution.data import Issue
|
|
from ..utils.sentry import capture_exception
|
|
from .const import (
|
|
ATTR_PATH,
|
|
ATTR_READ_ONLY,
|
|
ATTR_SERVER,
|
|
ATTR_SHARE,
|
|
ATTR_USAGE,
|
|
MountCifsVersion,
|
|
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, usage=self.usage, read_only=self.read_only
|
|
)
|
|
|
|
@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
|
|
def read_only(self) -> bool:
|
|
"""Is mount read-only."""
|
|
return self._data.get(ATTR_READ_ONLY, False)
|
|
|
|
@property
|
|
@abstractmethod
|
|
def what(self) -> str:
|
|
"""What to mount."""
|
|
|
|
@property
|
|
@abstractmethod
|
|
def where(self) -> PurePath:
|
|
"""Where to mount (on host)."""
|
|
|
|
@property
|
|
def options(self) -> list[str]:
|
|
"""List of options to use to mount."""
|
|
return ["ro"] if self.read_only else []
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
"""Description of mount."""
|
|
return f"Supervisor {self.type} 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
|
|
)
|
|
|
|
@property
|
|
def failed_issue(self) -> Issue:
|
|
"""Get issue used if this mount has failed."""
|
|
return Issue(IssueType.MOUNT_FAILED, ContextType.MOUNT, reference=self.name)
|
|
|
|
async def is_mounted(self) -> bool:
|
|
"""Return true if successfully mounted and available."""
|
|
return self.state == UnitActiveState.ACTIVE
|
|
|
|
def __eq__(self, other):
|
|
"""Return true if mounts are the same."""
|
|
return isinstance(other, Mount) and self.name == other.name
|
|
|
|
async def load(self) -> None:
|
|
"""Initialize object."""
|
|
# If there's no mount unit, mount it to make one
|
|
if not await self._update_unit():
|
|
await self.mount()
|
|
return
|
|
|
|
await self._update_state_await(not_state=UnitActiveState.ACTIVATING)
|
|
|
|
# If mount is not available, try to reload it
|
|
if not await self.is_mounted():
|
|
await self.reload()
|
|
|
|
async def _update_state(self) -> UnitActiveState | None:
|
|
"""Update mount unit state."""
|
|
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_unit(self) -> SystemdUnit | None:
|
|
"""Get systemd unit from dbus."""
|
|
try:
|
|
self._unit = await self.sys_dbus.systemd.get_unit(self.unit_name)
|
|
except DBusSystemdNoSuchUnit:
|
|
self._unit = None
|
|
self._state = None
|
|
except DBusError as err:
|
|
capture_exception(err)
|
|
raise MountError(f"Could not get mount unit due to: {err!s}") from err
|
|
return self.unit
|
|
|
|
async def update(self) -> bool:
|
|
"""Update info about mount from dbus. Return true if it is mounted and available."""
|
|
if not await self._update_unit():
|
|
return False
|
|
|
|
await self._update_state()
|
|
|
|
# If active, dismiss corresponding failed mount issue if found
|
|
if (
|
|
mounted := await self.is_mounted()
|
|
) and self.failed_issue in self.sys_resolution.issues:
|
|
self.sys_resolution.dismiss_issue(self.failed_issue)
|
|
|
|
return mounted
|
|
|
|
async def _update_state_await(
|
|
self,
|
|
expected_states: list[UnitActiveState] | None = None,
|
|
not_state: UnitActiveState = UnitActiveState.ACTIVATING,
|
|
) -> None:
|
|
"""Update state info about mount from dbus. Wait for one of expected_states to appear or state to change from not_state."""
|
|
if not self.unit:
|
|
return
|
|
|
|
try:
|
|
async with asyncio.timeout(30), self.unit.properties_changed() as signal:
|
|
await self._update_state()
|
|
while (
|
|
expected_states
|
|
and self.state not in expected_states
|
|
or not expected_states
|
|
and self.state == not_state
|
|
):
|
|
prop_change_signal = await signal.wait_for_signal()
|
|
if (
|
|
prop_change_signal[0] == DBUS_IFACE_SYSTEMD_UNIT
|
|
and DBUS_ATTR_ACTIVE_STATE in prop_change_signal[1]
|
|
):
|
|
self._state = prop_change_signal[1][
|
|
DBUS_ATTR_ACTIVE_STATE
|
|
].value
|
|
|
|
except TimeoutError:
|
|
_LOGGER.warning(
|
|
"Mount %s still in state %s after waiting for 30 seconds to complete",
|
|
self.name,
|
|
str(self.state).lower(),
|
|
)
|
|
|
|
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 []
|
|
)
|
|
if self.type != MountType.BIND:
|
|
options += [(DBUS_ATTR_TYPE, Variant("s", self.type))]
|
|
|
|
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)),
|
|
],
|
|
)
|
|
except DBusError as err:
|
|
raise MountError(
|
|
f"Could not mount {self.name} due to: {err!s}", _LOGGER.error
|
|
) from err
|
|
|
|
if await self._update_unit():
|
|
await self._update_state_await(not_state=UnitActiveState.ACTIVATING)
|
|
|
|
if not await self.is_mounted():
|
|
raise MountActivationError(
|
|
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."""
|
|
if not await self._update_unit():
|
|
_LOGGER.info("Mount %s is not mounted, skipping unmount", self.name)
|
|
return
|
|
|
|
await self._update_state()
|
|
try:
|
|
if self.state != UnitActiveState.FAILED:
|
|
await self.sys_dbus.systemd.stop_unit(self.unit_name, StopUnitMode.FAIL)
|
|
|
|
await self._update_state_await(
|
|
[UnitActiveState.INACTIVE, UnitActiveState.FAILED]
|
|
)
|
|
|
|
if self.state == UnitActiveState.FAILED:
|
|
await self.sys_dbus.systemd.reset_failed_unit(self.unit_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()
|
|
except DBusError as err:
|
|
raise MountError(
|
|
f"Could not reload mount {self.name} due to: {err!s}", _LOGGER.error
|
|
) from err
|
|
else:
|
|
if await self._update_unit():
|
|
await self._update_state_await(not_state=UnitActiveState.ACTIVATING)
|
|
|
|
if not await self.is_mounted():
|
|
raise MountActivationError(
|
|
f"Reloading {self.name} did not succeed. Check host logs for errors from mount or systemd unit {self.unit_name} for details.",
|
|
_LOGGER.error,
|
|
)
|
|
|
|
# If it is mounted now, dismiss corresponding issue if present
|
|
if self.failed_issue in self.sys_resolution.issues:
|
|
self.sys_resolution.dismiss_issue(self.failed_issue)
|
|
|
|
|
|
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."""
|
|
options = super().options
|
|
if self.port:
|
|
options.append(f"port={self.port}")
|
|
return options
|
|
|
|
async def is_mounted(self) -> bool:
|
|
"""Return true if successfully mounted and available."""
|
|
return self.state == UnitActiveState.ACTIVE and await self.sys_run_in_executor(
|
|
self.local_where.is_mount
|
|
)
|
|
|
|
|
|
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
|
|
out[ATTR_VERSION] = self.version
|
|
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 version(self) -> str | None:
|
|
"""Get password, returns none if auth is not used."""
|
|
version = self._data.get(ATTR_VERSION)
|
|
if version == MountCifsVersion.LEGACY_1_0:
|
|
return "1.0"
|
|
if version == MountCifsVersion.LEGACY_2_0:
|
|
return "2.0"
|
|
return None
|
|
|
|
@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."""
|
|
options = super().options + ["noserverino"]
|
|
if self.version:
|
|
options.append(f"vers={self.version}")
|
|
|
|
if self.username and self.password:
|
|
options.append(f"credentials={self.path_extern_credentials.as_posix()}")
|
|
else:
|
|
options.append("guest")
|
|
|
|
return options
|
|
|
|
@property
|
|
def path_credentials(self) -> Path:
|
|
"""Path to credentials file."""
|
|
return self.sys_config.path_mounts_credentials / self.name
|
|
|
|
@property
|
|
def path_extern_credentials(self) -> PurePath:
|
|
"""Path to credentials file external to Docker."""
|
|
return self.sys_config.path_extern_mounts_credentials / self.name
|
|
|
|
async def mount(self) -> None:
|
|
"""Mount using systemd."""
|
|
if self.username and self.password:
|
|
if not self.path_credentials.exists():
|
|
self.path_credentials.touch(mode=0o600)
|
|
|
|
with self.path_credentials.open(mode="w") as cred_file:
|
|
cred_file.write(f"username={self.username}\npassword={self.password}")
|
|
|
|
await super().mount()
|
|
|
|
async def unmount(self) -> None:
|
|
"""Unmount using systemd."""
|
|
self.path_credentials.unlink(missing_ok=True)
|
|
await super().unmount()
|
|
|
|
|
|
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()}"
|
|
|
|
@property
|
|
def options(self) -> list[str]:
|
|
"""Options to use to mount."""
|
|
return super().options + ["soft", "timeo=200"]
|
|
|
|
|
|
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,
|
|
read_only: bool = False,
|
|
) -> "BindMount":
|
|
"""Create a new bind mount instance."""
|
|
return BindMount(
|
|
coresys,
|
|
MountData(
|
|
name=name,
|
|
type=MountType.BIND,
|
|
path=path.as_posix(),
|
|
usage=usage and usage,
|
|
read_only=read_only,
|
|
),
|
|
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 super().options + ["bind"]
|