Addon methods interfacing with docker are job groups (#4659)

* Addon methods interfacing with docker are job groups

* Add test for install
This commit is contained in:
Mike Degatano 2023-11-02 06:28:48 -04:00 committed by GitHub
parent 18e422ca77
commit 31200df89f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 291 additions and 187 deletions

View File

@ -17,8 +17,8 @@ from ..exceptions import (
DockerAPIError, DockerAPIError,
DockerError, DockerError,
DockerNotFound, DockerNotFound,
HassioError,
HomeAssistantAPIError, HomeAssistantAPIError,
HostAppArmorError,
) )
from ..jobs.decorator import Job, JobCondition from ..jobs.decorator import Job, JobCondition
from ..resolution.const import ContextType, IssueType, SuggestionType from ..resolution.const import ContextType, IssueType, SuggestionType
@ -119,8 +119,8 @@ class AddonManager(CoreSysAttributes):
): ):
addon.boot = AddonBoot.MANUAL addon.boot = AddonBoot.MANUAL
addon.save_persist() addon.save_persist()
except Exception as err: # pylint: disable=broad-except except HassioError:
capture_exception(err) pass # These are already handled
else: else:
continue continue
@ -169,36 +169,7 @@ class AddonManager(CoreSysAttributes):
store.validate_availability() store.validate_availability()
self.data.install(store) await Addon(self.coresys, slug).install()
addon = Addon(self.coresys, slug)
await addon.load()
if not addon.path_data.is_dir():
_LOGGER.info(
"Creating Home Assistant add-on data folder %s", addon.path_data
)
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()
try:
await addon.instance.install(store.version, store.image, arch=addon.arch)
except DockerError as err:
self.data.uninstall(addon)
raise AddonsError() from err
self.local[slug] = addon
# Reload ingress tokens
if addon.with_ingress:
await self.sys_ingress.reload()
_LOGGER.info("Add-on '%s' successfully installed", slug) _LOGGER.info("Add-on '%s' successfully installed", slug)
@ -207,51 +178,8 @@ class AddonManager(CoreSysAttributes):
if slug not in self.local: if slug not in self.local:
_LOGGER.warning("Add-on %s is not installed", slug) _LOGGER.warning("Add-on %s is not installed", slug)
return return
addon = self.local[slug]
try: await self.local[slug].uninstall()
await addon.instance.remove()
except DockerError as err:
raise AddonsError() from err
addon.state = AddonState.UNKNOWN
await addon.unload()
# Cleanup audio settings
if addon.path_pulse.exists():
with suppress(OSError):
addon.path_pulse.unlink()
# Cleanup AppArmor profile
with suppress(HostAppArmorError):
await addon.uninstall_apparmor()
# Cleanup Ingress panel from sidebar
if addon.ingress_panel:
addon.ingress_panel = False
with suppress(HomeAssistantAPIError):
await self.sys_ingress.update_hass_panel(addon)
# Cleanup Ingress dynamic port assignment
if addon.with_ingress:
self.sys_create_task(self.sys_ingress.reload())
self.sys_ingress.del_dynamic_port(slug)
# Cleanup discovery data
for message in self.sys_discovery.list_messages:
if message.addon != addon.slug:
continue
self.sys_discovery.remove(message)
# Cleanup services data
for service in self.sys_services.list_services:
if addon.slug not in service.active:
continue
service.del_service_data(addon)
self.data.uninstall(addon)
self.local.pop(slug)
_LOGGER.info("Add-on '%s' successfully removed", slug) _LOGGER.info("Add-on '%s' successfully removed", slug)
@ -262,10 +190,10 @@ class AddonManager(CoreSysAttributes):
) )
async def update( async def update(
self, slug: str, backup: bool | None = False self, slug: str, backup: bool | None = False
) -> Awaitable[None] | None: ) -> asyncio.Task | None:
"""Update add-on. """Update add-on.
Returns a coroutine that completes when addon has state 'started' (see addon.start) Returns a Task that completes when addon has state 'started' (see addon.start)
if addon is started after update. Else nothing is returned. if addon is started after update. Else nothing is returned.
""" """
self.sys_jobs.current.reference = slug self.sys_jobs.current.reference = slug
@ -293,41 +221,7 @@ class AddonManager(CoreSysAttributes):
addons=[addon.slug], addons=[addon.slug],
) )
# Update instance return await addon.update()
old_image = addon.image
# Cache data to prevent races with other updates to global
store = store.clone()
try:
await addon.instance.update(store.version, store.image)
except DockerError as err:
raise AddonsError() from err
# Stop the addon if running
if (last_state := addon.state) in {AddonState.STARTED, AddonState.STARTUP}:
await addon.stop()
try:
_LOGGER.info("Add-on '%s' successfully updated", slug)
self.data.update(store)
# Cleanup
with suppress(DockerError):
await addon.instance.cleanup(
old_image=old_image, image=store.image, version=store.version
)
# Setup/Fix AppArmor profile
await addon.install_apparmor()
finally:
# restore state. Return awaitable for caller if no exception
out = (
await addon.start()
if last_state in {AddonState.STARTED, AddonState.STARTUP}
else None
)
return out
@Job( @Job(
name="addon_manager_rebuild", name="addon_manager_rebuild",
@ -338,10 +232,10 @@ class AddonManager(CoreSysAttributes):
], ],
on_condition=AddonsJobError, on_condition=AddonsJobError,
) )
async def rebuild(self, slug: str) -> Awaitable[None] | None: async def rebuild(self, slug: str) -> asyncio.Task | None:
"""Perform a rebuild of local build add-on. """Perform a rebuild of local build add-on.
Returns a coroutine that completes when addon has state 'started' (see addon.start) Returns a Task that completes when addon has state 'started' (see addon.start)
if addon is started after rebuild. Else nothing is returned. if addon is started after rebuild. Else nothing is returned.
""" """
self.sys_jobs.current.reference = slug self.sys_jobs.current.reference = slug
@ -366,23 +260,7 @@ class AddonManager(CoreSysAttributes):
"Can't rebuild a image based add-on", _LOGGER.error "Can't rebuild a image based add-on", _LOGGER.error
) )
# remove docker container but not addon config return await addon.rebuild()
last_state: AddonState = addon.state
try:
await addon.instance.remove()
await addon.instance.install(addon.version)
except DockerError as err:
raise AddonsError() from err
self.data.update(store)
_LOGGER.info("Add-on '%s' successfully rebuilt", slug)
# restore state
return (
await addon.start()
if last_state in [AddonState.STARTED, AddonState.STARTUP]
else None
)
@Job( @Job(
name="addon_manager_restore", name="addon_manager_restore",
@ -395,10 +273,10 @@ class AddonManager(CoreSysAttributes):
) )
async def restore( async def restore(
self, slug: str, tar_file: tarfile.TarFile self, slug: str, tar_file: tarfile.TarFile
) -> Awaitable[None] | None: ) -> asyncio.Task | None:
"""Restore state of an add-on. """Restore state of an add-on.
Returns a coroutine that completes when addon has state 'started' (see addon.start) Returns a Task that completes when addon has state 'started' (see addon.start)
if addon is started after restore. Else nothing is returned. if addon is started after restore. Else nothing is returned.
""" """
self.sys_jobs.current.reference = slug self.sys_jobs.current.reference = slug

View File

@ -65,12 +65,14 @@ from ..exceptions import (
AddonsNotSupportedError, AddonsNotSupportedError,
ConfigurationFileError, ConfigurationFileError,
DockerError, DockerError,
HomeAssistantAPIError,
HostAppArmorError, HostAppArmorError,
) )
from ..hardware.data import Device from ..hardware.data import Device
from ..homeassistant.const import WSEvent, WSType from ..homeassistant.const import WSEvent, WSType
from ..jobs.const import JobExecutionLimit from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job from ..jobs.decorator import Job
from ..store.addon import AddonStore
from ..utils import check_port from ..utils import check_port
from ..utils.apparmor import adjust_profile from ..utils.apparmor import adjust_profile
from ..utils.json import read_json_file, write_json_file from ..utils.json import read_json_file, write_json_file
@ -200,6 +202,11 @@ class Addon(AddonModel):
"""Return add-on data from store.""" """Return add-on data from store."""
return self.sys_store.data.addons.get(self.slug, self.data) return self.sys_store.data.addons.get(self.slug, self.data)
@property
def addon_store(self) -> AddonStore | None:
"""Return store representation of addon."""
return self.sys_addons.store.get(self.slug)
@property @property
def persist(self) -> Data: def persist(self) -> Data:
"""Return add-on data/config.""" """Return add-on data/config."""
@ -575,6 +582,11 @@ class Addon(AddonModel):
raise AddonConfigurationError() raise AddonConfigurationError()
@Job(
name="addon_unload",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def unload(self) -> None: async def unload(self) -> None:
"""Unload add-on and remove data.""" """Unload add-on and remove data."""
if self._startup_task: if self._startup_task:
@ -594,6 +606,177 @@ class Addon(AddonModel):
_LOGGER.info("Removing add-on config folder %s", self.path_config) _LOGGER.info("Removing add-on config folder %s", self.path_config)
await remove_data(self.path_config) await remove_data(self.path_config)
@Job(
name="addon_install",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def install(self) -> None:
"""Install and setup this addon."""
self.sys_addons.data.install(self.addon_store)
await self.load()
if not self.path_data.is_dir():
_LOGGER.info(
"Creating Home Assistant add-on data folder %s", self.path_data
)
self.path_data.mkdir()
if self.addon_config_used and not self.path_config.is_dir():
_LOGGER.info(
"Creating Home Assistant add-on config folder %s", self.path_config
)
self.path_config.mkdir()
# Setup/Fix AppArmor profile
await self.install_apparmor()
# Install image
try:
await self.instance.install(
self.latest_version, self.addon_store.image, arch=self.arch
)
except DockerError as err:
self.sys_addons.data.uninstall(self)
raise AddonsError() from err
# Add to addon manager
self.sys_addons.local[self.slug] = self
# Reload ingress tokens
if self.with_ingress:
await self.sys_ingress.reload()
@Job(
name="addon_uninstall",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def uninstall(self) -> None:
"""Uninstall and cleanup this addon."""
try:
await self.instance.remove()
except DockerError as err:
raise AddonsError() from err
self.state = AddonState.UNKNOWN
await self.unload()
# Cleanup audio settings
if self.path_pulse.exists():
with suppress(OSError):
self.path_pulse.unlink()
# Cleanup AppArmor profile
with suppress(HostAppArmorError):
await self.uninstall_apparmor()
# Cleanup Ingress panel from sidebar
if self.ingress_panel:
self.ingress_panel = False
with suppress(HomeAssistantAPIError):
await self.sys_ingress.update_hass_panel(self)
# Cleanup Ingress dynamic port assignment
if self.with_ingress:
self.sys_create_task(self.sys_ingress.reload())
self.sys_ingress.del_dynamic_port(self.slug)
# Cleanup discovery data
for message in self.sys_discovery.list_messages:
if message.addon != self.slug:
continue
self.sys_discovery.remove(message)
# Cleanup services data
for service in self.sys_services.list_services:
if self.slug not in service.active:
continue
service.del_service_data(self)
# Remove from addon manager
self.sys_addons.data.uninstall(self)
self.sys_addons.local.pop(self.slug)
@Job(
name="addon_update",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def update(self) -> asyncio.Task | None:
"""Update this addon to latest version.
Returns a Task that completes when addon has state 'started' (see start)
if it was running. Else nothing is returned.
"""
old_image = self.image
# Cache data to prevent races with other updates to global
store = self.addon_store.clone()
try:
await self.instance.update(store.version, store.image)
except DockerError as err:
raise AddonsError() from err
# Stop the addon if running
if (last_state := self.state) in {AddonState.STARTED, AddonState.STARTUP}:
await self.stop()
try:
_LOGGER.info("Add-on '%s' successfully updated", self.slug)
self.sys_addons.data.update(store)
# Cleanup
with suppress(DockerError):
await self.instance.cleanup(
old_image=old_image, image=store.image, version=store.version
)
# Setup/Fix AppArmor profile
await self.install_apparmor()
finally:
# restore state. Return Task for caller if no exception
out = (
await self.start()
if last_state in {AddonState.STARTED, AddonState.STARTUP}
else None
)
return out
@Job(
name="addon_rebuild",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def rebuild(self) -> asyncio.Task | None:
"""Rebuild this addons container and image.
Returns a Task that completes when addon has state 'started' (see start)
if it was running. Else nothing is returned.
"""
last_state: AddonState = self.state
try:
# remove docker container but not addon config
try:
await self.instance.remove()
await self.instance.install(self.version)
except DockerError as err:
raise AddonsError() from err
self.sys_addons.data.update(self.addon_store)
_LOGGER.info("Add-on '%s' successfully rebuilt", self.slug)
finally:
# restore state
out = (
await self.start()
if last_state in [AddonState.STARTED, AddonState.STARTUP]
else None
)
return out
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(
@ -689,16 +872,21 @@ class Addon(AddonModel):
finally: finally:
self._startup_task = None self._startup_task = None
async def start(self) -> Awaitable[None]: @Job(
name="addon_start",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def start(self) -> asyncio.Task:
"""Set options and start add-on. """Set options and start add-on.
Returns a coroutine that completes when addon has state 'started'. Returns a Task that completes when addon has state 'started'.
For addons with a healthcheck, that is when they become healthy or unhealthy. For addons with a healthcheck, that is when they become healthy or unhealthy.
Addons without a healthcheck have state 'started' immediately. Addons without a healthcheck have state 'started' immediately.
""" """
if await self.instance.is_running(): if await self.instance.is_running():
_LOGGER.warning("%s is already running!", self.slug) _LOGGER.warning("%s is already running!", self.slug)
return self._wait_for_startup() return self.sys_create_task(self._wait_for_startup())
# Access Token # Access Token
self.persist[ATTR_ACCESS_TOKEN] = secrets.token_hex(56) self.persist[ATTR_ACCESS_TOKEN] = secrets.token_hex(56)
@ -719,8 +907,13 @@ class Addon(AddonModel):
self.state = AddonState.ERROR self.state = AddonState.ERROR
raise AddonsError() from err raise AddonsError() from err
return self._wait_for_startup() return self.sys_create_task(self._wait_for_startup())
@Job(
name="addon_stop",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def stop(self) -> None: async def stop(self) -> None:
"""Stop add-on.""" """Stop add-on."""
self._manual_stop = True self._manual_stop = True
@ -730,10 +923,15 @@ class Addon(AddonModel):
self.state = AddonState.ERROR self.state = AddonState.ERROR
raise AddonsError() from err raise AddonsError() from err
async def restart(self) -> Awaitable[None]: @Job(
name="addon_restart",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def restart(self) -> asyncio.Task:
"""Restart add-on. """Restart add-on.
Returns a coroutine that completes when addon has state 'started' (see start). Returns a Task that completes when addon has state 'started' (see start).
""" """
with suppress(AddonsError): with suppress(AddonsError):
await self.stop() await self.stop()
@ -760,6 +958,11 @@ class Addon(AddonModel):
except DockerError as err: except DockerError as err:
raise AddonsError() from err raise AddonsError() from err
@Job(
name="addon_write_stdin",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def write_stdin(self, data) -> None: async def write_stdin(self, data) -> None:
"""Write data to add-on stdin.""" """Write data to add-on stdin."""
if not self.with_stdin: if not self.with_stdin:
@ -789,7 +992,11 @@ class Addon(AddonModel):
_LOGGER.error, _LOGGER.error,
) from err ) from err
@Job(name="addon_begin_backup") @Job(
name="addon_begin_backup",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def begin_backup(self) -> bool: async def begin_backup(self) -> bool:
"""Execute pre commands or stop addon if necessary. """Execute pre commands or stop addon if necessary.
@ -807,11 +1014,15 @@ class Addon(AddonModel):
return True return True
@Job(name="addon_end_backup") @Job(
async def end_backup(self) -> Awaitable[None] | None: name="addon_end_backup",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def end_backup(self) -> asyncio.Task | None:
"""Execute post commands or restart addon if necessary. """Execute post commands or restart addon if necessary.
Returns a coroutine that completes when addon has state 'started' (see start) Returns a Task that completes when addon has state 'started' (see start)
for cold backup. Else nothing is returned. for cold backup. Else nothing is returned.
""" """
if self.backup_mode is AddonBackupMode.COLD: if self.backup_mode is AddonBackupMode.COLD:
@ -822,11 +1033,15 @@ class Addon(AddonModel):
await self._backup_command(self.backup_post) await self._backup_command(self.backup_post)
return None return None
@Job(name="addon_backup") @Job(
async def backup(self, tar_file: tarfile.TarFile) -> Awaitable[None] | None: name="addon_backup",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def backup(self, tar_file: tarfile.TarFile) -> asyncio.Task | None:
"""Backup state of an add-on. """Backup state of an add-on.
Returns a coroutine that completes when addon has state 'started' (see start) Returns a Task that completes when addon has state 'started' (see start)
for cold backup. Else nothing is returned. for cold backup. Else nothing is returned.
""" """
wait_for_start: Awaitable[None] | None = None wait_for_start: Awaitable[None] | None = None
@ -905,10 +1120,15 @@ class Addon(AddonModel):
_LOGGER.info("Finish backup for addon %s", self.slug) _LOGGER.info("Finish backup for addon %s", self.slug)
return wait_for_start return wait_for_start
async def restore(self, tar_file: tarfile.TarFile) -> Awaitable[None] | None: @Job(
name="addon_restore",
limit=JobExecutionLimit.GROUP_ONCE,
on_condition=AddonsJobError,
)
async def restore(self, tar_file: tarfile.TarFile) -> asyncio.Task | None:
"""Restore state of an add-on. """Restore state of an add-on.
Returns a coroutine that completes when addon has state 'started' (see start) Returns a Task that completes when addon has state 'started' (see start)
if addon is started after restore. Else nothing is returned. if addon is started after restore. Else nothing is returned.
""" """
wait_for_start: Awaitable[None] | None = None wait_for_start: Awaitable[None] | None = None

View File

@ -1,7 +1,7 @@
"""Init file for Supervisor add-ons.""" """Init file for Supervisor add-ons."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from collections.abc import Awaitable, Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
import logging import logging
from pathlib import Path from pathlib import Path
@ -669,19 +669,3 @@ class AddonModel(JobGroup, ABC):
# local build # local build
return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default}-addon-{config[ATTR_SLUG]}" return f"{config[ATTR_REPOSITORY]}/{self.sys_arch.default}-addon-{config[ATTR_SLUG]}"
def install(self) -> Awaitable[None]:
"""Install this add-on."""
return self.sys_addons.install(self.slug)
def uninstall(self) -> Awaitable[None]:
"""Uninstall this add-on."""
return self.sys_addons.uninstall(self.slug)
def update(self, backup: bool | None = False) -> Awaitable[Awaitable[None] | None]:
"""Update this add-on."""
return self.sys_addons.update(self.slug, backup=backup)
def rebuild(self) -> Awaitable[Awaitable[None] | None]:
"""Rebuild this add-on."""
return self.sys_addons.rebuild(self.slug)

View File

@ -388,7 +388,7 @@ class APIAddons(CoreSysAttributes):
def uninstall(self, request: web.Request) -> Awaitable[None]: def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on.""" """Uninstall add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
return asyncio.shield(addon.uninstall()) return asyncio.shield(self.sys_addons.uninstall(addon.slug))
@api_process @api_process
async def start(self, request: web.Request) -> None: async def start(self, request: web.Request) -> None:
@ -414,7 +414,7 @@ class APIAddons(CoreSysAttributes):
async def rebuild(self, request: web.Request) -> None: async def rebuild(self, request: web.Request) -> None:
"""Rebuild local build add-on.""" """Rebuild local build add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
if start_task := await asyncio.shield(addon.rebuild()): if start_task := await asyncio.shield(self.sys_addons.rebuild(addon.slug)):
await start_task await start_task
@api_process_raw(CONTENT_TYPE_BINARY) @api_process_raw(CONTENT_TYPE_BINARY)

View File

@ -199,7 +199,7 @@ class APIStore(CoreSysAttributes):
def addons_addon_install(self, request: web.Request) -> Awaitable[None]: def addons_addon_install(self, request: web.Request) -> Awaitable[None]:
"""Install add-on.""" """Install add-on."""
addon = self._extract_addon(request) addon = self._extract_addon(request)
return asyncio.shield(addon.install()) return asyncio.shield(self.sys_addons.install(addon.slug))
@api_process @api_process
async def addons_addon_update(self, request: web.Request) -> None: async def addons_addon_update(self, request: web.Request) -> None:
@ -211,7 +211,7 @@ class APIStore(CoreSysAttributes):
body = await api_validate(SCHEMA_UPDATE, request) body = await api_validate(SCHEMA_UPDATE, request)
if start_task := await asyncio.shield( if start_task := await asyncio.shield(
addon.update(backup=body.get(ATTR_BACKUP)) self.sys_addons.update(addon.slug, backup=body.get(ATTR_BACKUP))
): ):
await start_task await start_task

View File

@ -349,14 +349,14 @@ class Backup(CoreSysAttributes):
finally: finally:
self._tmp.cleanup() self._tmp.cleanup()
async def store_addons(self, addon_list: list[str]) -> list[Awaitable[None]]: async def store_addons(self, addon_list: list[str]) -> list[asyncio.Task]:
"""Add a list of add-ons into backup. """Add a list of add-ons into backup.
For each addon that needs to be started after backup, returns a task which For each addon that needs to be started after backup, returns a Task which
completes when that addon has state 'started' (see addon.start). completes when that addon has state 'started' (see addon.start).
""" """
async def _addon_save(addon: Addon) -> Awaitable[None] | None: async def _addon_save(addon: Addon) -> asyncio.Task | None:
"""Task to store an add-on into backup.""" """Task to store an add-on into backup."""
tar_name = f"{addon.slug}.tar{'.gz' if self.compressed else ''}" tar_name = f"{addon.slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile( addon_file = SecureTarFile(
@ -388,7 +388,7 @@ class Backup(CoreSysAttributes):
# Save Add-ons sequential # Save Add-ons sequential
# avoid issue on slow IO # avoid issue on slow IO
start_tasks: list[Awaitable[None]] = [] start_tasks: list[asyncio.Task] = []
for addon in addon_list: for addon in addon_list:
try: try:
if start_task := await _addon_save(addon): if start_task := await _addon_save(addon):
@ -398,10 +398,10 @@ class Backup(CoreSysAttributes):
return start_tasks return start_tasks
async def restore_addons(self, addon_list: list[str]) -> list[Awaitable[None]]: async def restore_addons(self, addon_list: list[str]) -> list[asyncio.Task]:
"""Restore a list add-on from backup.""" """Restore a list add-on from backup."""
async def _addon_restore(addon_slug: str) -> Awaitable[None] | None: async def _addon_restore(addon_slug: str) -> asyncio.Task | None:
"""Task to restore an add-on into backup.""" """Task to restore an add-on into backup."""
tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}" tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}"
addon_file = SecureTarFile( addon_file = SecureTarFile(
@ -425,7 +425,7 @@ class Backup(CoreSysAttributes):
# Save Add-ons sequential # Save Add-ons sequential
# avoid issue on slow IO # avoid issue on slow IO
start_tasks: list[Awaitable[None]] = [] start_tasks: list[asyncio.Task] = []
for slug in addon_list: for slug in addon_list:
try: try:
if start_task := await _addon_restore(slug): if start_task := await _addon_restore(slug):

View File

@ -406,7 +406,7 @@ class BackupManager(FileConfiguration, JobGroup):
# Remove Add-on because it's not a part of the new env # Remove Add-on because it's not a part of the new env
# Do it sequential avoid issue on slow IO # Do it sequential avoid issue on slow IO
try: try:
await addon.uninstall() await self.sys_addons.uninstall(addon.slug)
except AddonsError: except AddonsError:
_LOGGER.warning("Can't uninstall Add-on %s", addon.slug) _LOGGER.warning("Can't uninstall Add-on %s", addon.slug)
@ -614,7 +614,7 @@ class BackupManager(FileConfiguration, JobGroup):
await self.sys_homeassistant.end_backup() await self.sys_homeassistant.end_backup()
self._change_stage(BackupJobStage.ADDONS) self._change_stage(BackupJobStage.ADDONS)
addon_start_tasks: list[Awaitable[None]] = [ addon_start_tasks: list[asyncio.Task] = [
task task
for task in await asyncio.gather( for task in await asyncio.gather(
*[addon.end_backup() for addon in running_addons] *[addon.end_backup() for addon in running_addons]

View File

@ -103,7 +103,7 @@ class Tasks(CoreSysAttributes):
# avoid issue on slow IO # avoid issue on slow IO
_LOGGER.info("Add-on auto update process %s", addon.slug) _LOGGER.info("Add-on auto update process %s", addon.slug)
try: try:
if start_task := await addon.update(backup=True): if start_task := await self.sys_addons.update(addon.slug, backup=True):
start_tasks.append(start_task) start_tasks.append(start_task)
except AddonsError: except AddonsError:
_LOGGER.error("Can't auto update Add-on %s", addon.slug) _LOGGER.error("Can't auto update Add-on %s", addon.slug)

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch from unittest.mock import MagicMock, PropertyMock, patch
from docker.errors import DockerException, NotFound from docker.errors import DockerException, NotFound
@ -267,7 +268,7 @@ async def test_install_update_fails_if_out_of_date(
with pytest.raises(AddonsJobError): with pytest.raises(AddonsJobError):
await coresys.addons.install(TEST_ADDON_SLUG) await coresys.addons.install(TEST_ADDON_SLUG)
with pytest.raises(AddonsJobError): with pytest.raises(AddonsJobError):
await install_addon_ssh.update() await coresys.addons.update(TEST_ADDON_SLUG)
with patch.object( with patch.object(
type(coresys.plugins.audio), "need_update", new=PropertyMock(return_value=True) type(coresys.plugins.audio), "need_update", new=PropertyMock(return_value=True)
@ -277,7 +278,7 @@ async def test_install_update_fails_if_out_of_date(
with pytest.raises(AddonsJobError): with pytest.raises(AddonsJobError):
await coresys.addons.install(TEST_ADDON_SLUG) await coresys.addons.install(TEST_ADDON_SLUG)
with pytest.raises(AddonsJobError): with pytest.raises(AddonsJobError):
await install_addon_ssh.update() await coresys.addons.update(TEST_ADDON_SLUG)
async def test_listeners_removed_on_uninstall( async def test_listeners_removed_on_uninstall(
@ -342,7 +343,7 @@ async def test_start_wait_healthcheck(
await asyncio.sleep(0) await asyncio.sleep(0)
assert install_addon_ssh.state == AddonState.STOPPED assert install_addon_ssh.state == AddonState.STOPPED
start_task = asyncio.create_task(await install_addon_ssh.start()) start_task = await install_addon_ssh.start()
assert start_task assert start_task
_fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING) _fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING)
@ -646,3 +647,24 @@ async def test_start_when_running(
await start_task await start_task
assert "local_ssh is already running" in caplog.text assert "local_ssh is already running" in caplog.text
async def test_install(
coresys: CoreSys, container: MagicMock, tmp_supervisor_data: Path, repository
):
"""Test install of an addon."""
assert not (
data_dir := tmp_supervisor_data / "addons" / "data" / "local_example"
).exists()
assert not (
config_dir := tmp_supervisor_data / "addon_configs" / "local_example"
).exists()
with patch.object(
CpuArch, "supported", new=PropertyMock(return_value=["aarch64"])
), patch.object(DockerAddon, "install") as install:
await coresys.addons.install("local_example")
install.assert_called_once()
assert data_dir.is_dir()
assert config_dir.is_dir()

View File

@ -65,7 +65,7 @@ async def test_image_added_removed_on_update(
with patch.object(DockerInterface, "install") as install, patch.object( with patch.object(DockerInterface, "install") as install, patch.object(
DockerAddon, "_build" DockerAddon, "_build"
) as build: ) as build:
await install_addon_ssh.update() await coresys.addons.update(TEST_ADDON_SLUG)
build.assert_not_called() build.assert_not_called()
install.assert_called_once_with( install.assert_called_once_with(
AwesomeVersion("10.0.0"), "test/amd64-my-ssh-addon", False, None AwesomeVersion("10.0.0"), "test/amd64-my-ssh-addon", False, None
@ -85,7 +85,7 @@ async def test_image_added_removed_on_update(
with patch.object(DockerInterface, "install") as install, patch.object( with patch.object(DockerInterface, "install") as install, patch.object(
DockerAddon, "_build" DockerAddon, "_build"
) as build: ) as build:
await install_addon_ssh.update() await coresys.addons.update(TEST_ADDON_SLUG)
build.assert_called_once_with(AwesomeVersion("11.0.0")) build.assert_called_once_with(AwesomeVersion("11.0.0"))
install.assert_not_called() install.assert_not_called()
@ -299,7 +299,7 @@ async def test_start_wait_cancel_on_uninstall(
await asyncio.sleep(0) await asyncio.sleep(0)
assert install_addon_ssh.state == AddonState.STOPPED assert install_addon_ssh.state == AddonState.STOPPED
start_task = asyncio.create_task(await install_addon_ssh.start()) start_task = await install_addon_ssh.start()
assert start_task assert start_task
coresys.bus.fire_event( coresys.bus.fire_event(