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,
|
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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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]
|
||||||
|
@ -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)
|
||||||
|
@ -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()
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user