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,
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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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):

View File

@ -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]

View File

@ -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)

View File

@ -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()

View File

@ -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(