diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index 5a679ccb3..a4f74a31f 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -32,6 +32,7 @@ from ..const import ( ATTR_IMAGE, ATTR_INGRESS, ATTR_INIT, + ATTR_JOURNALD, ATTR_KERNEL_MODULES, ATTR_LEGACY, ATTR_LOCATON, @@ -550,6 +551,11 @@ class AddonModel(CoreSysAttributes, ABC): return None return UiOptions(self.coresys)(raw_schema) + @property + def with_journald(self) -> bool: + """Return True if the add-on accesses the system journal.""" + return self.data[ATTR_JOURNALD] + def __eq__(self, other): """Compaired add-on objects.""" if not isinstance(other, AddonModel): diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index 1344150a4..700be80cf 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -45,6 +45,7 @@ from ..const import ( ATTR_INGRESS_PORT, ATTR_INGRESS_TOKEN, ATTR_INIT, + ATTR_JOURNALD, ATTR_KERNEL_MODULES, ATTR_LEGACY, ATTR_LOCATON, @@ -299,6 +300,7 @@ _SCHEMA_ADDON_CONFIG = vol.Schema( vol.Optional(ATTR_TIMEOUT, default=10): vol.All( vol.Coerce(int), vol.Range(min=10, max=300) ), + vol.Optional(ATTR_JOURNALD, default=False): vol.Boolean(), }, extra=vol.REMOVE_EXTRA, ) diff --git a/supervisor/const.py b/supervisor/const.py index e257c9270..192e5d87c 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -27,6 +27,7 @@ MACHINE_ID = Path("/etc/machine-id") SOCKET_DBUS = Path("/run/dbus/system_bus_socket") SOCKET_DOCKER = Path("/run/docker.sock") RUN_SUPERVISOR_STATE = Path("/run/supervisor") +SYSTEMD_JOURNAL = Path("/var/logs/journal") DOCKER_NETWORK = "hassio" DOCKER_NETWORK_MASK = ip_network("172.30.32.0/23") @@ -279,6 +280,7 @@ ATTR_SUPERVISOR_INTERNET = "supervisor_internet" ATTR_SUPPORTED = "supported" ATTR_SUPPORTED_ARCH = "supported_arch" ATTR_SYSTEM = "system" +ATTR_JOURNALD = "journald" ATTR_TIMEOUT = "timeout" ATTR_TIMEZONE = "timezone" ATTR_TITLE = "title" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 62ca87554..c793c2ad1 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -26,6 +26,7 @@ from ..const import ( MAP_SSL, SECURITY_DISABLE, SECURITY_PROFILE, + SYSTEMD_JOURNAL, ) from ..coresys import CoreSys from ..exceptions import CoreDNSError, DockerError, DockerNotFound, HardwareNotFound @@ -413,6 +414,17 @@ class DockerAddon(DockerInterface): } ) + # System Journal access + if self.addon.with_journald: + volumes.update( + { + str(SYSTEMD_JOURNAL): { + "bind": str(SYSTEMD_JOURNAL), + "mode": "ro", + } + } + ) + return volumes def _run(self) -> None: diff --git a/tests/docker/__init__.py b/tests/docker/__init__.py new file mode 100644 index 000000000..65ac125c1 --- /dev/null +++ b/tests/docker/__init__.py @@ -0,0 +1 @@ +"""Docker tests.""" diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py new file mode 100644 index 000000000..269147aff --- /dev/null +++ b/tests/docker/test_addon.py @@ -0,0 +1,119 @@ +"""Test docker addon setup.""" +from typing import Dict +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest + +from supervisor.addons import validate as vd +from supervisor.addons.addon import Addon +from supervisor.addons.model import Data +from supervisor.const import SYSTEMD_JOURNAL +from supervisor.coresys import CoreSys +from supervisor.docker.addon import DockerAddon + +from ..common import load_json_fixture + + +@pytest.fixture(name="addonsdata_system") +def fixture_addonsdata_system() -> Dict[str, Data]: + """Mock AddonsData.system.""" + with patch( + "supervisor.addons.data.AddonsData.system", new_callable=PropertyMock + ) as mock: + yield mock + + +@pytest.fixture(name="addonsdata_user", autouse=True) +def fixture_addonsdata_user() -> Dict[str, Data]: + """Mock AddonsData.user.""" + with patch( + "supervisor.addons.data.AddonsData.user", new_callable=PropertyMock + ) as mock: + mock.return_value = MagicMock() + yield mock + + +@pytest.fixture(name="os_environ", autouse=True) +def fixture_os_environ(): + """Mock os.environ.""" + with patch("supervisor.config.os.environ") as mock: + yield mock + + +def get_docker_addon( + coresys: CoreSys, addonsdata_system: Dict[str, Data], config_file: str +): + """Make and return docker addon object.""" + config = vd.SCHEMA_ADDON_CONFIG(load_json_fixture(config_file)) + slug = config.get("slug") + addonsdata_system.return_value = {slug: config} + + addon = Addon(coresys, config.get("slug")) + docker_addon = DockerAddon(coresys, addon) + return docker_addon + + +def test_base_volumes_included(coresys: CoreSys, addonsdata_system: Dict[str, Data]): + """Dev and data volumes always 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" + + # 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" + + +def test_addon_map_folder_defaults( + coresys: CoreSys, addonsdata_system: Dict[str, Data] +): + """Validate defaults for mapped folders in addons.""" + 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" + + # 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" + + # Share not mapped + assert str(docker_addon.sys_config.path_extern_share) not in volumes + + +def test_journald_addon(coresys: CoreSys, addonsdata_system: Dict[str, Data]): + """Validate volume for journald option.""" + docker_addon = get_docker_addon( + coresys, addonsdata_system, "journald-addon-config.json" + ) + volumes = docker_addon.volumes + + assert str(SYSTEMD_JOURNAL) in volumes + assert volumes.get(str(SYSTEMD_JOURNAL)).get("bind") == str(SYSTEMD_JOURNAL) + assert volumes.get(str(SYSTEMD_JOURNAL)).get("mode") == "ro" + + +def test_not_journald_addon(coresys: CoreSys, addonsdata_system: Dict[str, Data]): + """Validate journald option defaults off.""" + docker_addon = get_docker_addon( + coresys, addonsdata_system, "basic-addon-config.json" + ) + volumes = docker_addon.volumes + + assert str(SYSTEMD_JOURNAL) not in volumes diff --git a/tests/fixtures/journald-addon-config.json b/tests/fixtures/journald-addon-config.json new file mode 100644 index 000000000..70529c6f4 --- /dev/null +++ b/tests/fixtures/journald-addon-config.json @@ -0,0 +1,13 @@ +{ + "name": "Journald Add-on", + "version": "1.0.1", + "slug": "journald_addon", + "description": "This is a Test Add-on that uses journald", + "arch": ["amd64"], + "url": "https://www.home-assistant.io/", + "startup": "application", + "boot": "auto", + "journald": true, + "options": {}, + "schema": {} +}