From c64744dedff340b6f1637da81b762ef699b400d5 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 12 Dec 2023 03:36:05 -0500 Subject: [PATCH] Refactor addons init to addons manager (#4760) Co-authored-by: Stefan Agner --- supervisor/addons/__init__.py | 373 -------------------------------- supervisor/addons/manager.py | 374 +++++++++++++++++++++++++++++++++ supervisor/api/addons.py | 2 +- supervisor/api/store.py | 2 +- supervisor/backups/backup.py | 2 +- supervisor/bootstrap.py | 2 +- supervisor/coresys.py | 2 +- tests/docker/test_interface.py | 2 +- 8 files changed, 380 insertions(+), 379 deletions(-) create mode 100644 supervisor/addons/manager.py diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 96cf09799..8938064a1 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -1,374 +1 @@ """Init file for Supervisor add-ons.""" -import asyncio -from collections.abc import Awaitable -from contextlib import suppress -import logging -import tarfile -from typing import Union - -from ..const import AddonBoot, AddonStartup, AddonState -from ..coresys import CoreSys, CoreSysAttributes -from ..exceptions import ( - AddonConfigurationError, - AddonsError, - AddonsJobError, - AddonsNotSupportedError, - CoreDNSError, - DockerAPIError, - DockerError, - DockerNotFound, - HassioError, - HomeAssistantAPIError, -) -from ..jobs.decorator import Job, JobCondition -from ..resolution.const import ContextType, IssueType, SuggestionType -from ..store.addon import AddonStore -from ..utils import check_exception_chain -from ..utils.sentry import capture_exception -from .addon import Addon -from .const import ADDON_UPDATE_CONDITIONS -from .data import AddonsData - -_LOGGER: logging.Logger = logging.getLogger(__name__) - -AnyAddon = Union[Addon, AddonStore] - - -class AddonManager(CoreSysAttributes): - """Manage add-ons inside Supervisor.""" - - def __init__(self, coresys: CoreSys): - """Initialize Docker base wrapper.""" - self.coresys: CoreSys = coresys - self.data: AddonsData = AddonsData(coresys) - self.local: dict[str, Addon] = {} - self.store: dict[str, AddonStore] = {} - - @property - def all(self) -> list[AnyAddon]: - """Return a list of all add-ons.""" - addons: dict[str, AnyAddon] = {**self.store, **self.local} - return list(addons.values()) - - @property - def installed(self) -> list[Addon]: - """Return a list of all installed add-ons.""" - return list(self.local.values()) - - def get(self, addon_slug: str, local_only: bool = False) -> AnyAddon | None: - """Return an add-on from slug. - - Prio: - 1 - Local - 2 - Store - """ - if addon_slug in self.local: - return self.local[addon_slug] - if not local_only: - return self.store.get(addon_slug) - return None - - def from_token(self, token: str) -> Addon | None: - """Return an add-on from Supervisor token.""" - for addon in self.installed: - if token == addon.supervisor_token: - return addon - return None - - async def load(self) -> None: - """Start up add-on management.""" - tasks = [] - for slug in self.data.system: - addon = self.local[slug] = Addon(self.coresys, slug) - tasks.append(self.sys_create_task(addon.load())) - - # Run initial tasks - _LOGGER.info("Found %d installed add-ons", len(tasks)) - if tasks: - await asyncio.wait(tasks) - - # Sync DNS - await self.sync_dns() - - async def boot(self, stage: AddonStartup) -> None: - """Boot add-ons with mode auto.""" - tasks: list[Addon] = [] - for addon in self.installed: - if addon.boot != AddonBoot.AUTO or addon.startup != stage: - continue - tasks.append(addon) - - # Evaluate add-ons which need to be started - _LOGGER.info("Phase '%s' starting %d add-ons", stage, len(tasks)) - if not tasks: - return - - # Start Add-ons sequential - # avoid issue on slow IO - # Config.wait_boot is deprecated. Until addons update with healthchecks, - # add a sleep task for it to keep the same minimum amount of wait time - wait_boot: list[Awaitable[None]] = [asyncio.sleep(self.sys_config.wait_boot)] - for addon in tasks: - try: - if start_task := await addon.start(): - wait_boot.append(start_task) - except AddonsError as err: - # Check if there is an system/user issue - if check_exception_chain( - err, (DockerAPIError, DockerNotFound, AddonConfigurationError) - ): - addon.boot = AddonBoot.MANUAL - addon.save_persist() - except HassioError: - pass # These are already handled - else: - continue - - _LOGGER.warning("Can't start Add-on %s", addon.slug) - - # Ignore exceptions from waiting for addon startup, addon errors handled elsewhere - await asyncio.gather(*wait_boot, return_exceptions=True) - - async def shutdown(self, stage: AddonStartup) -> None: - """Shutdown addons.""" - tasks: list[Addon] = [] - for addon in self.installed: - if addon.state != AddonState.STARTED or addon.startup != stage: - continue - tasks.append(addon) - - # Evaluate add-ons which need to be stopped - _LOGGER.info("Phase '%s' stopping %d add-ons", stage, len(tasks)) - if not tasks: - return - - # Stop Add-ons sequential - # avoid issue on slow IO - for addon in tasks: - try: - await addon.stop() - except Exception as err: # pylint: disable=broad-except - _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) - capture_exception(err) - - @Job( - name="addon_manager_install", - conditions=ADDON_UPDATE_CONDITIONS, - on_condition=AddonsJobError, - ) - async def install(self, slug: str) -> None: - """Install an add-on.""" - self.sys_jobs.current.reference = slug - - if slug in self.local: - raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning) - store = self.store.get(slug) - - if not store: - raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error) - - store.validate_availability() - - await Addon(self.coresys, slug).install() - - _LOGGER.info("Add-on '%s' successfully installed", slug) - - async def uninstall(self, slug: str) -> None: - """Remove an add-on.""" - if slug not in self.local: - _LOGGER.warning("Add-on %s is not installed", slug) - return - - await self.local[slug].uninstall() - - _LOGGER.info("Add-on '%s' successfully removed", slug) - - @Job( - name="addon_manager_update", - conditions=ADDON_UPDATE_CONDITIONS, - on_condition=AddonsJobError, - ) - async def update( - self, slug: str, backup: bool | None = False - ) -> asyncio.Task | None: - """Update add-on. - - 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 - - if slug not in self.local: - raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error) - addon = self.local[slug] - - if addon.is_detached: - raise AddonsError( - f"Add-on {slug} is not available inside store", _LOGGER.error - ) - store = self.store[slug] - - if addon.version == store.version: - raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning) - - # Check if available, Maybe something have changed - store.validate_availability() - - if backup: - await self.sys_backups.do_backup_partial( - name=f"addon_{addon.slug}_{addon.version}", - homeassistant=False, - addons=[addon.slug], - ) - - return await addon.update() - - @Job( - name="addon_manager_rebuild", - conditions=[ - JobCondition.FREE_SPACE, - JobCondition.INTERNET_HOST, - JobCondition.HEALTHY, - ], - on_condition=AddonsJobError, - ) - async def rebuild(self, slug: str) -> asyncio.Task | None: - """Perform a rebuild of local build add-on. - - 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 - - if slug not in self.local: - raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error) - addon = self.local[slug] - - if addon.is_detached: - raise AddonsError( - f"Add-on {slug} is not available inside store", _LOGGER.error - ) - store = self.store[slug] - - # Check if a rebuild is possible now - if addon.version != store.version: - raise AddonsError( - "Version changed, use Update instead Rebuild", _LOGGER.error - ) - if not addon.need_build: - raise AddonsNotSupportedError( - "Can't rebuild a image based add-on", _LOGGER.error - ) - - return await addon.rebuild() - - @Job( - name="addon_manager_restore", - conditions=[ - JobCondition.FREE_SPACE, - JobCondition.INTERNET_HOST, - JobCondition.HEALTHY, - ], - on_condition=AddonsJobError, - ) - async def restore( - self, slug: str, tar_file: tarfile.TarFile - ) -> asyncio.Task | None: - """Restore state of an add-on. - - 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 - - if slug not in self.local: - _LOGGER.debug("Add-on %s is not local available for restore", slug) - addon = Addon(self.coresys, slug) - had_ingress = False - else: - _LOGGER.debug("Add-on %s is local available for restore", slug) - addon = self.local[slug] - had_ingress = addon.ingress_panel - - wait_for_start = await addon.restore(tar_file) - - # Check if new - if slug not in self.local: - _LOGGER.info("Detect new Add-on after restore %s", slug) - self.local[slug] = addon - - # Update ingress - if had_ingress != addon.ingress_panel: - await self.sys_ingress.reload() - with suppress(HomeAssistantAPIError): - await self.sys_ingress.update_hass_panel(addon) - - return wait_for_start - - @Job( - name="addon_manager_repair", - conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST], - ) - async def repair(self) -> None: - """Repair local add-ons.""" - needs_repair: list[Addon] = [] - - # Evaluate Add-ons to repair - for addon in self.installed: - if await addon.instance.exists(): - continue - needs_repair.append(addon) - - _LOGGER.info("Found %d add-ons to repair", len(needs_repair)) - if not needs_repair: - return - - for addon in needs_repair: - _LOGGER.info("Repairing for add-on: %s", addon.slug) - with suppress(DockerError, KeyError): - # Need pull a image again - if not addon.need_build: - await addon.instance.install(addon.version, addon.image) - continue - - # Need local lookup - if addon.need_build and not addon.is_detached: - store = self.store[addon.slug] - # If this add-on is available for rebuild - if addon.version == store.version: - await addon.instance.install(addon.version, addon.image) - continue - - _LOGGER.error("Can't repair %s", addon.slug) - with suppress(AddonsError): - await self.uninstall(addon.slug) - - async def sync_dns(self) -> None: - """Sync add-ons DNS names.""" - # Update hosts - add_host_coros: list[Awaitable[None]] = [] - for addon in self.installed: - try: - if not await addon.instance.is_running(): - continue - except DockerError as err: - _LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err) - self.sys_resolution.create_issue( - IssueType.CORRUPT_DOCKER, - ContextType.ADDON, - reference=addon.slug, - suggestions=[SuggestionType.EXECUTE_REPAIR], - ) - capture_exception(err) - else: - add_host_coros.append( - self.sys_plugins.dns.add_host( - ipv4=addon.ip_address, names=[addon.hostname], write=False - ) - ) - - await asyncio.gather(*add_host_coros) - - # Write hosts files - with suppress(CoreDNSError): - await self.sys_plugins.dns.write_hosts() diff --git a/supervisor/addons/manager.py b/supervisor/addons/manager.py new file mode 100644 index 000000000..baa395aad --- /dev/null +++ b/supervisor/addons/manager.py @@ -0,0 +1,374 @@ +"""Supervisor add-on manager.""" +import asyncio +from collections.abc import Awaitable +from contextlib import suppress +import logging +import tarfile +from typing import Union + +from ..const import AddonBoot, AddonStartup, AddonState +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import ( + AddonConfigurationError, + AddonsError, + AddonsJobError, + AddonsNotSupportedError, + CoreDNSError, + DockerAPIError, + DockerError, + DockerNotFound, + HassioError, + HomeAssistantAPIError, +) +from ..jobs.decorator import Job, JobCondition +from ..resolution.const import ContextType, IssueType, SuggestionType +from ..store.addon import AddonStore +from ..utils import check_exception_chain +from ..utils.sentry import capture_exception +from .addon import Addon +from .const import ADDON_UPDATE_CONDITIONS +from .data import AddonsData + +_LOGGER: logging.Logger = logging.getLogger(__name__) + +AnyAddon = Union[Addon, AddonStore] + + +class AddonManager(CoreSysAttributes): + """Manage add-ons inside Supervisor.""" + + def __init__(self, coresys: CoreSys): + """Initialize Docker base wrapper.""" + self.coresys: CoreSys = coresys + self.data: AddonsData = AddonsData(coresys) + self.local: dict[str, Addon] = {} + self.store: dict[str, AddonStore] = {} + + @property + def all(self) -> list[AnyAddon]: + """Return a list of all add-ons.""" + addons: dict[str, AnyAddon] = {**self.store, **self.local} + return list(addons.values()) + + @property + def installed(self) -> list[Addon]: + """Return a list of all installed add-ons.""" + return list(self.local.values()) + + def get(self, addon_slug: str, local_only: bool = False) -> AnyAddon | None: + """Return an add-on from slug. + + Prio: + 1 - Local + 2 - Store + """ + if addon_slug in self.local: + return self.local[addon_slug] + if not local_only: + return self.store.get(addon_slug) + return None + + def from_token(self, token: str) -> Addon | None: + """Return an add-on from Supervisor token.""" + for addon in self.installed: + if token == addon.supervisor_token: + return addon + return None + + async def load(self) -> None: + """Start up add-on management.""" + tasks = [] + for slug in self.data.system: + addon = self.local[slug] = Addon(self.coresys, slug) + tasks.append(self.sys_create_task(addon.load())) + + # Run initial tasks + _LOGGER.info("Found %d installed add-ons", len(tasks)) + if tasks: + await asyncio.wait(tasks) + + # Sync DNS + await self.sync_dns() + + async def boot(self, stage: AddonStartup) -> None: + """Boot add-ons with mode auto.""" + tasks: list[Addon] = [] + for addon in self.installed: + if addon.boot != AddonBoot.AUTO or addon.startup != stage: + continue + tasks.append(addon) + + # Evaluate add-ons which need to be started + _LOGGER.info("Phase '%s' starting %d add-ons", stage, len(tasks)) + if not tasks: + return + + # Start Add-ons sequential + # avoid issue on slow IO + # Config.wait_boot is deprecated. Until addons update with healthchecks, + # add a sleep task for it to keep the same minimum amount of wait time + wait_boot: list[Awaitable[None]] = [asyncio.sleep(self.sys_config.wait_boot)] + for addon in tasks: + try: + if start_task := await addon.start(): + wait_boot.append(start_task) + except AddonsError as err: + # Check if there is an system/user issue + if check_exception_chain( + err, (DockerAPIError, DockerNotFound, AddonConfigurationError) + ): + addon.boot = AddonBoot.MANUAL + addon.save_persist() + except HassioError: + pass # These are already handled + else: + continue + + _LOGGER.warning("Can't start Add-on %s", addon.slug) + + # Ignore exceptions from waiting for addon startup, addon errors handled elsewhere + await asyncio.gather(*wait_boot, return_exceptions=True) + + async def shutdown(self, stage: AddonStartup) -> None: + """Shutdown addons.""" + tasks: list[Addon] = [] + for addon in self.installed: + if addon.state != AddonState.STARTED or addon.startup != stage: + continue + tasks.append(addon) + + # Evaluate add-ons which need to be stopped + _LOGGER.info("Phase '%s' stopping %d add-ons", stage, len(tasks)) + if not tasks: + return + + # Stop Add-ons sequential + # avoid issue on slow IO + for addon in tasks: + try: + await addon.stop() + except Exception as err: # pylint: disable=broad-except + _LOGGER.warning("Can't stop Add-on %s: %s", addon.slug, err) + capture_exception(err) + + @Job( + name="addon_manager_install", + conditions=ADDON_UPDATE_CONDITIONS, + on_condition=AddonsJobError, + ) + async def install(self, slug: str) -> None: + """Install an add-on.""" + self.sys_jobs.current.reference = slug + + if slug in self.local: + raise AddonsError(f"Add-on {slug} is already installed", _LOGGER.warning) + store = self.store.get(slug) + + if not store: + raise AddonsError(f"Add-on {slug} does not exist", _LOGGER.error) + + store.validate_availability() + + await Addon(self.coresys, slug).install() + + _LOGGER.info("Add-on '%s' successfully installed", slug) + + async def uninstall(self, slug: str) -> None: + """Remove an add-on.""" + if slug not in self.local: + _LOGGER.warning("Add-on %s is not installed", slug) + return + + await self.local[slug].uninstall() + + _LOGGER.info("Add-on '%s' successfully removed", slug) + + @Job( + name="addon_manager_update", + conditions=ADDON_UPDATE_CONDITIONS, + on_condition=AddonsJobError, + ) + async def update( + self, slug: str, backup: bool | None = False + ) -> asyncio.Task | None: + """Update add-on. + + 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 + + if slug not in self.local: + raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error) + addon = self.local[slug] + + if addon.is_detached: + raise AddonsError( + f"Add-on {slug} is not available inside store", _LOGGER.error + ) + store = self.store[slug] + + if addon.version == store.version: + raise AddonsError(f"No update available for add-on {slug}", _LOGGER.warning) + + # Check if available, Maybe something have changed + store.validate_availability() + + if backup: + await self.sys_backups.do_backup_partial( + name=f"addon_{addon.slug}_{addon.version}", + homeassistant=False, + addons=[addon.slug], + ) + + return await addon.update() + + @Job( + name="addon_manager_rebuild", + conditions=[ + JobCondition.FREE_SPACE, + JobCondition.INTERNET_HOST, + JobCondition.HEALTHY, + ], + on_condition=AddonsJobError, + ) + async def rebuild(self, slug: str) -> asyncio.Task | None: + """Perform a rebuild of local build add-on. + + 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 + + if slug not in self.local: + raise AddonsError(f"Add-on {slug} is not installed", _LOGGER.error) + addon = self.local[slug] + + if addon.is_detached: + raise AddonsError( + f"Add-on {slug} is not available inside store", _LOGGER.error + ) + store = self.store[slug] + + # Check if a rebuild is possible now + if addon.version != store.version: + raise AddonsError( + "Version changed, use Update instead Rebuild", _LOGGER.error + ) + if not addon.need_build: + raise AddonsNotSupportedError( + "Can't rebuild a image based add-on", _LOGGER.error + ) + + return await addon.rebuild() + + @Job( + name="addon_manager_restore", + conditions=[ + JobCondition.FREE_SPACE, + JobCondition.INTERNET_HOST, + JobCondition.HEALTHY, + ], + on_condition=AddonsJobError, + ) + async def restore( + self, slug: str, tar_file: tarfile.TarFile + ) -> asyncio.Task | None: + """Restore state of an add-on. + + 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 + + if slug not in self.local: + _LOGGER.debug("Add-on %s is not local available for restore", slug) + addon = Addon(self.coresys, slug) + had_ingress = False + else: + _LOGGER.debug("Add-on %s is local available for restore", slug) + addon = self.local[slug] + had_ingress = addon.ingress_panel + + wait_for_start = await addon.restore(tar_file) + + # Check if new + if slug not in self.local: + _LOGGER.info("Detect new Add-on after restore %s", slug) + self.local[slug] = addon + + # Update ingress + if had_ingress != addon.ingress_panel: + await self.sys_ingress.reload() + with suppress(HomeAssistantAPIError): + await self.sys_ingress.update_hass_panel(addon) + + return wait_for_start + + @Job( + name="addon_manager_repair", + conditions=[JobCondition.FREE_SPACE, JobCondition.INTERNET_HOST], + ) + async def repair(self) -> None: + """Repair local add-ons.""" + needs_repair: list[Addon] = [] + + # Evaluate Add-ons to repair + for addon in self.installed: + if await addon.instance.exists(): + continue + needs_repair.append(addon) + + _LOGGER.info("Found %d add-ons to repair", len(needs_repair)) + if not needs_repair: + return + + for addon in needs_repair: + _LOGGER.info("Repairing for add-on: %s", addon.slug) + with suppress(DockerError, KeyError): + # Need pull a image again + if not addon.need_build: + await addon.instance.install(addon.version, addon.image) + continue + + # Need local lookup + if addon.need_build and not addon.is_detached: + store = self.store[addon.slug] + # If this add-on is available for rebuild + if addon.version == store.version: + await addon.instance.install(addon.version, addon.image) + continue + + _LOGGER.error("Can't repair %s", addon.slug) + with suppress(AddonsError): + await self.uninstall(addon.slug) + + async def sync_dns(self) -> None: + """Sync add-ons DNS names.""" + # Update hosts + add_host_coros: list[Awaitable[None]] = [] + for addon in self.installed: + try: + if not await addon.instance.is_running(): + continue + except DockerError as err: + _LOGGER.warning("Add-on %s is corrupt: %s", addon.slug, err) + self.sys_resolution.create_issue( + IssueType.CORRUPT_DOCKER, + ContextType.ADDON, + reference=addon.slug, + suggestions=[SuggestionType.EXECUTE_REPAIR], + ) + capture_exception(err) + else: + add_host_coros.append( + self.sys_plugins.dns.add_host( + ipv4=addon.ip_address, names=[addon.hostname], write=False + ) + ) + + await asyncio.gather(*add_host_coros) + + # Write hosts files + with suppress(CoreDNSError): + await self.sys_plugins.dns.write_hosts() diff --git a/supervisor/api/addons.py b/supervisor/api/addons.py index 1022d9c0c..075ab8928 100644 --- a/supervisor/api/addons.py +++ b/supervisor/api/addons.py @@ -8,8 +8,8 @@ from aiohttp import web import voluptuous as vol from voluptuous.humanize import humanize_error -from ..addons import AnyAddon from ..addons.addon import Addon +from ..addons.manager import AnyAddon from ..addons.utils import rating_security from ..const import ( ATTR_ADDONS, diff --git a/supervisor/api/store.py b/supervisor/api/store.py index 5fb6fae5d..a3a09b81e 100644 --- a/supervisor/api/store.py +++ b/supervisor/api/store.py @@ -6,7 +6,7 @@ from typing import Any from aiohttp import web import voluptuous as vol -from ..addons import AnyAddon +from ..addons.manager import AnyAddon from ..addons.utils import rating_security from ..api.const import ATTR_SIGNED from ..api.utils import api_process, api_process_raw, api_validate diff --git a/supervisor/backups/backup.py b/supervisor/backups/backup.py index 1182a0480..d45a9e6de 100644 --- a/supervisor/backups/backup.py +++ b/supervisor/backups/backup.py @@ -19,7 +19,7 @@ from securetar import SecureTarFile, atomic_contents_add, secure_path import voluptuous as vol from voluptuous.humanize import humanize_error -from ..addons import Addon +from ..addons.manager import Addon from ..const import ( ATTR_ADDONS, ATTR_COMPRESSED, diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 51a79e958..a153b8715 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -6,7 +6,7 @@ import signal from colorlog import ColoredFormatter -from .addons import AddonManager +from .addons.manager import AddonManager from .api import RestAPI from .arch import CpuArch from .auth import Auth diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 62bb6e648..f8a8f7f71 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -18,7 +18,7 @@ from .const import ENV_SUPERVISOR_DEV, SERVER_SOFTWARE from .utils.dt import UTC, get_time_zone if TYPE_CHECKING: - from .addons import AddonManager + from .addons.manager import AddonManager from .api import RestAPI from .arch import CpuArch from .auth import Auth diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index a9222de54..3cf03c549 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -10,7 +10,7 @@ from docker.models.images import Image import pytest from requests import RequestException -from supervisor.addons import Addon +from supervisor.addons.manager import Addon from supervisor.const import BusEvent, CpuArch from supervisor.coresys import CoreSys from supervisor.docker.const import ContainerState