From a7c1693911b98cbc43f9a66cab2fa748b77e2a9f Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 24 May 2023 09:12:35 -0400 Subject: [PATCH] Set bind propagation mode for media (#4308) * Set bind propagation mode for media * Add some test cases --- supervisor/addons/model.py | 6 +- supervisor/config.py | 12 +- supervisor/docker/addon.py | 217 ++++++++++++++----------- supervisor/docker/audio.py | 38 +++-- supervisor/docker/const.py | 47 +++++- supervisor/docker/dns.py | 17 +- supervisor/docker/homeassistant.py | 146 ++++++++++------- supervisor/docker/observer.py | 4 +- supervisor/homeassistant/module.py | 8 +- tests/conftest.py | 2 + tests/docker/test_addon.py | 93 +++++++---- tests/docker/test_audio.py | 48 ++++++ tests/docker/test_dns.py | 38 +++++ tests/docker/test_observer.py | 38 +++++ tests/fixtures/basic-addon-config.json | 4 +- 15 files changed, 498 insertions(+), 220 deletions(-) create mode 100644 tests/docker/test_audio.py create mode 100644 tests/docker/test_dns.py create mode 100644 tests/docker/test_observer.py diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index c98a295c3..02d761b8c 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -532,14 +532,14 @@ class AddonModel(CoreSysAttributes, ABC): return ATTR_IMAGE not in self.data @property - def map_volumes(self) -> dict[str, str]: - """Return a dict of {volume: policy} from add-on.""" + def map_volumes(self) -> dict[str, bool]: + """Return a dict of {volume: read-only} from add-on.""" volumes = {} for volume in self.data[ATTR_MAP]: result = RE_VOLUME.match(volume) if not result: continue - volumes[result.group(1)] = result.group(2) or "ro" + volumes[result.group(1)] = result.group(2) != "rw" return volumes diff --git a/supervisor/config.py b/supervisor/config.py index fd55a6c52..927e149d5 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -181,9 +181,9 @@ class CoreConfig(FileConfiguration): return PurePath(os.environ[ENV_SUPERVISOR_SHARE]) @property - def path_extern_homeassistant(self) -> str: + def path_extern_homeassistant(self) -> PurePath: """Return config path external for Docker.""" - return str(PurePath(self.path_extern_supervisor, HOMEASSISTANT_CONFIG)) + return PurePath(self.path_extern_supervisor, HOMEASSISTANT_CONFIG) @property def path_homeassistant(self) -> Path: @@ -191,9 +191,9 @@ class CoreConfig(FileConfiguration): return self.path_supervisor / HOMEASSISTANT_CONFIG @property - def path_extern_ssl(self) -> str: + def path_extern_ssl(self) -> PurePath: """Return SSL path external for Docker.""" - return str(PurePath(self.path_extern_supervisor, HASSIO_SSL)) + return PurePath(self.path_extern_supervisor, HASSIO_SSL) @property def path_ssl(self) -> Path: @@ -291,9 +291,9 @@ class CoreConfig(FileConfiguration): return PurePath(self.path_extern_supervisor, SHARE_DATA) @property - def path_extern_dns(self) -> str: + def path_extern_dns(self) -> PurePath: """Return dns path external for Docker.""" - return str(PurePath(self.path_extern_supervisor, DNS_DATA)) + return PurePath(self.path_extern_supervisor, DNS_DATA) @property def path_dns(self) -> Path: diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 5ca0ff267..b80f5cce3 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING from awesomeversion import AwesomeVersion import docker +from docker.types import Mount import requests from ..addons.build import AddonBuild @@ -46,12 +47,16 @@ from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils import process_lock from ..utils.sentry import capture_exception from .const import ( - DBUS_PATH, - DBUS_VOLUME, ENV_TIME, ENV_TOKEN, ENV_TOKEN_OLD, + MOUNT_DBUS, + MOUNT_DEV, + MOUNT_DOCKER, + MOUNT_UDEV, Capabilities, + MountType, + PropagationMode, ) from .interface import DockerInterface @@ -320,74 +325,80 @@ class DockerAddon(DockerInterface): return None @property - def volumes(self) -> dict[str, dict[str, str]]: - """Generate volumes for mappings.""" + def mounts(self) -> list[Mount]: + """Return mounts for container.""" addon_mapping = self.addon.map_volumes - volumes = { - "/dev": {"bind": "/dev", "mode": "ro"}, - str(self.addon.path_extern_data): {"bind": "/data", "mode": "rw"}, - } + mounts = [ + MOUNT_DEV, + Mount( + type=MountType.BIND.value, + source=self.addon.path_extern_data.as_posix(), + target="/data", + read_only=False, + ), + ] # setup config mappings if MAP_CONFIG in addon_mapping: - volumes.update( - { - str(self.sys_config.path_extern_homeassistant): { - "bind": "/config", - "mode": addon_mapping[MAP_CONFIG], - } - } + mounts.append( + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_homeassistant.as_posix(), + target="/config", + read_only=addon_mapping[MAP_CONFIG], + ) ) if MAP_SSL in addon_mapping: - volumes.update( - { - str(self.sys_config.path_extern_ssl): { - "bind": "/ssl", - "mode": addon_mapping[MAP_SSL], - } - } + mounts.append( + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_ssl.as_posix(), + target="/ssl", + read_only=addon_mapping[MAP_SSL], + ) ) if MAP_ADDONS in addon_mapping: - volumes.update( - { - str(self.sys_config.path_extern_addons_local): { - "bind": "/addons", - "mode": addon_mapping[MAP_ADDONS], - } - } + mounts.append( + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_addons_local.as_posix(), + target="/addons", + read_only=addon_mapping[MAP_ADDONS], + ) ) if MAP_BACKUP in addon_mapping: - volumes.update( - { - str(self.sys_config.path_extern_backup): { - "bind": "/backup", - "mode": addon_mapping[MAP_BACKUP], - } - } + mounts.append( + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_backup.as_posix(), + target="/backup", + read_only=addon_mapping[MAP_BACKUP], + ) ) if MAP_SHARE in addon_mapping: - volumes.update( - { - str(self.sys_config.path_extern_share): { - "bind": "/share", - "mode": addon_mapping[MAP_SHARE], - } - } + mounts.append( + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_share.as_posix(), + target="/share", + read_only=addon_mapping[MAP_SHARE], + ) ) if MAP_MEDIA in addon_mapping: - volumes.update( - { - str(self.sys_config.path_extern_media): { - "bind": "/media", - "mode": addon_mapping[MAP_MEDIA], - } - } + mounts.append( + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_media.as_posix(), + target="/media", + read_only=addon_mapping[MAP_MEDIA], + propagation=PropagationMode.SLAVE.value, + ) ) # Init other hardware mappings @@ -397,72 +408,90 @@ class DockerAddon(DockerInterface): for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"): if not Path(gpio_path).exists(): continue - volumes.update({gpio_path: {"bind": gpio_path, "mode": "rw"}}) + mounts.append( + Mount( + type=MountType.BIND.value, + source=gpio_path, + target=gpio_path, + read_only=False, + ) + ) # DeviceTree support if self.addon.with_devicetree: - volumes.update( - { - "/sys/firmware/devicetree/base": { - "bind": "/device-tree", - "mode": "ro", - } - } + mounts.append( + Mount( + type=MountType.BIND.value, + source="/sys/firmware/devicetree/base", + target="/device-tree", + read_only=True, + ) ) # Host udev support if self.addon.with_udev: - volumes.update({"/run/udev": {"bind": "/run/udev", "mode": "ro"}}) + mounts.append(MOUNT_UDEV) # Kernel Modules support if self.addon.with_kernel_modules: - volumes.update({"/lib/modules": {"bind": "/lib/modules", "mode": "ro"}}) + mounts.append( + Mount( + type=MountType.BIND.value, + source="/lib/modules", + target="/lib/modules", + read_only=True, + ) + ) # Docker API support if not self.addon.protected and self.addon.access_docker_api: - volumes.update( - {"/run/docker.sock": {"bind": "/run/docker.sock", "mode": "ro"}} - ) + mounts.append(MOUNT_DOCKER) # Host D-Bus system if self.addon.host_dbus: - volumes.update({DBUS_PATH: DBUS_VOLUME}) + mounts.append(MOUNT_DBUS) # Configuration Audio if self.addon.with_audio: - volumes.update( - { - str(self.addon.path_extern_pulse): { - "bind": "/etc/pulse/client.conf", - "mode": "ro", - }, - str(self.sys_plugins.audio.path_extern_pulse): { - "bind": "/run/audio", - "mode": "ro", - }, - str(self.sys_plugins.audio.path_extern_asound): { - "bind": "/etc/asound.conf", - "mode": "ro", - }, - } - ) + mounts += [ + Mount( + type=MountType.BIND.value, + source=self.sys_homeassistant.path_extern_pulse.as_posix(), + target="/etc/pulse/client.conf", + read_only=True, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_plugins.audio.path_extern_pulse.as_posix(), + target="/run/audio", + read_only=True, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_plugins.audio.path_extern_asound.as_posix(), + target="/etc/asound.conf", + read_only=True, + ), + ] # System Journal access if self.addon.with_journald: - volumes.update( - { - str(SYSTEMD_JOURNAL_PERSISTENT): { - "bind": str(SYSTEMD_JOURNAL_PERSISTENT), - "mode": "ro", - }, - str(SYSTEMD_JOURNAL_VOLATILE): { - "bind": str(SYSTEMD_JOURNAL_VOLATILE), - "mode": "ro", - }, - } - ) + mounts += [ + Mount( + type=MountType.BIND.value, + source=SYSTEMD_JOURNAL_PERSISTENT.as_posix(), + target=SYSTEMD_JOURNAL_PERSISTENT.as_posix(), + read_only=True, + ), + Mount( + type=MountType.BIND.value, + source=SYSTEMD_JOURNAL_VOLATILE.as_posix(), + target=SYSTEMD_JOURNAL_VOLATILE.as_posix(), + read_only=True, + ), + ] - return volumes + return mounts def _run(self) -> None: """Run Docker image. @@ -503,7 +532,7 @@ class DockerAddon(DockerInterface): cpu_rt_runtime=self.cpu_rt_runtime, security_opt=self.security_opt, environment=self.environment, - volumes=self.volumes, + mounts=self.mounts, tmpfs=self.tmpfs, oom_score_adj=200, ) diff --git a/supervisor/docker/audio.py b/supervisor/docker/audio.py index e171761b6..82c57fec7 100644 --- a/supervisor/docker/audio.py +++ b/supervisor/docker/audio.py @@ -2,11 +2,20 @@ import logging import docker +from docker.types import Mount from ..const import DOCKER_CPU_RUNTIME_ALLOCATION, MACHINE_ID from ..coresys import CoreSysAttributes from ..hardware.const import PolicyGroup -from .const import ENV_TIME, Capabilities +from .const import ( + ENV_TIME, + MOUNT_DBUS, + MOUNT_DEV, + MOUNT_MACHINE_ID, + MOUNT_UDEV, + Capabilities, + MountType, +) from .interface import DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -28,20 +37,25 @@ class DockerAudio(DockerInterface, CoreSysAttributes): return AUDIO_DOCKER_NAME @property - def volumes(self) -> dict[str, dict[str, str]]: - """Return Volumes for the mount.""" - volumes = { - "/dev": {"bind": "/dev", "mode": "ro"}, - str(self.sys_config.path_extern_audio): {"bind": "/data", "mode": "rw"}, - "/run/dbus": {"bind": "/run/dbus", "mode": "ro"}, - "/run/udev": {"bind": "/run/udev", "mode": "ro"}, - } + def mounts(self) -> list[Mount]: + """Return mounts for container.""" + mounts = [ + MOUNT_DEV, + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_audio.as_posix(), + target="/data", + read_only=False, + ), + MOUNT_DBUS, + MOUNT_UDEV, + ] # Machine ID if MACHINE_ID.exists(): - volumes.update({str(MACHINE_ID): {"bind": str(MACHINE_ID), "mode": "ro"}}) + mounts.append(MOUNT_MACHINE_ID) - return volumes + return mounts @property def cgroups_rules(self) -> list[str]: @@ -96,7 +110,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes): environment={ ENV_TIME: self.sys_timezone, }, - volumes=self.volumes, + mounts=self.mounts, ) self._meta = docker_container.attrs diff --git a/supervisor/docker/const.py b/supervisor/docker/const.py index 324def624..f8e3edbeb 100644 --- a/supervisor/docker/const.py +++ b/supervisor/docker/const.py @@ -1,6 +1,10 @@ """Docker constants.""" from enum import Enum +from docker.types import Mount + +from ..const import MACHINE_ID + class Capabilities(str, Enum): """Linux Capabilities.""" @@ -40,11 +44,50 @@ class RestartPolicy(str, Enum): ALWAYS = "always" -DBUS_PATH = "/run/dbus" -DBUS_VOLUME = {"bind": DBUS_PATH, "mode": "ro"} +class MountType(str, Enum): + """Mount type.""" + + BIND = "bind" + VOLUME = "volume" + TMPFS = "tmpfs" + NPIPE = "npipe" + + +class PropagationMode(str, Enum): + """Propagataion mode, only for bind type mounts.""" + + PRIVATE = "private" + SHARED = "shared" + SLAVE = "slave" + RPRIVATE = "rprivate" + RSHARED = "rshared" + RSLAVE = "rslave" + ENV_TIME = "TZ" ENV_TOKEN = "SUPERVISOR_TOKEN" ENV_TOKEN_OLD = "HASSIO_TOKEN" LABEL_MANAGED = "supervisor_managed" + +MOUNT_DBUS = Mount( + type=MountType.BIND.value, source="/run/dbus", target="/run/dbus", read_only=True +) +MOUNT_DEV = Mount( + type=MountType.BIND.value, source="/dev", target="/dev", read_only=True +) +MOUNT_DOCKER = Mount( + type=MountType.BIND.value, + source="/run/docker.sock", + target="/run/docker.sock", + read_only=True, +) +MOUNT_MACHINE_ID = Mount( + type=MountType.BIND.value, + source=MACHINE_ID.as_posix(), + target=MACHINE_ID.as_posix(), + read_only=True, +) +MOUNT_UDEV = Mount( + type=MountType.BIND.value, source="/run/udev", target="/run/udev", read_only=True +) diff --git a/supervisor/docker/dns.py b/supervisor/docker/dns.py index a945010a0..378a435b6 100644 --- a/supervisor/docker/dns.py +++ b/supervisor/docker/dns.py @@ -1,8 +1,10 @@ """DNS docker object.""" import logging +from docker.types import Mount + from ..coresys import CoreSysAttributes -from .const import DBUS_PATH, DBUS_VOLUME, ENV_TIME +from .const import ENV_TIME, MOUNT_DBUS, MountType from .interface import DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -46,10 +48,15 @@ class DockerDNS(DockerInterface, CoreSysAttributes): detach=True, security_opt=self.security_opt, environment={ENV_TIME: self.sys_timezone}, - volumes={ - str(self.sys_config.path_extern_dns): {"bind": "/config", "mode": "rw"}, - DBUS_PATH: DBUS_VOLUME, - }, + mounts=[ + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_dns.as_posix(), + target="/config", + read_only=False, + ), + MOUNT_DBUS, + ], oom_score_adj=-300, ) diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index 17b050410..fa87cede4 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -5,13 +5,24 @@ import logging from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import docker +from docker.types import Mount import requests from ..const import LABEL_MACHINE, MACHINE_ID from ..exceptions import DockerError from ..hardware.const import PolicyGroup from ..homeassistant.const import LANDINGPAGE -from .const import ENV_TIME, ENV_TOKEN, ENV_TOKEN_OLD +from .const import ( + ENV_TIME, + ENV_TOKEN, + ENV_TOKEN_OLD, + MOUNT_DBUS, + MOUNT_DEV, + MOUNT_MACHINE_ID, + MOUNT_UDEV, + MountType, + PropagationMode, +) from .interface import CommandReturn, DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -62,56 +73,64 @@ class DockerHomeAssistant(DockerInterface): ) @property - def volumes(self) -> dict[str, dict[str, str]]: - """Return Volumes for the mount.""" - volumes = { - "/dev": {"bind": "/dev", "mode": "ro"}, - "/run/dbus": {"bind": "/run/dbus", "mode": "ro"}, - "/run/udev": {"bind": "/run/udev", "mode": "ro"}, - } - - # Add folders - volumes.update( - { - str(self.sys_config.path_extern_homeassistant): { - "bind": "/config", - "mode": "rw", - }, - str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"}, - str(self.sys_config.path_extern_share): { - "bind": "/share", - "mode": "rw", - }, - str(self.sys_config.path_extern_media): { - "bind": "/media", - "mode": "rw", - }, - } - ) + def mounts(self) -> list[Mount]: + """Return mounts for container.""" + mounts = [ + MOUNT_DEV, + MOUNT_DBUS, + MOUNT_UDEV, + # Add folders + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_homeassistant.as_posix(), + target="/config", + read_only=False, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_ssl.as_posix(), + target="/ssl", + read_only=True, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_share.as_posix(), + target="/share", + read_only=False, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_media.as_posix(), + target="/media", + read_only=False, + propagation=PropagationMode.SLAVE.value, + ), + # Configuration audio + Mount( + type=MountType.BIND.value, + source=self.sys_homeassistant.path_extern_pulse.as_posix(), + target="/etc/pulse/client.conf", + read_only=True, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_plugins.audio.path_extern_pulse.as_posix(), + target="/run/audio", + read_only=True, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_plugins.audio.path_extern_asound.as_posix(), + target="/etc/asound.conf", + read_only=True, + ), + ] # Machine ID if MACHINE_ID.exists(): - volumes.update({str(MACHINE_ID): {"bind": str(MACHINE_ID), "mode": "ro"}}) + mounts.append(MOUNT_MACHINE_ID) - # Configuration Audio - volumes.update( - { - str(self.sys_homeassistant.path_extern_pulse): { - "bind": "/etc/pulse/client.conf", - "mode": "ro", - }, - str(self.sys_plugins.audio.path_extern_pulse): { - "bind": "/run/audio", - "mode": "ro", - }, - str(self.sys_plugins.audio.path_extern_asound): { - "bind": "/etc/asound.conf", - "mode": "ro", - }, - } - ) - - return volumes + return mounts def _run(self) -> None: """Run Docker image. @@ -135,7 +154,7 @@ class DockerHomeAssistant(DockerInterface): init=False, security_opt=self.security_opt, network_mode="host", - volumes=self.volumes, + mounts=self.mounts, device_cgroup_rules=self.cgroups_rules, extra_hosts={ "supervisor": self.sys_docker.network.supervisor, @@ -172,17 +191,26 @@ class DockerHomeAssistant(DockerInterface): detach=True, stdout=True, stderr=True, - volumes={ - str(self.sys_config.path_extern_homeassistant): { - "bind": "/config", - "mode": "rw", - }, - str(self.sys_config.path_extern_ssl): {"bind": "/ssl", "mode": "ro"}, - str(self.sys_config.path_extern_share): { - "bind": "/share", - "mode": "ro", - }, - }, + mounts=[ + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_homeassistant.as_posix(), + target="/config", + read_only=False, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_ssl.as_posix(), + target="/ssl", + read_only=True, + ), + Mount( + type=MountType.BIND.value, + source=self.sys_config.path_extern_share.as_posix(), + target="/share", + read_only=False, + ), + ], environment={ENV_TIME: self.sys_timezone}, ) diff --git a/supervisor/docker/observer.py b/supervisor/docker/observer.py index dd0dddeeb..ad3a3946f 100644 --- a/supervisor/docker/observer.py +++ b/supervisor/docker/observer.py @@ -3,7 +3,7 @@ import logging from ..const import DOCKER_NETWORK_MASK from ..coresys import CoreSysAttributes -from .const import ENV_TIME, ENV_TOKEN, RestartPolicy +from .const import ENV_TIME, ENV_TOKEN, MOUNT_DOCKER, RestartPolicy from .interface import DockerInterface _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ class DockerObserver(DockerInterface, CoreSysAttributes): ENV_TOKEN: self.sys_plugins.observer.supervisor_token, ENV_NETWORK_MASK: DOCKER_NETWORK_MASK, }, - volumes={"/run/docker.sock": {"bind": "/run/docker.sock", "mode": "ro"}}, + mounts=[MOUNT_DOCKER], ports={"80/tcp": 4357}, oom_score_adj=-300, ) diff --git a/supervisor/homeassistant/module.py b/supervisor/homeassistant/module.py index 2629530b2..0a9bf2077 100644 --- a/supervisor/homeassistant/module.py +++ b/supervisor/homeassistant/module.py @@ -2,7 +2,7 @@ import asyncio from ipaddress import IPv4Address import logging -from pathlib import Path +from pathlib import Path, PurePath import shutil import tarfile from tempfile import TemporaryDirectory @@ -219,14 +219,14 @@ class HomeAssistant(FileConfiguration, CoreSysAttributes): self._data[ATTR_REFRESH_TOKEN] = value @property - def path_pulse(self): + def path_pulse(self) -> Path: """Return path to asound config.""" return Path(self.sys_config.path_tmp, "homeassistant_pulse") @property - def path_extern_pulse(self): + def path_extern_pulse(self) -> PurePath: """Return path to asound config for Docker.""" - return Path(self.sys_config.path_extern_tmp, "homeassistant_pulse") + return PurePath(self.sys_config.path_extern_tmp, "homeassistant_pulse") @property def audio_output(self) -> str | None: diff --git a/tests/conftest.py b/tests/conftest.py index 6775fca41..9fb4f1cb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -374,6 +374,8 @@ async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path: coresys.config.path_backup.mkdir() coresys.config.path_tmp.mkdir() coresys.config.path_homeassistant.mkdir() + coresys.config.path_audio.mkdir() + coresys.config.path_dns.mkdir() yield tmp_path diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index 26a4cb60d..5da414dd9 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -3,13 +3,13 @@ from ipaddress import IPv4Address from unittest.mock import MagicMock, Mock, PropertyMock, patch from docker.errors import NotFound +from docker.types import Mount import pytest from supervisor.addons import validate as vd from supervisor.addons.addon import Addon from supervisor.addons.model import Data from supervisor.addons.options import AddonOptions -from supervisor.const import SYSTEMD_JOURNAL_PERSISTENT, SYSTEMD_JOURNAL_VOLATILE from supervisor.coresys import CoreSys from supervisor.docker.addon import DockerAddon from supervisor.exceptions import CoreDNSError, DockerNotFound @@ -66,18 +66,23 @@ def test_base_volumes_included( docker_addon = get_docker_addon( coresys, addonsdata_system, "basic-addon-config.json" ) - volumes = docker_addon.volumes # Dev added as ro - assert "/dev" in volumes - assert volumes["/dev"]["bind"] == "/dev" - assert volumes["/dev"]["mode"] == "ro" + assert ( + Mount(type="bind", source="/dev", target="/dev", read_only=True) + in docker_addon.mounts + ) # Data added as rw - data_path = str(docker_addon.addon.path_extern_data) - assert data_path in volumes - assert volumes[data_path]["bind"] == "/data" - assert volumes[data_path]["mode"] == "rw" + assert ( + Mount( + type="bind", + source=docker_addon.addon.path_extern_data.as_posix(), + target="/data", + read_only=False, + ) + in docker_addon.mounts + ) def test_addon_map_folder_defaults( @@ -87,22 +92,42 @@ def test_addon_map_folder_defaults( docker_addon = get_docker_addon( coresys, addonsdata_system, "basic-addon-config.json" ) - volumes = docker_addon.volumes - # Config added and is marked rw - config_path = str(docker_addon.sys_config.path_extern_homeassistant) - assert config_path in volumes - assert volumes[config_path]["bind"] == "/config" - assert volumes[config_path]["mode"] == "rw" + assert ( + Mount( + type="bind", + source=coresys.config.path_extern_homeassistant.as_posix(), + target="/config", + read_only=False, + ) + in docker_addon.mounts + ) # SSL added and defaults to ro - ssl_path = str(docker_addon.sys_config.path_extern_ssl) - assert ssl_path in volumes - assert volumes[ssl_path]["bind"] == "/ssl" - assert volumes[ssl_path]["mode"] == "ro" + assert ( + Mount( + type="bind", + source=coresys.config.path_extern_ssl.as_posix(), + target="/ssl", + read_only=True, + ) + in docker_addon.mounts + ) + + # Media added and propagation set + assert ( + Mount( + type="bind", + source=coresys.config.path_extern_media.as_posix(), + target="/media", + read_only=True, + propagation="slave", + ) + in docker_addon.mounts + ) # Share not mapped - assert str(docker_addon.sys_config.path_extern_share) not in volumes + assert "/share" not in [mount["Target"] for mount in docker_addon.mounts] def test_journald_addon( @@ -112,18 +137,25 @@ def test_journald_addon( docker_addon = get_docker_addon( coresys, addonsdata_system, "journald-addon-config.json" ) - volumes = docker_addon.volumes - assert str(SYSTEMD_JOURNAL_PERSISTENT) in volumes - assert volumes.get(str(SYSTEMD_JOURNAL_PERSISTENT)).get("bind") == str( - SYSTEMD_JOURNAL_PERSISTENT + assert ( + Mount( + type="bind", + source="/var/log/journal", + target="/var/log/journal", + read_only=True, + ) + in docker_addon.mounts ) - assert volumes.get(str(SYSTEMD_JOURNAL_PERSISTENT)).get("mode") == "ro" - assert str(SYSTEMD_JOURNAL_VOLATILE) in volumes - assert volumes.get(str(SYSTEMD_JOURNAL_VOLATILE)).get("bind") == str( - SYSTEMD_JOURNAL_VOLATILE + assert ( + Mount( + type="bind", + source="/run/log/journal", + target="/run/log/journal", + read_only=True, + ) + in docker_addon.mounts ) - assert volumes.get(str(SYSTEMD_JOURNAL_VOLATILE)).get("mode") == "ro" def test_not_journald_addon( @@ -133,9 +165,8 @@ def test_not_journald_addon( docker_addon = get_docker_addon( coresys, addonsdata_system, "basic-addon-config.json" ) - volumes = docker_addon.volumes - assert str(SYSTEMD_JOURNAL_PERSISTENT) not in volumes + assert "/var/log/journal" not in [mount["Target"] for mount in docker_addon.mounts] async def test_addon_run_docker_error( diff --git a/tests/docker/test_audio.py b/tests/docker/test_audio.py new file mode 100644 index 000000000..0b3e23b97 --- /dev/null +++ b/tests/docker/test_audio.py @@ -0,0 +1,48 @@ +"""Test audio plugin container.""" + +from ipaddress import IPv4Address +from pathlib import Path +from unittest.mock import patch + +from docker.types import Mount + +from supervisor.coresys import CoreSys +from supervisor.docker.manager import DockerAPI + + +async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern): + """Test starting audio plugin.""" + config_file = tmp_supervisor_data / "audio" / "pulse_audio.json" + assert not config_file.exists() + + with patch.object(DockerAPI, "run") as run: + await coresys.plugins.audio.start() + + run.assert_called_once() + assert run.call_args.kwargs["ipv4"] == IPv4Address("172.30.32.4") + assert run.call_args.kwargs["name"] == "hassio_audio" + assert run.call_args.kwargs["hostname"] == "hassio-audio" + assert run.call_args.kwargs["cap_add"] == ["SYS_NICE", "SYS_RESOURCE"] + assert run.call_args.kwargs["ulimits"] == [ + {"Name": "rtprio", "Soft": 10, "Hard": 10} + ] + assert run.call_args.kwargs["mounts"] == [ + Mount(type="bind", source="/dev", target="/dev", read_only=True), + Mount( + type="bind", + source=coresys.config.path_extern_audio.as_posix(), + target="/data", + read_only=False, + ), + Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True), + Mount(type="bind", source="/run/udev", target="/run/udev", read_only=True), + Mount( + type="bind", + source="/etc/machine-id", + target="/etc/machine-id", + read_only=True, + ), + ] + assert "volumes" not in run.call_args.kwargs + + assert config_file.exists() diff --git a/tests/docker/test_dns.py b/tests/docker/test_dns.py new file mode 100644 index 000000000..767f2267c --- /dev/null +++ b/tests/docker/test_dns.py @@ -0,0 +1,38 @@ +"""Test DNS plugin container.""" + +from ipaddress import IPv4Address +from pathlib import Path +from unittest.mock import patch + +from docker.types import Mount + +from supervisor.coresys import CoreSys +from supervisor.docker.manager import DockerAPI + + +async def test_start(coresys: CoreSys, tmp_supervisor_data: Path, path_extern): + """Test starting dns plugin.""" + config_file = tmp_supervisor_data / "dns" / "coredns.json" + assert not config_file.exists() + + with patch.object(DockerAPI, "run") as run: + await coresys.plugins.dns.start() + + run.assert_called_once() + assert run.call_args.kwargs["ipv4"] == IPv4Address("172.30.32.3") + assert run.call_args.kwargs["name"] == "hassio_dns" + assert run.call_args.kwargs["hostname"] == "hassio-dns" + assert run.call_args.kwargs["dns"] is False + assert run.call_args.kwargs["oom_score_adj"] == -300 + assert run.call_args.kwargs["mounts"] == [ + Mount( + type="bind", + source=coresys.config.path_extern_dns.as_posix(), + target="/config", + read_only=False, + ), + Mount(type="bind", source="/run/dbus", target="/run/dbus", read_only=True), + ] + assert "volumes" not in run.call_args.kwargs + + assert config_file.exists() diff --git a/tests/docker/test_observer.py b/tests/docker/test_observer.py new file mode 100644 index 000000000..50c074c41 --- /dev/null +++ b/tests/docker/test_observer.py @@ -0,0 +1,38 @@ +"""Test Observer plugin container.""" + +from ipaddress import IPv4Address, ip_network +from unittest.mock import patch + +from docker.types import Mount + +from supervisor.coresys import CoreSys +from supervisor.docker.manager import DockerAPI + + +async def test_start(coresys: CoreSys): + """Test starting observer plugin.""" + with patch.object(DockerAPI, "run") as run: + await coresys.plugins.observer.start() + + run.assert_called_once() + assert run.call_args.kwargs["ipv4"] == IPv4Address("172.30.32.6") + assert run.call_args.kwargs["name"] == "hassio_observer" + assert run.call_args.kwargs["hostname"] == "hassio-observer" + assert run.call_args.kwargs["restart_policy"] == {"Name": "always"} + assert run.call_args.kwargs["extra_hosts"] == { + "supervisor": IPv4Address("172.30.32.2") + } + assert run.call_args.kwargs["oom_score_adj"] == -300 + assert run.call_args.kwargs["environment"]["NETWORK_MASK"] == ip_network( + "172.30.32.0/23" + ) + assert run.call_args.kwargs["ports"] == {"80/tcp": 4357} + assert run.call_args.kwargs["mounts"] == [ + Mount( + type="bind", + source="/run/docker.sock", + target="/run/docker.sock", + read_only=True, + ), + ] + assert "volumes" not in run.call_args.kwargs diff --git a/tests/fixtures/basic-addon-config.json b/tests/fixtures/basic-addon-config.json index 18b4890c8..bb17bda3f 100644 --- a/tests/fixtures/basic-addon-config.json +++ b/tests/fixtures/basic-addon-config.json @@ -7,8 +7,8 @@ "url": "https://www.home-assistant.io/", "startup": "application", "boot": "auto", - "map": ["config:rw", "ssl"], + "map": ["config:rw", "ssl", "media"], "options": {}, "schema": {}, "image": "test/{arch}-my-custom-addon" -} \ No newline at end of file +}