Add a public config folder per addon (#4650)

* Add a public config folder per addon

* Finish addon_configs map option

* Rename map values and add addon_config
This commit is contained in:
Mike Degatano 2023-10-27 09:43:57 -04:00 committed by GitHub
parent b04efe4eac
commit 0f600da096
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 294 additions and 18 deletions

View File

@ -179,6 +179,12 @@ class AddonManager(CoreSysAttributes):
) )
addon.path_data.mkdir() addon.path_data.mkdir()
if addon.addon_config_used and not addon.path_config.is_dir():
_LOGGER.info(
"Creating Home Assistant add-on config folder %s", addon.path_config
)
addon.path_config.mkdir()
# Setup/Fix AppArmor profile # Setup/Fix AppArmor profile
await addon.install_apparmor() await addon.install_apparmor()

View File

@ -47,6 +47,7 @@ from ..const import (
ATTR_VERSION, ATTR_VERSION,
ATTR_WATCHDOG, ATTR_WATCHDOG,
DNS_SUFFIX, DNS_SUFFIX,
MAP_ADDON_CONFIG,
AddonBoot, AddonBoot,
AddonStartup, AddonStartup,
AddonState, AddonState,
@ -453,6 +454,21 @@ class Addon(AddonModel):
"""Return add-on data path external for Docker.""" """Return add-on data path external for Docker."""
return PurePath(self.sys_config.path_extern_addons_data, self.slug) return PurePath(self.sys_config.path_extern_addons_data, self.slug)
@property
def addon_config_used(self) -> bool:
"""Add-on is using its public config folder."""
return MAP_ADDON_CONFIG in self.map_volumes
@property
def path_config(self) -> Path:
"""Return add-on config path inside Supervisor."""
return Path(self.sys_config.path_addon_configs, self.slug)
@property
def path_extern_config(self) -> PurePath:
"""Return add-on config path external for Docker."""
return PurePath(self.sys_config.path_extern_addon_configs, self.slug)
@property @property
def path_options(self) -> Path: def path_options(self) -> Path:
"""Return path to add-on options.""" """Return path to add-on options."""
@ -570,12 +586,14 @@ class Addon(AddonModel):
for listener in self._listeners: for listener in self._listeners:
self.sys_bus.remove_listener(listener) self.sys_bus.remove_listener(listener)
if not self.path_data.is_dir(): if self.path_data.is_dir():
return
_LOGGER.info("Removing add-on data folder %s", self.path_data) _LOGGER.info("Removing add-on data folder %s", self.path_data)
await remove_data(self.path_data) await remove_data(self.path_data)
if self.path_config.is_dir():
_LOGGER.info("Removing add-on config folder %s", self.path_config)
await remove_data(self.path_config)
def write_pulse(self) -> None: def write_pulse(self) -> None:
"""Write asound config to file and return True on success.""" """Write asound config to file and return True on success."""
pulse_config = self.sys_plugins.audio.pulse_client( pulse_config = self.sys_plugins.audio.pulse_client(
@ -863,6 +881,15 @@ class Addon(AddonModel):
arcname="data", arcname="data",
) )
# Backup config
if self.addon_config_used:
atomic_contents_add(
backup,
self.path_config,
excludes=self.backup_exclude,
arcname="config",
)
is_running = await self.begin_backup() is_running = await self.begin_backup()
try: try:
_LOGGER.info("Building backup for add-on %s", self.slug) _LOGGER.info("Building backup for add-on %s", self.slug)
@ -951,18 +978,27 @@ class Addon(AddonModel):
with suppress(DockerError): with suppress(DockerError):
await self.instance.update(version, restore_image) await self.instance.update(version, restore_image)
# Restore data # Restore data and config
def _restore_data(): def _restore_data():
"""Restore data.""" """Restore data and config."""
temp_data = Path(temp, "data") temp_data = Path(temp, "data")
if temp_data.is_dir(): if temp_data.is_dir():
shutil.copytree(temp_data, self.path_data, symlinks=True) shutil.copytree(temp_data, self.path_data, symlinks=True)
else: else:
self.path_data.mkdir() self.path_data.mkdir()
_LOGGER.info("Restoring data for addon %s", self.slug) temp_config = Path(temp, "config")
if temp_config.is_dir():
shutil.copytree(temp_config, self.path_config, symlinks=True)
elif self.addon_config_used:
self.path_config.mkdir()
_LOGGER.info("Restoring data and config for addon %s", self.slug)
if self.path_data.is_dir(): if self.path_data.is_dir():
await remove_data(self.path_data) await remove_data(self.path_data)
if self.path_config.is_dir():
await remove_data(self.path_config)
try: try:
await self.sys_run_in_executor(_restore_data) await self.sys_run_in_executor(_restore_data)
except shutil.Error as err: except shutil.Error as err:

View File

@ -91,6 +91,9 @@ 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,
@ -114,7 +117,9 @@ from .options import RE_SCHEMA_ELEMENT
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
RE_VOLUME = re.compile(r"^(config|ssl|addons|backup|share|media)(?::(rw|ro))?$") RE_VOLUME = re.compile(
r"^(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)$")
@ -260,6 +265,29 @@ def _migrate_addon_config(protocol=False):
name, name,
) )
# 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) for volume in volumes):
if any(
volume
and volume.group(1) in {MAP_ADDON_CONFIG, MAP_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,
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,
name,
)
return config return config
return _migrate return _migrate

View File

@ -221,6 +221,14 @@ def initialize_system(coresys: CoreSys) -> None:
) )
config.path_emergency.mkdir() config.path_emergency.mkdir()
# Addon Configs folder
if not config.path_addon_configs.is_dir():
_LOGGER.debug(
"Creating Supervisor add-on configs folder at '%s'",
config.path_addon_configs,
)
config.path_addon_configs.mkdir()
def migrate_system_env(coresys: CoreSys) -> None: def migrate_system_env(coresys: CoreSys) -> None:
"""Cleanup some stuff after update.""" """Cleanup some stuff after update."""

View File

@ -48,6 +48,7 @@ MEDIA_DATA = PurePath("media")
MOUNTS_FOLDER = PurePath("mounts") MOUNTS_FOLDER = PurePath("mounts")
MOUNTS_CREDENTIALS = PurePath(".mounts_credentials") MOUNTS_CREDENTIALS = PurePath(".mounts_credentials")
EMERGENCY_DATA = PurePath("emergency") EMERGENCY_DATA = PurePath("emergency")
ADDON_CONFIGS = PurePath("addon_configs")
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat() DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
@ -231,6 +232,16 @@ class CoreConfig(FileConfiguration):
"""Return root add-on data folder external for Docker.""" """Return root add-on data folder external for Docker."""
return PurePath(self.path_extern_supervisor, ADDONS_DATA) return PurePath(self.path_extern_supervisor, ADDONS_DATA)
@property
def path_addon_configs(self) -> Path:
"""Return root Add-on configs folder."""
return self.path_supervisor / ADDON_CONFIGS
@property
def path_extern_addon_configs(self) -> PurePath:
"""Return root Add-on configs folder external for Docker."""
return PurePath(self.path_extern_supervisor, ADDON_CONFIGS)
@property @property
def path_audio(self) -> Path: def path_audio(self) -> Path:
"""Return root audio data folder.""" """Return root audio data folder."""

View File

@ -350,6 +350,9 @@ MAP_ADDONS = "addons"
MAP_BACKUP = "backup" MAP_BACKUP = "backup"
MAP_SHARE = "share" MAP_SHARE = "share"
MAP_MEDIA = "media" 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"

View File

@ -18,9 +18,12 @@ from ..addons.build import AddonBuild
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_ADDONS,
MAP_ALL_ADDON_CONFIGS,
MAP_BACKUP, MAP_BACKUP,
MAP_CONFIG, MAP_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
MAP_MEDIA, MAP_MEDIA,
MAP_SHARE, MAP_SHARE,
MAP_SSL, MAP_SSL,
@ -350,6 +353,39 @@ class DockerAddon(DockerInterface):
) )
) )
else:
# Map addon's public config folder if not using deprecated config option
if self.addon.addon_config_used:
mounts.append(
Mount(
type=MountType.BIND,
source=self.addon.path_extern_config.as_posix(),
target="/config",
read_only=addon_mapping[MAP_ADDON_CONFIG],
)
)
# Map Home Assistant config in new way
if MAP_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],
)
)
if MAP_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],
)
)
if MAP_SSL in addon_mapping: if MAP_SSL in addon_mapping:
mounts.append( mounts.append(
Mount( Mount(

View File

@ -1375,26 +1375,64 @@ async def test_restore_only_reloads_ingress_on_change(
async def test_restore_new_addon( async def test_restore_new_addon(
coresys: CoreSys, coresys: CoreSys,
install_addon_ssh: Addon, install_addon_example: Addon,
container: MagicMock, container: MagicMock,
tmp_supervisor_data, tmp_supervisor_data,
path_extern, path_extern,
): ):
"""Test restore installing new addon.""" """Test restore installing new addon."""
install_addon_ssh.path_data.mkdir()
coresys.core.state = CoreState.RUNNING coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.hardware.disk.get_disk_free_space = lambda x: 5000
backup: Backup = await coresys.backups.do_backup_partial(addons=["local_ssh"]) assert not install_addon_example.path_data.exists()
await coresys.addons.uninstall("local_ssh") assert not install_addon_example.path_config.exists()
assert "local_ssh" not in coresys.addons.local
backup: Backup = await coresys.backups.do_backup_partial(addons=["local_example"])
await coresys.addons.uninstall("local_example")
assert "local_example" not in coresys.addons.local
with patch.object(AddonModel, "_validate_availability"), patch.object( with patch.object(AddonModel, "_validate_availability"), patch.object(
DockerAddon, "attach" DockerAddon, "attach"
): ):
assert await coresys.backups.do_restore_partial(backup, addons=["local_ssh"]) assert await coresys.backups.do_restore_partial(
backup, addons=["local_example"]
)
assert "local_ssh" in coresys.addons.local assert "local_example" in coresys.addons.local
assert install_addon_example.path_data.exists()
assert install_addon_example.path_config.exists()
async def test_restore_preserves_data_config(
coresys: CoreSys,
install_addon_example: Addon,
container: MagicMock,
tmp_supervisor_data,
path_extern,
):
"""Test restore preserves data and config."""
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
install_addon_example.path_data.mkdir()
(test_data := install_addon_example.path_data / "data.txt").touch()
install_addon_example.path_config.mkdir()
(test_config := install_addon_example.path_config / "config.yaml").touch()
backup: Backup = await coresys.backups.do_backup_partial(addons=["local_example"])
await coresys.addons.uninstall("local_example")
assert not install_addon_example.path_data.exists()
assert not install_addon_example.path_config.exists()
with patch.object(AddonModel, "_validate_availability"), patch.object(
DockerAddon, "attach"
):
assert await coresys.backups.do_restore_partial(
backup, addons=["local_example"]
)
assert test_data.exists()
assert test_config.exists()
async def test_backup_to_mount_bypasses_free_space_condition( async def test_backup_to_mount_bypasses_free_space_condition(

View File

@ -394,6 +394,7 @@ async def tmp_supervisor_data(coresys: CoreSys, tmp_path: Path) -> Path:
coresys.config.path_dns.mkdir() coresys.config.path_dns.mkdir()
coresys.config.path_share.mkdir() coresys.config.path_share.mkdir()
coresys.config.path_addons_data.mkdir(parents=True) coresys.config.path_addons_data.mkdir(parents=True)
coresys.config.path_addon_configs.mkdir(parents=True)
yield tmp_path yield tmp_path

View File

@ -1,5 +1,6 @@
"""Test docker addon setup.""" """Test docker addon setup."""
from ipaddress import IPv4Address from ipaddress import IPv4Address
from typing import Any
from unittest.mock import MagicMock, Mock, PropertyMock, patch from unittest.mock import MagicMock, Mock, PropertyMock, patch
from docker.errors import NotFound from docker.errors import NotFound
@ -40,10 +41,15 @@ def fixture_addonsdata_user() -> dict[str, Data]:
def get_docker_addon( def get_docker_addon(
coresys: CoreSys, addonsdata_system: dict[str, Data], config_file: str coresys: CoreSys,
): addonsdata_system: dict[str, Data],
config_file: str | dict[str, Any],
) -> DockerAddon:
"""Make and return docker addon object.""" """Make and return docker addon object."""
config = vd.SCHEMA_ADDON_CONFIG(load_json_fixture(config_file)) config = (
load_json_fixture(config_file) if isinstance(config_file, str) else config_file
)
config = vd.SCHEMA_ADDON_CONFIG(config)
slug = config.get("slug") slug = config.get("slug")
addonsdata_system.return_value = {slug: config} addonsdata_system.return_value = {slug: config}
@ -135,6 +141,94 @@ def test_addon_map_folder_defaults(
assert "/backup" not in [mount["Target"] for mount in docker_addon.mounts] assert "/backup" not in [mount["Target"] for mount in docker_addon.mounts]
def test_addon_map_homeassistant_folder(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
):
"""Test mounts for addon which maps homeassistant folder."""
config = load_json_fixture("addon-config-map-addon_config.json")
config["map"].append("homeassistant_config")
docker_addon = get_docker_addon(coresys, addonsdata_system, config)
# Home Assistant config folder mounted to /homeassistant, not /config
assert (
Mount(
type="bind",
source=coresys.config.path_extern_homeassistant.as_posix(),
target="/homeassistant",
read_only=True,
)
in docker_addon.mounts
)
def test_addon_map_addon_configs_folder(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
):
"""Test mounts for addon which maps addon configs folder."""
config = load_json_fixture("addon-config-map-addon_config.json")
config["map"].append("all_addon_configs")
docker_addon = get_docker_addon(coresys, addonsdata_system, config)
# Addon configs folder included
assert (
Mount(
type="bind",
source=coresys.config.path_extern_addon_configs.as_posix(),
target="/addon_configs",
read_only=True,
)
in docker_addon.mounts
)
def test_addon_map_addon_config_folder(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
):
"""Test mounts for addon which maps its own config folder."""
docker_addon = get_docker_addon(
coresys, addonsdata_system, "addon-config-map-addon_config.json"
)
# Addon config folder included
assert (
Mount(
type="bind",
source=docker_addon.addon.path_extern_config.as_posix(),
target="/config",
read_only=True,
)
in docker_addon.mounts
)
def test_addon_ignore_on_config_map(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
):
"""Test mounts for addon don't include addon config or homeassistant when config included."""
config = load_json_fixture("basic-addon-config.json")
config["map"].extend(["addon_config", "homeassistant_config"])
docker_addon = get_docker_addon(coresys, addonsdata_system, config)
# Config added and is marked rw
assert (
Mount(
type="bind",
source=coresys.config.path_extern_homeassistant.as_posix(),
target="/config",
read_only=False,
)
in docker_addon.mounts
)
# Mount for addon's specific config folder omitted since config in map field
assert (
len([mount for mount in docker_addon.mounts if mount["Target"] == "/config"])
== 1
)
# Home Assistant mount omitted since config in map field
assert "/homeassistant" not in [mount["Target"] for mount in docker_addon.mounts]
def test_journald_addon( def test_journald_addon(
coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern coresys: CoreSys, addonsdata_system: dict[str, Data], path_extern
): ):

View File

@ -0,0 +1,14 @@
{
"name": "Test Add-on",
"version": "1.0.1",
"slug": "test_addon",
"description": "This is an Add-on which maps all addon configs",
"arch": ["amd64"],
"url": "https://www.home-assistant.io/",
"startup": "application",
"boot": "auto",
"map": ["addon_config", "ssl", "media", "share"],
"options": {},
"schema": {},
"image": "test/{arch}-my-custom-addon"
}

View File

@ -13,6 +13,7 @@ arch:
init: false init: false
map: map:
- share:rw - share:rw
- addon_config
options: options:
message: "Hello world..." message: "Hello world..."
schema: schema: