mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-09 18:26:30 +00:00
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:
parent
2c09e7929f
commit
e08c8ca26d
@ -48,7 +48,6 @@ from ..const import (
|
||||
ATTR_VERSION,
|
||||
ATTR_WATCHDOG,
|
||||
DNS_SUFFIX,
|
||||
MAP_ADDON_CONFIG,
|
||||
AddonBoot,
|
||||
AddonStartup,
|
||||
AddonState,
|
||||
@ -85,6 +84,7 @@ from .const import (
|
||||
WATCHDOG_THROTTLE_MAX_CALLS,
|
||||
WATCHDOG_THROTTLE_PERIOD,
|
||||
AddonBackupMode,
|
||||
MappingType,
|
||||
)
|
||||
from .model import AddonModel, Data
|
||||
from .options import AddonOptions
|
||||
@ -467,7 +467,7 @@ class Addon(AddonModel):
|
||||
@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:
|
||||
|
11
supervisor/addons/configuration.py
Normal file
11
supervisor/addons/configuration.py
Normal 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
|
@ -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)
|
||||
|
@ -65,6 +65,7 @@ from ..const import (
|
||||
ATTR_TIMEOUT,
|
||||
ATTR_TMPFS,
|
||||
ATTR_TRANSLATIONS,
|
||||
ATTR_TYPE,
|
||||
ATTR_UART,
|
||||
ATTR_UDEV,
|
||||
ATTR_URL,
|
||||
@ -86,9 +87,17 @@ from ..exceptions import AddonsNotSupportedError
|
||||
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 @@ class AddonModel(JobGroup, ABC):
|
||||
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
|
||||
|
||||
|
@ -81,6 +81,7 @@ from ..const import (
|
||||
ATTR_TIMEOUT,
|
||||
ATTR_TMPFS,
|
||||
ATTR_TRANSLATIONS,
|
||||
ATTR_TYPE,
|
||||
ATTR_UART,
|
||||
ATTR_UDEV,
|
||||
ATTR_URL,
|
||||
@ -91,9 +92,6 @@ from ..const import (
|
||||
ATTR_VIDEO,
|
||||
ATTR_WATCHDOG,
|
||||
ATTR_WEBUI,
|
||||
MAP_ADDON_CONFIG,
|
||||
MAP_CONFIG,
|
||||
MAP_HOMEASSISTANT_CONFIG,
|
||||
ROLE_ALL,
|
||||
ROLE_DEFAULT,
|
||||
AddonBoot,
|
||||
@ -112,13 +110,21 @@ from ..validate import (
|
||||
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"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$")
|
||||
|
||||
@ -266,26 +272,45 @@ def _migrate_addon_config(protocol=False):
|
||||
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 @@ _SCHEMA_ADDON_CONFIG = vol.Schema(
|
||||
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(),
|
||||
|
@ -345,17 +345,6 @@ PROVIDE_SERVICE = "provide"
|
||||
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"
|
||||
|
@ -15,18 +15,10 @@ from docker.types import Mount
|
||||
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 @@ class DockerAddon(DockerInterface):
|
||||
"""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 @@ class DockerAddon(DockerInterface):
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
@ -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
|
||||
):
|
||||
|
Loading…
x
Reference in New Issue
Block a user