Skip to content

Commit

Permalink
Add a public config folder per addon (#4650)
Browse files Browse the repository at this point in the history
* Add a public config folder per addon

* Finish addon_configs map option

* Rename map values and add addon_config
  • Loading branch information
mdegat01 authored Oct 27, 2023
1 parent b04efe4 commit 0f600da
Show file tree
Hide file tree
Showing 12 changed files with 294 additions and 18 deletions.
6 changes: 6 additions & 0 deletions supervisor/addons/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
50 changes: 43 additions & 7 deletions supervisor/addons/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
ATTR_VERSION,
ATTR_WATCHDOG,
DNS_SUFFIX,
MAP_ADDON_CONFIG,
AddonBoot,
AddonStartup,
AddonState,
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
30 changes: 29 additions & 1 deletion supervisor/addons/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@
ATTR_VIDEO,
ATTR_WATCHDOG,
ATTR_WEBUI,
MAP_ADDON_CONFIG,
MAP_CONFIG,
MAP_HOMEASSISTANT_CONFIG,
ROLE_ALL,
ROLE_DEFAULT,
AddonBoot,
Expand All @@ -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"^(?P<service>mqtt|mysql):(?P<rights>provide|want|need)$")


Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions supervisor/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
11 changes: 11 additions & 0 deletions supervisor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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."""
Expand Down
3 changes: 3 additions & 0 deletions supervisor/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions supervisor/docker/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
52 changes: 45 additions & 7 deletions tests/backups/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading

0 comments on commit 0f600da

Please sign in to comment.