diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 606bc7d97a3..8398902c76b 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -179,6 +179,12 @@ async def install(self, slug: str) -> None: ) 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() diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index be434d0f3be..eaba6007dee 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -47,6 +47,7 @@ ATTR_VERSION, ATTR_WATCHDOG, DNS_SUFFIX, + MAP_ADDON_CONFIG, AddonBoot, AddonStartup, AddonState, @@ -453,6 +454,21 @@ def path_extern_data(self) -> PurePath: """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 @@ async def unload(self) -> None: 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 @@ def _write_tarfile(): 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 @@ def _extract_tarfile(): 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: diff --git a/supervisor/addons/validate.py b/supervisor/addons/validate.py index e049f5506fc..35c9568e3dd 100644 --- a/supervisor/addons/validate.py +++ b/supervisor/addons/validate.py @@ -91,6 +91,9 @@ ATTR_VIDEO, ATTR_WATCHDOG, ATTR_WEBUI, + MAP_ADDON_CONFIG, + MAP_CONFIG, + MAP_HOMEASSISTANT_CONFIG, ROLE_ALL, ROLE_DEFAULT, AddonBoot, @@ -114,7 +117,9 @@ _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"^(?Pmqtt|mysql):(?Pprovide|want|need)$") @@ -260,6 +265,29 @@ def _migrate(config: dict[str, Any]): 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 diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 660650e0851..51a79e958e3 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -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.""" diff --git a/supervisor/config.py b/supervisor/config.py index d861c836796..63d469882f8 100644 --- a/supervisor/config.py +++ b/supervisor/config.py @@ -48,6 +48,7 @@ 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 @@ def path_extern_addons_data(self) -> PurePath: """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.""" diff --git a/supervisor/const.py b/supervisor/const.py index 5898acf21ef..983733d1d13 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -350,6 +350,9 @@ 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" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 3520a180a0b..79aa90fa48e 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -18,9 +18,12 @@ 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 @@ def mounts(self) -> list[Mount]: ) ) + 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( diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index 816480e8574..6dd6d0dd1f9 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -1375,26 +1375,64 @@ async def mock_is_running(*_) -> bool: 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_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 "local_ssh" in coresys.addons.local + assert test_data.exists() + assert test_config.exists() async def test_backup_to_mount_bypasses_free_space_condition( diff --git a/tests/conftest.py b/tests/conftest.py index 7ed69930f1b..03f325672f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/docker/test_addon.py b/tests/docker/test_addon.py index 652d2e9f1f7..d2391114bac 100644 --- a/tests/docker/test_addon.py +++ b/tests/docker/test_addon.py @@ -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 ): diff --git a/tests/fixtures/addon-config-map-addon_config.json b/tests/fixtures/addon-config-map-addon_config.json new file mode 100644 index 00000000000..0fed5089dde --- /dev/null +++ b/tests/fixtures/addon-config-map-addon_config.json @@ -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" +} diff --git a/tests/fixtures/addons/local/example/config.yaml b/tests/fixtures/addons/local/example/config.yaml index 4317d66755c..dc40ddd5ca1 100644 --- a/tests/fixtures/addons/local/example/config.yaml +++ b/tests/fixtures/addons/local/example/config.yaml @@ -13,6 +13,7 @@ arch: init: false map: - share:rw + - addon_config options: message: "Hello world..." schema: