Add support for setting target path in map config (#4694)

* Added support for setting addon target path in map config

* Updated addon target path mapping to use dataclass

* Added check before adding string folder maps

* Moved enum to addon/const, updated map_volumes logic, fixed test

* Removed log used for debugging

* Use more readable approach to determine addon_config_used

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Use cleaner approach for checking volume config

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Use dict syntax and ATTR_TYPE

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Use coerce for validating mapping type

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Default read_only to true in schema

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Use ATTR_TYPE and ATTR_READ_ONLY instead of static strings

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Use constants instead of in-line strings

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Correct type for path

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Added read_only and path constants

* Fixed small syntax error and added includes for constants

* Simplify logic for handling string and dict entries in map config

* Use ATTR_PATH instead of inline string

Co-authored-by: Mike Degatano <michael.degatano@gmail.com>

* Add missing ATTR_PATH reference

* Moved FolderMapping dataclass to data.py

* Fix edge case where "data" map type is used but optional path is not set

* Move FolderMapping dataclass to configuration.py to prevent circular reference

---------

Co-authored-by: Jeff Oakley <jeff.oakley@LearningCircleSoftware.com>
Co-authored-by: Mike Degatano <michael.degatano@gmail.com>
Co-authored-by: Pascal Vizeli <pvizeli@syshack.ch>
This commit is contained in:
Jeff Oakley 2023-12-27 15:14:23 -05:00 committed by GitHub
parent 2c09e7929f
commit e08c8ca26d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 173 additions and 71 deletions

View File

@ -48,7 +48,6 @@ from ..const import (
ATTR_VERSION, ATTR_VERSION,
ATTR_WATCHDOG, ATTR_WATCHDOG,
DNS_SUFFIX, DNS_SUFFIX,
MAP_ADDON_CONFIG,
AddonBoot, AddonBoot,
AddonStartup, AddonStartup,
AddonState, AddonState,
@ -85,6 +84,7 @@ from .const import (
WATCHDOG_THROTTLE_MAX_CALLS, WATCHDOG_THROTTLE_MAX_CALLS,
WATCHDOG_THROTTLE_PERIOD, WATCHDOG_THROTTLE_PERIOD,
AddonBackupMode, AddonBackupMode,
MappingType,
) )
from .model import AddonModel, Data from .model import AddonModel, Data
from .options import AddonOptions from .options import AddonOptions
@ -467,7 +467,7 @@ class Addon(AddonModel):
@property @property
def addon_config_used(self) -> bool: def addon_config_used(self) -> bool:
"""Add-on is using its public config folder.""" """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 @property
def path_config(self) -> Path: def path_config(self) -> Path:

View File

@ -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

View File

@ -12,8 +12,25 @@ class AddonBackupMode(StrEnum):
COLD = "cold" 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_BACKUP = "backup"
ATTR_CODENOTARY = "codenotary" ATTR_CODENOTARY = "codenotary"
ATTR_READ_ONLY = "read_only"
ATTR_PATH = "path"
WATCHDOG_RETRY_SECONDS = 10 WATCHDOG_RETRY_SECONDS = 10
WATCHDOG_MAX_ATTEMPTS = 5 WATCHDOG_MAX_ATTEMPTS = 5
WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30) WATCHDOG_THROTTLE_PERIOD = timedelta(minutes=30)

View File

@ -65,6 +65,7 @@ from ..const import (
ATTR_TIMEOUT, ATTR_TIMEOUT,
ATTR_TMPFS, ATTR_TMPFS,
ATTR_TRANSLATIONS, ATTR_TRANSLATIONS,
ATTR_TYPE,
ATTR_UART, ATTR_UART,
ATTR_UDEV, ATTR_UDEV,
ATTR_URL, ATTR_URL,
@ -86,9 +87,17 @@ from ..exceptions import AddonsNotSupportedError
from ..jobs.const import JOB_GROUP_ADDON from ..jobs.const import JOB_GROUP_ADDON
from ..jobs.job_group import JobGroup from ..jobs.job_group import JobGroup
from ..utils import version_is_new_enough 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 .options import AddonOptions, UiOptions
from .validate import RE_SERVICE, RE_VOLUME from .validate import RE_SERVICE
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -538,14 +547,13 @@ class AddonModel(JobGroup, ABC):
return ATTR_IMAGE not in self.data return ATTR_IMAGE not in self.data
@property @property
def map_volumes(self) -> dict[str, bool]: def map_volumes(self) -> dict[MappingType, FolderMapping]:
"""Return a dict of {volume: read-only} from add-on.""" """Return a dict of {MappingType: FolderMapping} from add-on."""
volumes = {} volumes = {}
for volume in self.data[ATTR_MAP]: for volume in self.data[ATTR_MAP]:
result = RE_VOLUME.match(volume) volumes[MappingType(volume[ATTR_TYPE])] = FolderMapping(
if not result: volume.get(ATTR_PATH), volume[ATTR_READ_ONLY]
continue )
volumes[result.group(1)] = result.group(2) != "rw"
return volumes return volumes

View File

@ -81,6 +81,7 @@ from ..const import (
ATTR_TIMEOUT, ATTR_TIMEOUT,
ATTR_TMPFS, ATTR_TMPFS,
ATTR_TRANSLATIONS, ATTR_TRANSLATIONS,
ATTR_TYPE,
ATTR_UART, ATTR_UART,
ATTR_UDEV, ATTR_UDEV,
ATTR_URL, ATTR_URL,
@ -91,9 +92,6 @@ from ..const import (
ATTR_VIDEO, ATTR_VIDEO,
ATTR_WATCHDOG, ATTR_WATCHDOG,
ATTR_WEBUI, ATTR_WEBUI,
MAP_ADDON_CONFIG,
MAP_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
ROLE_ALL, ROLE_ALL,
ROLE_DEFAULT, ROLE_DEFAULT,
AddonBoot, AddonBoot,
@ -112,13 +110,21 @@ from ..validate import (
uuid_match, uuid_match,
version_tag, 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 from .options import RE_SCHEMA_ELEMENT
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
RE_VOLUME = re.compile( 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"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$") RE_SERVICE = re.compile(r"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$")
@ -266,26 +272,45 @@ def _migrate_addon_config(protocol=False):
name, 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 # 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[ATTR_TYPE] == MappingType.CONFIG for volume in volumes):
if any(volume and volume.group(1) == MAP_CONFIG for volume in volumes):
if any( if any(
volume 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 for volume in volumes
): ):
_LOGGER.warning( _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", "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, MappingType.ADDON_CONFIG,
MAP_HOMEASSISTANT_CONFIG, MappingType.HOMEASSISTANT_CONFIG,
MAP_CONFIG, MappingType.CONFIG,
name, name,
) )
else: else:
_LOGGER.debug( _LOGGER.debug(
"Add-on config using deprecated map option '%s' instead of '%s'. Please report this to the maintainer of %s", "Add-on config using deprecated map option '%s' instead of '%s'. Please report this to the maintainer of %s",
MAP_CONFIG, MappingType.CONFIG,
MAP_HOMEASSISTANT_CONFIG, MappingType.HOMEASSISTANT_CONFIG,
name, name,
) )
@ -337,7 +362,15 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
vol.Optional(ATTR_DEVICES): [str], vol.Optional(ATTR_DEVICES): [str],
vol.Optional(ATTR_UDEV, default=False): vol.Boolean(), vol.Optional(ATTR_UDEV, default=False): vol.Boolean(),
vol.Optional(ATTR_TMPFS, 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_ENVIRONMENT): {vol.Match(r"\w*"): str},
vol.Optional(ATTR_PRIVILEGED): [vol.Coerce(Capabilities)], vol.Optional(ATTR_PRIVILEGED): [vol.Coerce(Capabilities)],
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(), vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),

View File

@ -345,17 +345,6 @@ PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need" NEED_SERVICE = "need"
WANT_SERVICE = "want" 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_ARMHF = "armhf"
ARCH_ARMV7 = "armv7" ARCH_ARMV7 = "armv7"
ARCH_AARCH64 = "aarch64" ARCH_AARCH64 = "aarch64"

View File

@ -15,18 +15,10 @@ from docker.types import Mount
import requests import requests
from ..addons.build import AddonBuild from ..addons.build import AddonBuild
from ..addons.const import MappingType
from ..bus import EventListener from ..bus import EventListener
from ..const import ( from ..const import (
DOCKER_CPU_RUNTIME_ALLOCATION, 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_DISABLE,
SECURITY_PROFILE, SECURITY_PROFILE,
SYSTEMD_JOURNAL_PERSISTENT, SYSTEMD_JOURNAL_PERSISTENT,
@ -332,24 +324,28 @@ class DockerAddon(DockerInterface):
"""Return mounts for container.""" """Return mounts for container."""
addon_mapping = self.addon.map_volumes addon_mapping = self.addon.map_volumes
target_data_path = ""
if MappingType.DATA in addon_mapping:
target_data_path = addon_mapping[MappingType.DATA].path
mounts = [ mounts = [
MOUNT_DEV, MOUNT_DEV,
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.addon.path_extern_data.as_posix(), source=self.addon.path_extern_data.as_posix(),
target="/data", target=target_data_path or "/data",
read_only=False, read_only=False,
), ),
] ]
# setup config mappings # setup config mappings
if MAP_CONFIG in addon_mapping: if MappingType.CONFIG in addon_mapping:
mounts.append( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(), source=self.sys_config.path_extern_homeassistant.as_posix(),
target="/config", target=addon_mapping[MappingType.CONFIG].path or "/config",
read_only=addon_mapping[MAP_CONFIG], read_only=addon_mapping[MappingType.CONFIG].read_only,
) )
) )
@ -360,80 +356,85 @@ class DockerAddon(DockerInterface):
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.addon.path_extern_config.as_posix(), source=self.addon.path_extern_config.as_posix(),
target="/config", target=addon_mapping[MappingType.ADDON_CONFIG].path
read_only=addon_mapping[MAP_ADDON_CONFIG], or "/config",
read_only=addon_mapping[MappingType.ADDON_CONFIG].read_only,
) )
) )
# Map Home Assistant config in new way # Map Home Assistant config in new way
if MAP_HOMEASSISTANT_CONFIG in addon_mapping: if MappingType.HOMEASSISTANT_CONFIG in addon_mapping:
mounts.append( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_homeassistant.as_posix(), source=self.sys_config.path_extern_homeassistant.as_posix(),
target="/homeassistant", target=addon_mapping[MappingType.HOMEASSISTANT_CONFIG].path
read_only=addon_mapping[MAP_HOMEASSISTANT_CONFIG], 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( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_addon_configs.as_posix(), source=self.sys_config.path_extern_addon_configs.as_posix(),
target="/addon_configs", target=addon_mapping[MappingType.ALL_ADDON_CONFIGS].path
read_only=addon_mapping[MAP_ALL_ADDON_CONFIGS], 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( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_ssl.as_posix(), source=self.sys_config.path_extern_ssl.as_posix(),
target="/ssl", target=addon_mapping[MappingType.SSL].path or "/ssl",
read_only=addon_mapping[MAP_SSL], read_only=addon_mapping[MappingType.SSL].read_only,
) )
) )
if MAP_ADDONS in addon_mapping: if MappingType.ADDONS in addon_mapping:
mounts.append( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_addons_local.as_posix(), source=self.sys_config.path_extern_addons_local.as_posix(),
target="/addons", target=addon_mapping[MappingType.ADDONS].path or "/addons",
read_only=addon_mapping[MAP_ADDONS], read_only=addon_mapping[MappingType.ADDONS].read_only,
) )
) )
if MAP_BACKUP in addon_mapping: if MappingType.BACKUP in addon_mapping:
mounts.append( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_backup.as_posix(), source=self.sys_config.path_extern_backup.as_posix(),
target="/backup", target=addon_mapping[MappingType.BACKUP].path or "/backup",
read_only=addon_mapping[MAP_BACKUP], read_only=addon_mapping[MappingType.BACKUP].read_only,
) )
) )
if MAP_SHARE in addon_mapping: if MappingType.SHARE in addon_mapping:
mounts.append( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_share.as_posix(), source=self.sys_config.path_extern_share.as_posix(),
target="/share", target=addon_mapping[MappingType.SHARE].path or "/share",
read_only=addon_mapping[MAP_SHARE], read_only=addon_mapping[MappingType.SHARE].read_only,
propagation=PropagationMode.RSLAVE, propagation=PropagationMode.RSLAVE,
) )
) )
if MAP_MEDIA in addon_mapping: if MappingType.MEDIA in addon_mapping:
mounts.append( mounts.append(
Mount( Mount(
type=MountType.BIND, type=MountType.BIND,
source=self.sys_config.path_extern_media.as_posix(), source=self.sys_config.path_extern_media.as_posix(),
target="/media", target=addon_mapping[MappingType.MEDIA].path or "/media",
read_only=addon_mapping[MAP_MEDIA], read_only=addon_mapping[MappingType.MEDIA].read_only,
propagation=PropagationMode.RSLAVE, propagation=PropagationMode.RSLAVE,
) )
) )

View File

@ -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( def test_addon_ignore_on_config_map(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
): ):