mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-07 17:26:32 +00:00
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:
parent
b04efe4eac
commit
0f600da096
@ -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()
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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."""
|
||||
|
@ -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"
|
||||
|
@ -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(
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
):
|
||||
|
14
tests/fixtures/addon-config-map-addon_config.json
vendored
Normal file
14
tests/fixtures/addon-config-map-addon_config.json
vendored
Normal 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"
|
||||
}
|
@ -13,6 +13,7 @@ arch:
|
||||
init: false
|
||||
map:
|
||||
- share:rw
|
||||
- addon_config
|
||||
options:
|
||||
message: "Hello world..."
|
||||
schema:
|
||||
|
Loading…
x
Reference in New Issue
Block a user