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()
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
await addon.install_apparmor()

View File

@ -47,6 +47,7 @@ from ..const import (
ATTR_VERSION,
ATTR_WATCHDOG,
DNS_SUFFIX,
MAP_ADDON_CONFIG,
AddonBoot,
AddonStartup,
AddonState,
@ -453,6 +454,21 @@ class Addon(AddonModel):
"""Return add-on data path external for Docker."""
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
def path_options(self) -> Path:
"""Return path to add-on options."""
@ -570,11 +586,13 @@ class Addon(AddonModel):
for listener in self._listeners:
self.sys_bus.remove_listener(listener)
if not self.path_data.is_dir():
return
if self.path_data.is_dir():
_LOGGER.info("Removing add-on data folder %s", self.path_data)
await remove_data(self.path_data)
_LOGGER.info("Removing add-on data folder %s", 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:
"""Write asound config to file and return True on success."""
@ -863,6 +881,15 @@ class Addon(AddonModel):
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()
try:
_LOGGER.info("Building backup for add-on %s", self.slug)
@ -951,18 +978,27 @@ class Addon(AddonModel):
with suppress(DockerError):
await self.instance.update(version, restore_image)
# Restore data
# Restore data and config
def _restore_data():
"""Restore data."""
"""Restore data and config."""
temp_data = Path(temp, "data")
if temp_data.is_dir():
shutil.copytree(temp_data, self.path_data, symlinks=True)
else:
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():
await remove_data(self.path_data)
if self.path_config.is_dir():
await remove_data(self.path_config)
try:
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:

View File

@ -91,6 +91,9 @@ from ..const import (
ATTR_VIDEO,
ATTR_WATCHDOG,
ATTR_WEBUI,
MAP_ADDON_CONFIG,
MAP_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
ROLE_ALL,
ROLE_DEFAULT,
AddonBoot,
@ -114,7 +117,9 @@ from .options import RE_SCHEMA_ELEMENT
_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)$")
@ -260,6 +265,29 @@ def _migrate_addon_config(protocol=False):
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 _migrate

View File

@ -221,6 +221,14 @@ def initialize_system(coresys: CoreSys) -> None:
)
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:
"""Cleanup some stuff after update."""

View File

@ -48,6 +48,7 @@ MEDIA_DATA = PurePath("media")
MOUNTS_FOLDER = PurePath("mounts")
MOUNTS_CREDENTIALS = PurePath(".mounts_credentials")
EMERGENCY_DATA = PurePath("emergency")
ADDON_CONFIGS = PurePath("addon_configs")
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
@ -231,6 +232,16 @@ class CoreConfig(FileConfiguration):
"""Return root add-on data folder external for Docker."""
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
def path_audio(self) -> Path:
"""Return root audio data folder."""

View File

@ -350,6 +350,9 @@ 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"

View File

@ -18,9 +18,12 @@ from ..addons.build import AddonBuild
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,
@ -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:
mounts.append(
Mount(

View File

@ -1375,26 +1375,64 @@ async def test_restore_only_reloads_ingress_on_change(
async def test_restore_new_addon(
coresys: CoreSys,
install_addon_ssh: Addon,
install_addon_example: Addon,
container: MagicMock,
tmp_supervisor_data,
path_extern,
):
"""Test restore installing new addon."""
install_addon_ssh.path_data.mkdir()
coresys.core.state = CoreState.RUNNING
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
backup: Backup = await coresys.backups.do_backup_partial(addons=["local_ssh"])
await coresys.addons.uninstall("local_ssh")
assert "local_ssh" not in coresys.addons.local
assert not install_addon_example.path_data.exists()
assert not install_addon_example.path_config.exists()
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(
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(

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_share.mkdir()
coresys.config.path_addons_data.mkdir(parents=True)
coresys.config.path_addon_configs.mkdir(parents=True)
yield tmp_path

View File

@ -1,5 +1,6 @@
"""Test docker addon setup."""
from ipaddress import IPv4Address
from typing import Any
from unittest.mock import MagicMock, Mock, PropertyMock, patch
from docker.errors import NotFound
@ -40,10 +41,15 @@ def fixture_addonsdata_user() -> dict[str, Data]:
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."""
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")
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]
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(
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
map:
- share:rw
- addon_config
options:
message: "Hello world..."
schema: