diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index 0b123c1f336..f5de7f18a20 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -48,7 +48,6 @@ ATTR_VERSION, ATTR_WATCHDOG, DNS_SUFFIX, - MAP_ADDON_CONFIG, AddonBoot, AddonStartup, AddonState, @@ -85,6 +84,7 @@ WATCHDOG_THROTTLE_MAX_CALLS, WATCHDOG_THROTTLE_PERIOD, AddonBackupMode, + MappingType, ) from .model import AddonModel, Data from .options import AddonOptions @@ -467,7 +467,7 @@ def path_extern_data(self) -> PurePath: @property def addon_config_used(self) -> bool: """Add-on is using its public config folder.""" - return MAP_ADDON_CONFIG in self.map_volumes + return MappingType.ADDON_CONFIG in self.map_volumes @property def path_config(self) -> Path: diff --git a/supervisor/addons/configuration.py b/supervisor/addons/configuration.py new file mode 100644 index 00000000000..09594f6c446 --- /dev/null +++ b/supervisor/addons/configuration.py @@ -0,0 +1,11 @@ +"""Confgiuration Objects for Addon Config.""" + +from dataclasses import dataclass + + +@dataclass(slots=True) +class FolderMapping: + """Represent folder mapping configuration.""" + + path: str | None + read_only: bool diff --git a/supervisor/addons/const.py b/supervisor/addons/const.py index 5a9c892e74e..edc4d415d7a 100644 --- a/supervisor/addons/const.py +++ b/supervisor/addons/const.py @@ -12,8 +12,25 @@ class AddonBackupMode(StrEnum): COLD = "cold" +class MappingType(StrEnum): + """Mapping type of an Add-on Folder.""" + + DATA = "data" + CONFIG = "config" + SSL = "ssl" + ADDONS = "addons" + BACKUP = "backup" + SHARE = "share" + MEDIA = "media" + HOMEASSISTANT_CONFIG = "homeassistant_config" + ALL_ADDON_CONFIGS = "all_addon_configs" + ADDON_CONFIG = "addon_config" + + ATTR_BACKUP = "backup" ATTR_CODENOTARY = "codenotary" +ATTR_READ_ONLY = "read_only" +ATTR_PATH = "path" WATCHDOG_RETRY_SECONDS = 10 WATCHDOG_MAX_ATTEMPTS = 5 WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30) diff --git a/supervisor/addons/model.py b/supervisor/addons/model.py index f1714eaeffd..bc3f161c7d5 100644 --- a/supervisor/addons/model.py +++ b/supervisor/addons/model.py @@ -65,6 +65,7 @@ ATTR_TIMEOUT, ATTR_TMPFS, ATTR_TRANSLATIONS, + ATTR_TYPE, ATTR_UART, ATTR_UDEV, ATTR_URL, @@ -86,9 +87,17 @@ from ..jobs.const import JOB_GROUP_ADDON from ..jobs.job_group import JobGroup from ..utils import version_is_new_enough -from .const import ATTR_BACKUP, ATTR_CODENOTARY, AddonBackupMode +from .configuration import FolderMapping +from .const import ( + ATTR_BACKUP, + ATTR_CODENOTARY, + ATTR_PATH, + ATTR_READ_ONLY, + AddonBackupMode, + MappingType, +) from .options import AddonOptions, UiOptions -from .validate import RE_SERVICE, RE_VOLUME +from .validate import RE_SERVICE _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -538,14 +547,13 @@ def need_build(self) -> bool: return ATTR_IMAGE not in self.data @property - def map_volumes(self) -> dict[str, bool]: - """Return a dict of {volume: read-only} from add-on.""" + def map_volumes(self) -> dict[MappingType, FolderMapping]: + """Return a dict of {MappingType: FolderMapping} 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) != "rw" + volumes[MappingType(volume[ATTR_TYPE])] = FolderMapping( + volume.get(ATTR_PATH), volume[ATTR_READ_ONLY] + ) return volumes diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index 2702cc7a2eb..69faeba4327 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -81,6 +81,7 @@ ATTR_TIMEOUT, ATTR_TMPFS, ATTR_TRANSLATIONS, + ATTR_TYPE, ATTR_UART, ATTR_UDEV, ATTR_URL, @@ -91,9 +92,6 @@ ATTR_VIDEO, ATTR_WATCHDOG, ATTR_WEBUI, - MAP_ADDON_CONFIG, - MAP_CONFIG, - MAP_HOMEASSISTANT_CONFIG, ROLE_ALL, ROLE_DEFAULT, AddonBoot, @@ -112,13 +110,21 @@ uuid_match, version_tag, ) -from .const import ATTR_BACKUP, ATTR_CODENOTARY, RE_SLUG, AddonBackupMode +from .const import ( + ATTR_BACKUP, + ATTR_CODENOTARY, + ATTR_PATH, + ATTR_READ_ONLY, + RE_SLUG, + AddonBackupMode, + MappingType, +) from .options import RE_SCHEMA_ELEMENT _LOGGER: logging.Logger = logging.getLogger(__name__) RE_VOLUME = re.compile( - r"^(config|ssl|addons|backup|share|media|homeassistant_config|all_addon_configs|addon_config)(?::(rw|ro))?$" + r"^(data|config|ssl|addons|backup|share|media|homeassistant_config|all_addon_configs|addon_config)(?::(rw|ro))?$" ) RE_SERVICE = re.compile(r"^(?Pmqtt|mysql):(?Pprovide|want|need)$") @@ -266,26 +272,45 @@ def _migrate(config: dict[str, Any]): name, ) + # 2023-11 "map" entries can also be dict to allow path configuration + volumes = [] + for entry in config.get(ATTR_MAP, []): + if isinstance(entry, dict): + volumes.append(entry) + if isinstance(entry, str): + result = RE_VOLUME.match(entry) + if not result: + continue + volumes.append( + { + ATTR_TYPE: result.group(1), + ATTR_READ_ONLY: result.group(2) != "rw", + } + ) + + if volumes: + config[ATTR_MAP] = volumes + # 2023-10 "config" became "homeassistant" so /config can be used for addon's public config - volumes = [RE_VOLUME.match(entry) for entry in config.get(ATTR_MAP, [])] - if any(volume and volume.group(1) == MAP_CONFIG for volume in volumes): + if any(volume[ATTR_TYPE] == MappingType.CONFIG for volume in volumes): if any( volume - and volume.group(1) in {MAP_ADDON_CONFIG, MAP_HOMEASSISTANT_CONFIG} + and volume[ATTR_TYPE] + in {MappingType.ADDON_CONFIG, MappingType.HOMEASSISTANT_CONFIG} for volume in volumes ): _LOGGER.warning( "Add-on config using incompatible map options, '%s' and '%s' are ignored if '%s' is included. Please report this to the maintainer of %s", - MAP_ADDON_CONFIG, - MAP_HOMEASSISTANT_CONFIG, - MAP_CONFIG, + MappingType.ADDON_CONFIG, + MappingType.HOMEASSISTANT_CONFIG, + MappingType.CONFIG, name, ) else: _LOGGER.debug( "Add-on config using deprecated map option '%s' instead of '%s'. Please report this to the maintainer of %s", - MAP_CONFIG, - MAP_HOMEASSISTANT_CONFIG, + MappingType.CONFIG, + MappingType.HOMEASSISTANT_CONFIG, name, ) @@ -337,7 +362,15 @@ def _migrate(config: dict[str, Any]): vol.Optional(ATTR_DEVICES): [str], vol.Optional(ATTR_UDEV, default=False): vol.Boolean(), vol.Optional(ATTR_TMPFS, default=False): vol.Boolean(), - vol.Optional(ATTR_MAP, default=list): [vol.Match(RE_VOLUME)], + vol.Optional(ATTR_MAP, default=list): [ + vol.Schema( + { + vol.Required(ATTR_TYPE): vol.Coerce(MappingType), + vol.Optional(ATTR_READ_ONLY, default=True): bool, + vol.Optional(ATTR_PATH): str, + } + ) + ], vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): str}, vol.Optional(ATTR_PRIVILEGED): [vol.Coerce(Capabilities)], vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(), diff --git a/supervisor/const.py b/supervisor/const.py index 09c7fa5cd78..418c18a909c 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -345,17 +345,6 @@ NEED_SERVICE = "need" WANT_SERVICE = "want" - -MAP_CONFIG = "config" -MAP_SSL = "ssl" -MAP_ADDONS = "addons" -MAP_BACKUP = "backup" -MAP_SHARE = "share" -MAP_MEDIA = "media" -MAP_HOMEASSISTANT_CONFIG = "homeassistant_config" -MAP_ALL_ADDON_CONFIGS = "all_addon_configs" -MAP_ADDON_CONFIG = "addon_config" - ARCH_ARMHF = "armhf" ARCH_ARMV7 = "armv7" ARCH_AARCH64 = "aarch64" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 1f93e64e611..cb50511172d 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -15,18 +15,10 @@ import requests from ..addons.build import AddonBuild +from ..addons.const import MappingType from ..bus import EventListener from ..const import ( DOCKER_CPU_RUNTIME_ALLOCATION, - MAP_ADDON_CONFIG, - MAP_ADDONS, - MAP_ALL_ADDON_CONFIGS, - MAP_BACKUP, - MAP_CONFIG, - MAP_HOMEASSISTANT_CONFIG, - MAP_MEDIA, - MAP_SHARE, - MAP_SSL, SECURITY_DISABLE, SECURITY_PROFILE, SYSTEMD_JOURNAL_PERSISTENT, @@ -332,24 +324,28 @@ def mounts(self) -> list[Mount]: """Return mounts for container.""" addon_mapping = self.addon.map_volumes + target_data_path = "" + if MappingType.DATA in addon_mapping: + target_data_path = addon_mapping[MappingType.DATA].path + mounts = [ MOUNT_DEV, Mount( type=MountType.BIND, source=self.addon.path_extern_data.as_posix(), - target="/data", + target=target_data_path or "/data", read_only=False, ), ] # setup config mappings - if MAP_CONFIG in addon_mapping: + if MappingType.CONFIG in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_homeassistant.as_posix(), - target="/config", - read_only=addon_mapping[MAP_CONFIG], + target=addon_mapping[MappingType.CONFIG].path or "/config", + read_only=addon_mapping[MappingType.CONFIG].read_only, ) ) @@ -360,80 +356,85 @@ def mounts(self) -> list[Mount]: Mount( type=MountType.BIND, source=self.addon.path_extern_config.as_posix(), - target="/config", - read_only=addon_mapping[MAP_ADDON_CONFIG], + target=addon_mapping[MappingType.ADDON_CONFIG].path + or "/config", + read_only=addon_mapping[MappingType.ADDON_CONFIG].read_only, ) ) # Map Home Assistant config in new way - if MAP_HOMEASSISTANT_CONFIG in addon_mapping: + if MappingType.HOMEASSISTANT_CONFIG in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_homeassistant.as_posix(), - target="/homeassistant", - read_only=addon_mapping[MAP_HOMEASSISTANT_CONFIG], + target=addon_mapping[MappingType.HOMEASSISTANT_CONFIG].path + or "/homeassistant", + read_only=addon_mapping[ + MappingType.HOMEASSISTANT_CONFIG + ].read_only, ) ) - if MAP_ALL_ADDON_CONFIGS in addon_mapping: + if MappingType.ALL_ADDON_CONFIGS in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_addon_configs.as_posix(), - target="/addon_configs", - read_only=addon_mapping[MAP_ALL_ADDON_CONFIGS], + target=addon_mapping[MappingType.ALL_ADDON_CONFIGS].path + or "/addon_configs", + read_only=addon_mapping[MappingType.ALL_ADDON_CONFIGS].read_only, ) ) - if MAP_SSL in addon_mapping: + if MappingType.SSL in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_ssl.as_posix(), - target="/ssl", - read_only=addon_mapping[MAP_SSL], + target=addon_mapping[MappingType.SSL].path or "/ssl", + read_only=addon_mapping[MappingType.SSL].read_only, ) ) - if MAP_ADDONS in addon_mapping: + if MappingType.ADDONS in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_addons_local.as_posix(), - target="/addons", - read_only=addon_mapping[MAP_ADDONS], + target=addon_mapping[MappingType.ADDONS].path or "/addons", + read_only=addon_mapping[MappingType.ADDONS].read_only, ) ) - if MAP_BACKUP in addon_mapping: + if MappingType.BACKUP in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_backup.as_posix(), - target="/backup", - read_only=addon_mapping[MAP_BACKUP], + target=addon_mapping[MappingType.BACKUP].path or "/backup", + read_only=addon_mapping[MappingType.BACKUP].read_only, ) ) - if MAP_SHARE in addon_mapping: + if MappingType.SHARE in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_share.as_posix(), - target="/share", - read_only=addon_mapping[MAP_SHARE], + target=addon_mapping[MappingType.SHARE].path or "/share", + read_only=addon_mapping[MappingType.SHARE].read_only, propagation=PropagationMode.RSLAVE, ) ) - if MAP_MEDIA in addon_mapping: + if MappingType.MEDIA in addon_mapping: mounts.append( Mount( type=MountType.BIND, source=self.sys_config.path_extern_media.as_posix(), - target="/media", - read_only=addon_mapping[MAP_MEDIA], + target=addon_mapping[MappingType.MEDIA].path or "/media", + read_only=addon_mapping[MappingType.MEDIA].read_only, propagation=PropagationMode.RSLAVE, ) ) diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index d2391114bac..f9a52aa6825 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -201,6 +201,49 @@ def test_addon_map_addon_config_folder( ) +def test_addon_map_addon_config_folder_with_custom_target( + coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern +): + """Test mounts for addon which maps its own config folder and sets target path.""" + config = load_json_fixture("addon-config-map-addon_config.json") + config["map"].remove("addon_config") + config["map"].append( + {"type": "addon_config", "read_only": False, "path": "/custom/target/path"} + ) + docker_addon = get_docker_addon(coresys, addonsdata_system, config) + + # Addon config folder included + assert ( + Mount( + type="bind", + source=docker_addon.addon.path_extern_config.as_posix(), + target="/custom/target/path", + read_only=False, + ) + in docker_addon.mounts + ) + + +def test_addon_map_data_folder_with_custom_target( + coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern +): + """Test mounts for addon which sets target path for data folder.""" + config = load_json_fixture("addon-config-map-addon_config.json") + config["map"].append({"type": "data", "path": "/custom/data/path"}) + docker_addon = get_docker_addon(coresys, addonsdata_system, config) + + # Addon config folder included + assert ( + Mount( + type="bind", + source=docker_addon.addon.path_extern_data.as_posix(), + target="/custom/data/path", + read_only=False, + ) + in docker_addon.mounts + ) + + def test_addon_ignore_on_config_map( coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern ):