mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-13 12:16:29 +00:00
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:
parent
18e422ca77
commit
31200df89f
@ -17,8 +17,8 @@ from ..exceptions import (
|
||||
DockerAPIError,
|
||||
DockerError,
|
||||
DockerNotFound,
|
||||
HassioError,
|
||||
HomeAssistantAPIError,
|
||||
HostAppArmorError,
|
||||
)
|
||||
from ..jobs.decorator import Job, JobCondition
|
||||
from ..resolution.const import ContextType, IssueType, SuggestionType
|
||||
@ -119,8 +119,8 @@ class AddonManager(CoreSysAttributes):
|
||||
):
|
||||
addon.boot = AddonBoot.MANUAL
|
||||
addon.save_persist()
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
capture_exception(err)
|
||||
except HassioError:
|
||||
pass # These are already handled
|
||||
else:
|
||||
continue
|
||||
|
||||
@ -169,36 +169,7 @@ class AddonManager(CoreSysAttributes):
|
||||
|
||||
store.validate_availability()
|
||||
|
||||
self.data.install(store)
|
||||
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()
|
||||
await Addon(self.coresys, slug).install()
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully installed", slug)
|
||||
|
||||
@ -207,51 +178,8 @@ class AddonManager(CoreSysAttributes):
|
||||
if slug not in self.local:
|
||||
_LOGGER.warning("Add-on %s is not installed", slug)
|
||||
return
|
||||
addon = self.local[slug]
|
||||
|
||||
try:
|
||||
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)
|
||||
await self.local[slug].uninstall()
|
||||
|
||||
_LOGGER.info("Add-on '%s' successfully removed", slug)
|
||||
|
||||
@ -262,10 +190,10 @@ class AddonManager(CoreSysAttributes):
|
||||
)
|
||||
async def update(
|
||||
self, slug: str, backup: bool | None = False
|
||||
) -> Awaitable[None] | None:
|
||||
) -> asyncio.Task | None:
|
||||
"""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.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
@ -293,41 +221,7 @@ class AddonManager(CoreSysAttributes):
|
||||
addons=[addon.slug],
|
||||
)
|
||||
|
||||
# Update instance
|
||||
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
|
||||
return await addon.update()
|
||||
|
||||
@Job(
|
||||
name="addon_manager_rebuild",
|
||||
@ -338,10 +232,10 @@ class AddonManager(CoreSysAttributes):
|
||||
],
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
@ -366,23 +260,7 @@ class AddonManager(CoreSysAttributes):
|
||||
"Can't rebuild a image based add-on", _LOGGER.error
|
||||
)
|
||||
|
||||
# remove docker container but not addon config
|
||||
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
|
||||
)
|
||||
return await addon.rebuild()
|
||||
|
||||
@Job(
|
||||
name="addon_manager_restore",
|
||||
@ -395,10 +273,10 @@ class AddonManager(CoreSysAttributes):
|
||||
)
|
||||
async def restore(
|
||||
self, slug: str, tar_file: tarfile.TarFile
|
||||
) -> Awaitable[None] | None:
|
||||
) -> asyncio.Task | None:
|
||||
"""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.
|
||||
"""
|
||||
self.sys_jobs.current.reference = slug
|
||||
|
@ -65,12 +65,14 @@ from ..exceptions import (
|
||||
AddonsNotSupportedError,
|
||||
ConfigurationFileError,
|
||||
DockerError,
|
||||
HomeAssistantAPIError,
|
||||
HostAppArmorError,
|
||||
)
|
||||
from ..hardware.data import Device
|
||||
from ..homeassistant.const import WSEvent, WSType
|
||||
from ..jobs.const import JobExecutionLimit
|
||||
from ..jobs.decorator import Job
|
||||
from ..store.addon import AddonStore
|
||||
from ..utils import check_port
|
||||
from ..utils.apparmor import adjust_profile
|
||||
from ..utils.json import read_json_file, write_json_file
|
||||
@ -200,6 +202,11 @@ class Addon(AddonModel):
|
||||
"""Return add-on data from store."""
|
||||
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
|
||||
def persist(self) -> Data:
|
||||
"""Return add-on data/config."""
|
||||
@ -575,6 +582,11 @@ class Addon(AddonModel):
|
||||
|
||||
raise AddonConfigurationError()
|
||||
|
||||
@Job(
|
||||
name="addon_unload",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def unload(self) -> None:
|
||||
"""Unload add-on and remove data."""
|
||||
if self._startup_task:
|
||||
@ -594,6 +606,177 @@ class Addon(AddonModel):
|
||||
_LOGGER.info("Removing add-on config folder %s", 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:
|
||||
"""Write asound config to file and return True on success."""
|
||||
pulse_config = self.sys_plugins.audio.pulse_client(
|
||||
@ -689,16 +872,21 @@ class Addon(AddonModel):
|
||||
finally:
|
||||
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.
|
||||
|
||||
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.
|
||||
Addons without a healthcheck have state 'started' immediately.
|
||||
"""
|
||||
if await self.instance.is_running():
|
||||
_LOGGER.warning("%s is already running!", self.slug)
|
||||
return self._wait_for_startup()
|
||||
return self.sys_create_task(self._wait_for_startup())
|
||||
|
||||
# Access Token
|
||||
self.persist[ATTR_ACCESS_TOKEN] = secrets.token_hex(56)
|
||||
@ -719,8 +907,13 @@ class Addon(AddonModel):
|
||||
self.state = AddonState.ERROR
|
||||
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:
|
||||
"""Stop add-on."""
|
||||
self._manual_stop = True
|
||||
@ -730,10 +923,15 @@ class Addon(AddonModel):
|
||||
self.state = AddonState.ERROR
|
||||
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.
|
||||
|
||||
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):
|
||||
await self.stop()
|
||||
@ -760,6 +958,11 @@ class Addon(AddonModel):
|
||||
except DockerError as err:
|
||||
raise AddonsError() from err
|
||||
|
||||
@Job(
|
||||
name="addon_write_stdin",
|
||||
limit=JobExecutionLimit.GROUP_ONCE,
|
||||
on_condition=AddonsJobError,
|
||||
)
|
||||
async def write_stdin(self, data) -> None:
|
||||
"""Write data to add-on stdin."""
|
||||
if not self.with_stdin:
|
||||
@ -789,7 +992,11 @@ class Addon(AddonModel):
|
||||
_LOGGER.error,
|
||||
) 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:
|
||||
"""Execute pre commands or stop addon if necessary.
|
||||
|
||||
@ -807,11 +1014,15 @@ class Addon(AddonModel):
|
||||
|
||||
return True
|
||||
|
||||
@Job(name="addon_end_backup")
|
||||
async def end_backup(self) -> Awaitable[None] | None:
|
||||
@Job(
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
if self.backup_mode is AddonBackupMode.COLD:
|
||||
@ -822,11 +1033,15 @@ class Addon(AddonModel):
|
||||
await self._backup_command(self.backup_post)
|
||||
return None
|
||||
|
||||
@Job(name="addon_backup")
|
||||
async def backup(self, tar_file: tarfile.TarFile) -> Awaitable[None] | None:
|
||||
@Job(
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
wait_for_start: Awaitable[None] | None = None
|
||||
@ -905,10 +1120,15 @@ class Addon(AddonModel):
|
||||
_LOGGER.info("Finish backup for addon %s", self.slug)
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
wait_for_start: Awaitable[None] | None = None
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""Init file for Supervisor add-ons."""
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@ -669,19 +669,3 @@ class AddonModel(JobGroup, ABC):
|
||||
|
||||
# local build
|
||||
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)
|
||||
|
@ -388,7 +388,7 @@ class APIAddons(CoreSysAttributes):
|
||||
def uninstall(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Uninstall add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.uninstall())
|
||||
return asyncio.shield(self.sys_addons.uninstall(addon.slug))
|
||||
|
||||
@api_process
|
||||
async def start(self, request: web.Request) -> None:
|
||||
@ -414,7 +414,7 @@ class APIAddons(CoreSysAttributes):
|
||||
async def rebuild(self, request: web.Request) -> None:
|
||||
"""Rebuild local build add-on."""
|
||||
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
|
||||
|
||||
@api_process_raw(CONTENT_TYPE_BINARY)
|
||||
|
@ -199,7 +199,7 @@ class APIStore(CoreSysAttributes):
|
||||
def addons_addon_install(self, request: web.Request) -> Awaitable[None]:
|
||||
"""Install add-on."""
|
||||
addon = self._extract_addon(request)
|
||||
return asyncio.shield(addon.install())
|
||||
return asyncio.shield(self.sys_addons.install(addon.slug))
|
||||
|
||||
@api_process
|
||||
async def addons_addon_update(self, request: web.Request) -> None:
|
||||
@ -211,7 +211,7 @@ class APIStore(CoreSysAttributes):
|
||||
body = await api_validate(SCHEMA_UPDATE, request)
|
||||
|
||||
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
|
||||
|
||||
|
@ -349,14 +349,14 @@ class Backup(CoreSysAttributes):
|
||||
finally:
|
||||
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.
|
||||
|
||||
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).
|
||||
"""
|
||||
|
||||
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."""
|
||||
tar_name = f"{addon.slug}.tar{'.gz' if self.compressed else ''}"
|
||||
addon_file = SecureTarFile(
|
||||
@ -388,7 +388,7 @@ class Backup(CoreSysAttributes):
|
||||
|
||||
# Save Add-ons sequential
|
||||
# avoid issue on slow IO
|
||||
start_tasks: list[Awaitable[None]] = []
|
||||
start_tasks: list[asyncio.Task] = []
|
||||
for addon in addon_list:
|
||||
try:
|
||||
if start_task := await _addon_save(addon):
|
||||
@ -398,10 +398,10 @@ class Backup(CoreSysAttributes):
|
||||
|
||||
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."""
|
||||
|
||||
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."""
|
||||
tar_name = f"{addon_slug}.tar{'.gz' if self.compressed else ''}"
|
||||
addon_file = SecureTarFile(
|
||||
@ -425,7 +425,7 @@ class Backup(CoreSysAttributes):
|
||||
|
||||
# Save Add-ons sequential
|
||||
# avoid issue on slow IO
|
||||
start_tasks: list[Awaitable[None]] = []
|
||||
start_tasks: list[asyncio.Task] = []
|
||||
for slug in addon_list:
|
||||
try:
|
||||
if start_task := await _addon_restore(slug):
|
||||
|
@ -406,7 +406,7 @@ class BackupManager(FileConfiguration, JobGroup):
|
||||
# Remove Add-on because it's not a part of the new env
|
||||
# Do it sequential avoid issue on slow IO
|
||||
try:
|
||||
await addon.uninstall()
|
||||
await self.sys_addons.uninstall(addon.slug)
|
||||
except AddonsError:
|
||||
_LOGGER.warning("Can't uninstall Add-on %s", addon.slug)
|
||||
|
||||
@ -614,7 +614,7 @@ class BackupManager(FileConfiguration, JobGroup):
|
||||
await self.sys_homeassistant.end_backup()
|
||||
|
||||
self._change_stage(BackupJobStage.ADDONS)
|
||||
addon_start_tasks: list[Awaitable[None]] = [
|
||||
addon_start_tasks: list[asyncio.Task] = [
|
||||
task
|
||||
for task in await asyncio.gather(
|
||||
*[addon.end_backup() for addon in running_addons]
|
||||
|
@ -103,7 +103,7 @@ class Tasks(CoreSysAttributes):
|
||||
# avoid issue on slow IO
|
||||
_LOGGER.info("Add-on auto update process %s", addon.slug)
|
||||
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)
|
||||
except AddonsError:
|
||||
_LOGGER.error("Can't auto update Add-on %s", addon.slug)
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from docker.errors import DockerException, NotFound
|
||||
@ -267,7 +268,7 @@ async def test_install_update_fails_if_out_of_date(
|
||||
with pytest.raises(AddonsJobError):
|
||||
await coresys.addons.install(TEST_ADDON_SLUG)
|
||||
with pytest.raises(AddonsJobError):
|
||||
await install_addon_ssh.update()
|
||||
await coresys.addons.update(TEST_ADDON_SLUG)
|
||||
|
||||
with patch.object(
|
||||
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):
|
||||
await coresys.addons.install(TEST_ADDON_SLUG)
|
||||
with pytest.raises(AddonsJobError):
|
||||
await install_addon_ssh.update()
|
||||
await coresys.addons.update(TEST_ADDON_SLUG)
|
||||
|
||||
|
||||
async def test_listeners_removed_on_uninstall(
|
||||
@ -342,7 +343,7 @@ async def test_start_wait_healthcheck(
|
||||
await asyncio.sleep(0)
|
||||
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
|
||||
|
||||
_fire_test_event(coresys, f"addon_{TEST_ADDON_SLUG}", ContainerState.RUNNING)
|
||||
@ -646,3 +647,24 @@ async def test_start_when_running(
|
||||
await start_task
|
||||
|
||||
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()
|
||||
|
@ -65,7 +65,7 @@ async def test_image_added_removed_on_update(
|
||||
with patch.object(DockerInterface, "install") as install, patch.object(
|
||||
DockerAddon, "_build"
|
||||
) as build:
|
||||
await install_addon_ssh.update()
|
||||
await coresys.addons.update(TEST_ADDON_SLUG)
|
||||
build.assert_not_called()
|
||||
install.assert_called_once_with(
|
||||
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(
|
||||
DockerAddon, "_build"
|
||||
) as build:
|
||||
await install_addon_ssh.update()
|
||||
await coresys.addons.update(TEST_ADDON_SLUG)
|
||||
build.assert_called_once_with(AwesomeVersion("11.0.0"))
|
||||
install.assert_not_called()
|
||||
|
||||
@ -299,7 +299,7 @@ async def test_start_wait_cancel_on_uninstall(
|
||||
await asyncio.sleep(0)
|
||||
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
|
||||
|
||||
coresys.bus.fire_event(
|
||||
|
Loading…
x
Reference in New Issue
Block a user