diff --git a/requirements.txt b/requirements.txt index cc1777553e8..3227ebc0e0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiodns==3.5.0 +aiodocker==0.24.0 aiohttp==3.13.1 atomicwrites-homeassistant==1.4.1 attrs==25.4.0 diff --git a/supervisor/bus.py b/supervisor/bus.py index 4061cf37cfa..715ad70f2c3 100644 --- a/supervisor/bus.py +++ b/supervisor/bus.py @@ -2,6 +2,7 @@ from __future__ import annotations +from asyncio import Task from collections.abc import Callable, Coroutine import logging from typing import Any @@ -38,11 +39,13 @@ def register_event( self._listeners.setdefault(event, []).append(listener) return listener - def fire_event(self, event: BusEvent, reference: Any) -> None: + def fire_event(self, event: BusEvent, reference: Any) -> list[Task]: """Fire an event to the bus.""" _LOGGER.debug("Fire event '%s' with '%s'", event, reference) + tasks: list[Task] = [] for listener in self._listeners.get(event, []): - self.sys_create_task(listener.callback(reference)) + tasks.append(self.sys_create_task(listener.callback(reference))) + return tasks def remove_listener(self, listener: EventListener) -> None: """Unregister an listener.""" diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index 01427948509..f9bacbd550f 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import TYPE_CHECKING, cast +import aiodocker from attr import evolve from awesomeversion import AwesomeVersion import docker @@ -717,19 +718,21 @@ def build_image(): error_message = f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}" raise docker.errors.DockerException(error_message) - addon_image = self.sys_docker.images.get(addon_image_tag) - - return addon_image, logs + return addon_image_tag, logs try: - docker_image, log = await self.sys_run_in_executor(build_image) + addon_image_tag, log = await self.sys_run_in_executor(build_image) _LOGGER.debug("Build %s:%s done: %s", self.image, version, log) # Update meta data - self._meta = docker_image.attrs + self._meta = await self.sys_docker.images.inspect(addon_image_tag) - except (docker.errors.DockerException, requests.RequestException) as err: + except ( + docker.errors.DockerException, + requests.RequestException, + aiodocker.DockerError, + ) as err: _LOGGER.error("Can't build %s:%s: %s", self.image, version, err) raise DockerError() from err @@ -751,11 +754,8 @@ def export_image(self, tar_file: Path) -> None: ) async def import_image(self, tar_file: Path) -> None: """Import a tar file as image.""" - docker_image = await self.sys_run_in_executor( - self.sys_docker.import_image, tar_file - ) - if docker_image: - self._meta = docker_image.attrs + if docker_image := await self.sys_docker.import_image(tar_file): + self._meta = docker_image _LOGGER.info("Importing image %s and version %s", tar_file, self.version) with suppress(DockerError): @@ -769,17 +769,21 @@ async def cleanup( version: AwesomeVersion | None = None, ) -> None: """Check if old version exists and cleanup other versions of image not in use.""" - await self.sys_run_in_executor( - self.sys_docker.cleanup_old_images, - (image := image or self.image), - version or self.version, + if not (use_image := image or self.image): + raise DockerError("Cannot determine image from metadata!", _LOGGER.error) + if not (use_version := version or self.version): + raise DockerError("Cannot determine version from metadata!", _LOGGER.error) + + await self.sys_docker.cleanup_old_images( + use_image, + use_version, {old_image} if old_image else None, keep_images={ f"{addon.image}:{addon.version}" for addon in self.sys_addons.installed if addon.slug != self.addon.slug and addon.image - and addon.image in {old_image, image} + and addon.image in {old_image, use_image} }, ) diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index b92559b5558..76985af0471 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -1,6 +1,5 @@ """Init file for Supervisor Docker object.""" -from collections.abc import Awaitable from ipaddress import IPv4Address import logging import re @@ -236,13 +235,12 @@ async def execute_command(self, command: str) -> CommandReturn: environment={ENV_TIME: self.sys_timezone}, ) - def is_initialize(self) -> Awaitable[bool]: + async def is_initialize(self) -> bool: """Return True if Docker container exists.""" - return self.sys_run_in_executor( - self.sys_docker.container_is_initialized, - self.name, - self.image, - self.sys_homeassistant.version, + if not self.sys_homeassistant.version: + return False + return await self.sys_docker.container_is_initialized( + self.name, self.image, self.sys_homeassistant.version ) async def _validate_trust(self, image_id: str) -> None: diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index b6efddecbb3..16142557d0b 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -6,17 +6,18 @@ from collections import defaultdict from collections.abc import Awaitable from contextlib import suppress +from http import HTTPStatus import logging import re from time import time from typing import Any, cast from uuid import uuid4 +import aiodocker from awesomeversion import AwesomeVersion from awesomeversion.strategy import AwesomeVersionStrategy import docker from docker.models.containers import Container -from docker.models.images import Image import requests from ..bus import EventListener @@ -35,6 +36,7 @@ CodeNotaryUntrusted, DockerAPIError, DockerError, + DockerHubRateLimitExceeded, DockerJobError, DockerLogOutOfOrder, DockerNotFound, @@ -218,7 +220,7 @@ async def _docker_login(self, image: str) -> None: if not credentials: return - await self.sys_run_in_executor(self.sys_docker.docker.login, **credentials) + await self.sys_run_in_executor(self.sys_docker.dockerpy.login, **credentials) def _process_pull_image_log( self, install_job_id: str, reference: PullLogEntry @@ -417,8 +419,7 @@ async def process_pull_image_log(reference: PullLogEntry) -> None: ) # Pull new image - docker_image = await self.sys_run_in_executor( - self.sys_docker.pull_image, + docker_image = await self.sys_docker.pull_image( self.sys_jobs.current.uuid, image, str(version), @@ -427,13 +428,11 @@ async def process_pull_image_log(reference: PullLogEntry) -> None: # Validate content try: - await self._validate_trust(cast(str, docker_image.id)) + await self._validate_trust(cast(str, docker_image["Id"])) except CodeNotaryError: - with suppress(docker.errors.DockerException): - await self.sys_run_in_executor( - self.sys_docker.images.remove, - image=f"{image}:{version!s}", - force=True, + with suppress(aiodocker.DockerError, requests.RequestException): + await self.sys_docker.images.delete( + f"{image}:{version!s}", force=True ) raise @@ -442,22 +441,37 @@ async def process_pull_image_log(reference: PullLogEntry) -> None: _LOGGER.info( "Tagging image %s with version %s as latest", image, version ) - await self.sys_run_in_executor(docker_image.tag, image, tag="latest") + await self.sys_docker.images.tag( + docker_image["Id"], image, tag="latest" + ) except docker.errors.APIError as err: - if err.status_code == 429: + if err.status_code == HTTPStatus.TOO_MANY_REQUESTS: self.sys_resolution.create_issue( IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM, suggestions=[SuggestionType.REGISTRY_LOGIN], ) - _LOGGER.info( - "Your IP address has made too many requests to Docker Hub which activated a rate limit. " - "For more details see https://www.home-assistant.io/more-info/dockerhub-rate-limit" + raise DockerHubRateLimitExceeded(_LOGGER.error) from err + await async_capture_exception(err) + raise DockerError( + f"Can't install {image}:{version!s}: {err}", _LOGGER.error + ) from err + except aiodocker.DockerError as err: + if err.status == HTTPStatus.TOO_MANY_REQUESTS: + self.sys_resolution.create_issue( + IssueType.DOCKER_RATELIMIT, + ContextType.SYSTEM, + suggestions=[SuggestionType.REGISTRY_LOGIN], ) + raise DockerHubRateLimitExceeded(_LOGGER.error) from err + await async_capture_exception(err) raise DockerError( f"Can't install {image}:{version!s}: {err}", _LOGGER.error ) from err - except (docker.errors.DockerException, requests.RequestException) as err: + except ( + docker.errors.DockerException, + requests.RequestException, + ) as err: await async_capture_exception(err) raise DockerError( f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error @@ -476,14 +490,12 @@ async def process_pull_image_log(reference: PullLogEntry) -> None: if listener: self.sys_bus.remove_listener(listener) - self._meta = docker_image.attrs + self._meta = docker_image async def exists(self) -> bool: """Return True if Docker image exists in local repository.""" - with suppress(docker.errors.DockerException, requests.RequestException): - await self.sys_run_in_executor( - self.sys_docker.images.get, f"{self.image}:{self.version!s}" - ) + with suppress(aiodocker.DockerError, requests.RequestException): + await self.sys_docker.images.inspect(f"{self.image}:{self.version!s}") return True return False @@ -542,11 +554,11 @@ async def attach( ), ) - with suppress(docker.errors.DockerException, requests.RequestException): + with suppress(aiodocker.DockerError, requests.RequestException): if not self._meta and self.image: - self._meta = self.sys_docker.images.get( + self._meta = await self.sys_docker.images.inspect( f"{self.image}:{version!s}" - ).attrs + ) # Successful? if not self._meta: @@ -614,14 +626,17 @@ def start(self) -> Awaitable[None]: ) async def remove(self, *, remove_image: bool = True) -> None: """Remove Docker images.""" + if not self.image or not self.version: + raise DockerError( + "Cannot determine image and/or version from metadata!", _LOGGER.error + ) + # Cleanup container with suppress(DockerError): await self.stop() if remove_image: - await self.sys_run_in_executor( - self.sys_docker.remove_image, self.image, self.version - ) + await self.sys_docker.remove_image(self.image, self.version) self._meta = None @@ -643,18 +658,16 @@ async def check_image( image_name = f"{expected_image}:{version!s}" if self.image == expected_image: try: - image: Image = await self.sys_run_in_executor( - self.sys_docker.images.get, image_name - ) - except (docker.errors.DockerException, requests.RequestException) as err: + image = await self.sys_docker.images.inspect(image_name) + except (aiodocker.DockerError, requests.RequestException) as err: raise DockerError( f"Could not get {image_name} for check due to: {err!s}", _LOGGER.error, ) from err - image_arch = f"{image.attrs['Os']}/{image.attrs['Architecture']}" - if "Variant" in image.attrs: - image_arch = f"{image_arch}/{image.attrs['Variant']}" + image_arch = f"{image['Os']}/{image['Architecture']}" + if "Variant" in image: + image_arch = f"{image_arch}/{image['Variant']}" # If we have an image and its the right arch, all set # It seems that newer Docker version return a variant for arm64 images. @@ -716,11 +729,13 @@ async def cleanup( version: AwesomeVersion | None = None, ) -> None: """Check if old version exists and cleanup.""" - await self.sys_run_in_executor( - self.sys_docker.cleanup_old_images, - image or self.image, - version or self.version, - {old_image} if old_image else None, + if not (use_image := image or self.image): + raise DockerError("Cannot determine image from metadata!", _LOGGER.error) + if not (use_version := version or self.version): + raise DockerError("Cannot determine version from metadata!", _LOGGER.error) + + await self.sys_docker.cleanup_old_images( + use_image, use_version, {old_image} if old_image else None ) @Job( @@ -772,10 +787,10 @@ async def get_latest_version(self) -> AwesomeVersion: """Return latest version of local image.""" available_version: list[AwesomeVersion] = [] try: - for image in await self.sys_run_in_executor( - self.sys_docker.images.list, self.image + for image in await self.sys_docker.images.list( + filters=f'{{"reference": ["{self.image}"]}}' ): - for tag in image.tags: + for tag in image["RepoTags"]: version = AwesomeVersion(tag.partition(":")[2]) if version.strategy == AwesomeVersionStrategy.UNKNOWN: continue @@ -784,7 +799,7 @@ async def get_latest_version(self) -> AwesomeVersion: if not available_version: raise ValueError() - except (docker.errors.DockerException, ValueError) as err: + except (aiodocker.DockerError, ValueError) as err: raise DockerNotFound( f"No version found for {self.image}", _LOGGER.info ) from err @@ -823,10 +838,10 @@ async def _validate_trust(self, image_id: str) -> None: async def check_trust(self) -> None: """Check trust of exists Docker image.""" try: - image = await self.sys_run_in_executor( - self.sys_docker.images.get, f"{self.image}:{self.version!s}" + image = await self.sys_docker.images.inspect( + f"{self.image}:{self.version!s}" ) - except (docker.errors.DockerException, requests.RequestException): + except (aiodocker.DockerError, requests.RequestException): return - await self._validate_trust(cast(str, image.id)) + await self._validate_trust(cast(str, image["Id"])) diff --git a/supervisor/docker/manager.py b/supervisor/docker/manager.py index b34bd25968b..9b186ffa23c 100644 --- a/supervisor/docker/manager.py +++ b/supervisor/docker/manager.py @@ -6,20 +6,24 @@ from contextlib import suppress from dataclasses import dataclass from functools import partial +from http import HTTPStatus from ipaddress import IPv4Address +import json import logging import os from pathlib import Path +import re from typing import Any, Final, Self, cast +import aiodocker +from aiodocker.images import DockerImages +from aiohttp import ClientSession, ClientTimeout, UnixConnector import attr from awesomeversion import AwesomeVersion, AwesomeVersionCompareException from docker import errors as docker_errors from docker.api.client import APIClient from docker.client import DockerClient -from docker.errors import DockerException, ImageNotFound, NotFound from docker.models.containers import Container, ContainerCollection -from docker.models.images import Image, ImageCollection from docker.models.networks import Network from docker.types.daemon import CancellableStream import requests @@ -53,6 +57,7 @@ MIN_SUPPORTED_DOCKER: Final = AwesomeVersion("24.0.0") DOCKER_NETWORK_HOST: Final = "host" +RE_IMPORT_IMAGE_STREAM = re.compile(r"(^Loaded image ID: |^Loaded image: )(.+)$") @attr.s(frozen=True) @@ -204,7 +209,15 @@ class DockerAPI(CoreSysAttributes): def __init__(self, coresys: CoreSys): """Initialize Docker base wrapper.""" self.coresys = coresys - self._docker: DockerClient | None = None + # We keep both until we can fully refactor to aiodocker + self._dockerpy: DockerClient | None = None + self.docker: aiodocker.Docker = aiodocker.Docker( + url="unix://localhost", # dummy hostname for URL composition + connector=(connector := UnixConnector(SOCKET_DOCKER.as_posix())), + session=ClientSession(connector=connector, timeout=ClientTimeout(900)), + api_version="auto", + ) + self._network: DockerNetwork | None = None self._info: DockerInfo | None = None self.config: DockerConfig = DockerConfig() @@ -212,28 +225,30 @@ def __init__(self, coresys: CoreSys): async def post_init(self) -> Self: """Post init actions that must be done in event loop.""" - self._docker = await asyncio.get_running_loop().run_in_executor( + # Use /var/run/docker.sock for this one so aiodocker and dockerpy don't + # share the same handle. Temporary fix while refactoring this client out + self._dockerpy = await asyncio.get_running_loop().run_in_executor( None, partial( DockerClient, - base_url=f"unix:/{str(SOCKET_DOCKER)}", + base_url=f"unix://var{SOCKET_DOCKER.as_posix()}", version="auto", timeout=900, ), ) - self._info = DockerInfo.new(self.docker.info()) + self._info = DockerInfo.new(self.dockerpy.info()) await self.config.read_data() - self._network = await DockerNetwork(self.docker).post_init( + self._network = await DockerNetwork(self.dockerpy).post_init( self.config.enable_ipv6, self.config.mtu ) return self @property - def docker(self) -> DockerClient: + def dockerpy(self) -> DockerClient: """Get docker API client.""" - if not self._docker: + if not self._dockerpy: raise RuntimeError("Docker API Client not initialized!") - return self._docker + return self._dockerpy @property def network(self) -> DockerNetwork: @@ -243,19 +258,19 @@ def network(self) -> DockerNetwork: return self._network @property - def images(self) -> ImageCollection: + def images(self) -> DockerImages: """Return API images.""" return self.docker.images @property def containers(self) -> ContainerCollection: """Return API containers.""" - return self.docker.containers + return self.dockerpy.containers @property def api(self) -> APIClient: """Return API containers.""" - return self.docker.api + return self.dockerpy.api @property def info(self) -> DockerInfo: @@ -267,7 +282,7 @@ def info(self) -> DockerInfo: @property def events(self) -> CancellableStream: """Return docker event stream.""" - return self.docker.events(decode=True) + return self.dockerpy.events(decode=True) @property def monitor(self) -> DockerMonitor: @@ -383,7 +398,7 @@ def run( with suppress(DockerError): self.network.detach_default_bridge(container) else: - host_network: Network = self.docker.networks.get(DOCKER_NETWORK_HOST) + host_network: Network = self.dockerpy.networks.get(DOCKER_NETWORK_HOST) # Check if container is register on host # https://github.com/moby/moby/issues/23302 @@ -410,35 +425,32 @@ def run( return container - def pull_image( + async def pull_image( self, job_id: str, repository: str, tag: str = "latest", platform: str | None = None, - ) -> Image: + ) -> dict[str, Any]: """Pull the specified image and return it. This mimics the high level API of images.pull but provides better error handling by raising based on a docker error on pull. Whereas the high level API ignores all errors on pull and raises only if the get fails afterwards. Additionally it fires progress reports for the pull on the bus so listeners can use that to update status for users. - - Must be run in executor. """ - pull_log = self.docker.api.pull( - repository, tag=tag, platform=platform, stream=True, decode=True - ) - for e in pull_log: + async for e in self.images.pull( + repository, tag=tag, platform=platform, stream=True + ): entry = PullLogEntry.from_pull_log_dict(job_id, e) if entry.error: raise entry.exception - self.sys_loop.call_soon_threadsafe( - self.sys_bus.fire_event, BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry + await asyncio.gather( + *self.sys_bus.fire_event(BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry) ) sep = "@" if tag.startswith("sha256:") else ":" - return self.images.get(f"{repository}{sep}{tag}") + return await self.images.inspect(f"{repository}{sep}{tag}") def run_command( self, @@ -459,7 +471,7 @@ def run_command( _LOGGER.info("Runing command '%s' on %s", command, image_with_tag) container = None try: - container = self.docker.containers.run( + container = self.dockerpy.containers.run( image_with_tag, command=command, detach=True, @@ -487,35 +499,35 @@ def repair(self) -> None: """Repair local docker overlayfs2 issues.""" _LOGGER.info("Prune stale containers") try: - output = self.docker.api.prune_containers() + output = self.dockerpy.api.prune_containers() _LOGGER.debug("Containers prune: %s", output) except docker_errors.APIError as err: _LOGGER.warning("Error for containers prune: %s", err) _LOGGER.info("Prune stale images") try: - output = self.docker.api.prune_images(filters={"dangling": False}) + output = self.dockerpy.api.prune_images(filters={"dangling": False}) _LOGGER.debug("Images prune: %s", output) except docker_errors.APIError as err: _LOGGER.warning("Error for images prune: %s", err) _LOGGER.info("Prune stale builds") try: - output = self.docker.api.prune_builds() + output = self.dockerpy.api.prune_builds() _LOGGER.debug("Builds prune: %s", output) except docker_errors.APIError as err: _LOGGER.warning("Error for builds prune: %s", err) _LOGGER.info("Prune stale volumes") try: - output = self.docker.api.prune_builds() + output = self.dockerpy.api.prune_volumes() _LOGGER.debug("Volumes prune: %s", output) except docker_errors.APIError as err: _LOGGER.warning("Error for volumes prune: %s", err) _LOGGER.info("Prune stale networks") try: - output = self.docker.api.prune_networks() + output = self.dockerpy.api.prune_networks() _LOGGER.debug("Networks prune: %s", output) except docker_errors.APIError as err: _LOGGER.warning("Error for networks prune: %s", err) @@ -537,11 +549,11 @@ def prune_networks(self, network_name: str) -> None: Fix: https://github.com/moby/moby/issues/23302 """ - network: Network = self.docker.networks.get(network_name) + network: Network = self.dockerpy.networks.get(network_name) for cid, data in network.attrs.get("Containers", {}).items(): try: - self.docker.containers.get(cid) + self.dockerpy.containers.get(cid) continue except docker_errors.NotFound: _LOGGER.debug( @@ -556,22 +568,26 @@ def prune_networks(self, network_name: str) -> None: with suppress(docker_errors.DockerException, requests.RequestException): network.disconnect(data.get("Name", cid), force=True) - def container_is_initialized( + async def container_is_initialized( self, name: str, image: str, version: AwesomeVersion ) -> bool: """Return True if docker container exists in good state and is built from expected image.""" try: - docker_container = self.containers.get(name) - docker_image = self.images.get(f"{image}:{version}") - except NotFound: + docker_container = await self.sys_run_in_executor(self.containers.get, name) + docker_image = await self.images.inspect(f"{image}:{version}") + except docker_errors.NotFound: return False - except (DockerException, requests.RequestException) as err: + except aiodocker.DockerError as err: + if err.status == HTTPStatus.NOT_FOUND: + return False + raise DockerError() from err + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError() from err # Check the image is correct and state is good return ( docker_container.image is not None - and docker_container.image.id == docker_image.id + and docker_container.image.id == docker_image["Id"] and docker_container.status in ("exited", "running", "created") ) @@ -581,18 +597,18 @@ def stop_container( """Stop/remove Docker container.""" try: docker_container: Container = self.containers.get(name) - except NotFound: + except docker_errors.NotFound: raise DockerNotFound() from None - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError() from err if docker_container.status == "running": _LOGGER.info("Stopping %s application", name) - with suppress(DockerException, requests.RequestException): + with suppress(docker_errors.DockerException, requests.RequestException): docker_container.stop(timeout=timeout) if remove_container: - with suppress(DockerException, requests.RequestException): + with suppress(docker_errors.DockerException, requests.RequestException): _LOGGER.info("Cleaning %s application", name) docker_container.remove(force=True, v=True) @@ -604,11 +620,11 @@ def start_container(self, name: str) -> None: """Start Docker container.""" try: docker_container: Container = self.containers.get(name) - except NotFound: + except docker_errors.NotFound: raise DockerNotFound( f"{name} not found for starting up", _LOGGER.error ) from None - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError( f"Could not get {name} for starting up", _LOGGER.error ) from err @@ -616,36 +632,36 @@ def start_container(self, name: str) -> None: _LOGGER.info("Starting %s", name) try: docker_container.start() - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError(f"Can't start {name}: {err}", _LOGGER.error) from err def restart_container(self, name: str, timeout: int) -> None: """Restart docker container.""" try: container: Container = self.containers.get(name) - except NotFound: + except docker_errors.NotFound: raise DockerNotFound() from None - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError() from err _LOGGER.info("Restarting %s", name) try: container.restart(timeout=timeout) - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError(f"Can't restart {name}: {err}", _LOGGER.warning) from err def container_logs(self, name: str, tail: int = 100) -> bytes: """Return Docker logs of container.""" try: docker_container: Container = self.containers.get(name) - except NotFound: + except docker_errors.NotFound: raise DockerNotFound() from None - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError() from err try: return docker_container.logs(tail=tail, stdout=True, stderr=True) - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError( f"Can't grep logs from {name}: {err}", _LOGGER.warning ) from err @@ -654,9 +670,9 @@ def container_stats(self, name: str) -> dict[str, Any]: """Read and return stats from container.""" try: docker_container: Container = self.containers.get(name) - except NotFound: + except docker_errors.NotFound: raise DockerNotFound() from None - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError() from err # container is not running @@ -665,7 +681,7 @@ def container_stats(self, name: str) -> dict[str, Any]: try: return docker_container.stats(stream=False) - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError( f"Can't read stats from {name}: {err}", _LOGGER.error ) from err @@ -674,61 +690,84 @@ def container_run_inside(self, name: str, command: str) -> CommandReturn: """Execute a command inside Docker container.""" try: docker_container: Container = self.containers.get(name) - except NotFound: + except docker_errors.NotFound: raise DockerNotFound() from None - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError() from err # Execute try: code, output = docker_container.exec_run(command) - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError() from err return CommandReturn(code, output) - def remove_image( + async def remove_image( self, image: str, version: AwesomeVersion, latest: bool = True ) -> None: """Remove a Docker image by version and latest.""" try: if latest: _LOGGER.info("Removing image %s with latest", image) - with suppress(ImageNotFound): - self.images.remove(image=f"{image}:latest", force=True) + try: + await self.images.delete(f"{image}:latest", force=True) + except aiodocker.DockerError as err: + if err.status != HTTPStatus.NOT_FOUND: + raise _LOGGER.info("Removing image %s with %s", image, version) - with suppress(ImageNotFound): - self.images.remove(image=f"{image}:{version!s}", force=True) + try: + await self.images.delete(f"{image}:{version!s}", force=True) + except aiodocker.DockerError as err: + if err.status != HTTPStatus.NOT_FOUND: + raise - except (DockerException, requests.RequestException) as err: + except (aiodocker.DockerError, requests.RequestException) as err: raise DockerError( f"Can't remove image {image}: {err}", _LOGGER.warning ) from err - def import_image(self, tar_file: Path) -> Image | None: + async def import_image(self, tar_file: Path) -> dict[str, Any] | None: """Import a tar file as image.""" try: with tar_file.open("rb") as read_tar: - docker_image_list: list[Image] = self.images.load(read_tar) # type: ignore + resp: list[dict[str, Any]] = self.images.import_image(read_tar) + except (aiodocker.DockerError, OSError) as err: + raise DockerError( + f"Can't import image from tar: {err}", _LOGGER.error + ) from err - if len(docker_image_list) != 1: - _LOGGER.warning( - "Unexpected image count %d while importing image from tar", - len(docker_image_list), + docker_image_list: list[str] = [] + for chunk in resp: + if "errorDetail" in chunk: + raise DockerError( + f"Can't import image from tar: {chunk['errorDetail']['message']}", + _LOGGER.error, ) - return None - return docker_image_list[0] - except (DockerException, OSError) as err: + if "stream" in chunk: + if match := RE_IMPORT_IMAGE_STREAM.search(chunk["stream"]): + docker_image_list.append(match.group(2)) + + if len(docker_image_list) != 1: + _LOGGER.warning( + "Unexpected image count %d while importing image from tar", + len(docker_image_list), + ) + return None + + try: + return await self.images.inspect(docker_image_list[0]) + except (aiodocker.DockerError, requests.RequestException) as err: raise DockerError( - f"Can't import image from tar: {err}", _LOGGER.error + f"Could not inspect imported image due to: {err!s}", _LOGGER.error ) from err def export_image(self, image: str, version: AwesomeVersion, tar_file: Path) -> None: """Export current images into a tar file.""" try: docker_image = self.api.get_image(f"{image}:{version}") - except (DockerException, requests.RequestException) as err: + except (docker_errors.DockerException, requests.RequestException) as err: raise DockerError( f"Can't fetch image {image}: {err}", _LOGGER.error ) from err @@ -745,7 +784,7 @@ def export_image(self, image: str, version: AwesomeVersion, tar_file: Path) -> N _LOGGER.info("Export image %s done", image) - def cleanup_old_images( + async def cleanup_old_images( self, current_image: str, current_version: AwesomeVersion, @@ -756,46 +795,57 @@ def cleanup_old_images( """Clean up old versions of an image.""" image = f"{current_image}:{current_version!s}" try: - keep = {cast(str, self.images.get(image).id)} - except ImageNotFound: - raise DockerNotFound( - f"{current_image} not found for cleanup", _LOGGER.warning - ) from None - except (DockerException, requests.RequestException) as err: + try: + image_attr = await self.images.inspect(image) + except aiodocker.DockerError as err: + if err.status == HTTPStatus.NOT_FOUND: + raise DockerNotFound( + f"{current_image} not found for cleanup", _LOGGER.warning + ) from None + raise + except (aiodocker.DockerError, requests.RequestException) as err: raise DockerError( f"Can't get {current_image} for cleanup", _LOGGER.warning ) from err + keep = {cast(str, image_attr["Id"])} if keep_images: keep_images -= {image} - try: - for image in keep_images: - # If its not found, no need to preserve it from getting removed - with suppress(ImageNotFound): - keep.add(cast(str, self.images.get(image).id)) - except (DockerException, requests.RequestException) as err: - raise DockerError( - f"Failed to get one or more images from {keep} during cleanup", - _LOGGER.warning, - ) from err + results = await asyncio.gather( + *[self.images.inspect(image) for image in keep_images], + return_exceptions=True, + ) + for result in results: + # If its not found, no need to preserve it from getting removed + if ( + isinstance(result, aiodocker.DockerError) + and result.status == HTTPStatus.NOT_FOUND + ): + continue + if isinstance(result, BaseException): + raise DockerError( + f"Failed to get one or more images from {keep} during cleanup", + _LOGGER.warning, + ) from result + keep.add(cast(str, result["Id"])) # Cleanup old and current image_names = list( old_images | {current_image} if old_images else {current_image} ) try: - # This API accepts a list of image names. Tested and confirmed working on docker==7.1.0 - # Its typing does say only `str` though. Bit concerning, could an update break this? - images_list = self.images.list(name=image_names) # type: ignore - except (DockerException, requests.RequestException) as err: + images_list = await self.images.list( + filters=json.dumps({"reference": image_names}) + ) + except (aiodocker.DockerError, requests.RequestException) as err: raise DockerError( f"Corrupt docker overlayfs found: {err}", _LOGGER.warning ) from err for docker_image in images_list: - if docker_image.id in keep: + if docker_image["Id"] in keep: continue - with suppress(DockerException, requests.RequestException): - _LOGGER.info("Cleanup images: %s", docker_image.tags) - self.images.remove(docker_image.id, force=True) + with suppress(aiodocker.DockerError, requests.RequestException): + _LOGGER.info("Cleanup images: %s", docker_image["RepoTags"]) + await self.images.delete(docker_image["Id"], force=True) diff --git a/supervisor/docker/supervisor.py b/supervisor/docker/supervisor.py index 710710d6fc9..356dcbcaca4 100644 --- a/supervisor/docker/supervisor.py +++ b/supervisor/docker/supervisor.py @@ -1,10 +1,12 @@ """Init file for Supervisor Docker object.""" +import asyncio from collections.abc import Awaitable from ipaddress import IPv4Address import logging import os +import aiodocker from awesomeversion.awesomeversion import AwesomeVersion import docker import requests @@ -112,19 +114,18 @@ def _retag(self) -> None: name="docker_supervisor_update_start_tag", concurrency=JobConcurrency.GROUP_QUEUE, ) - def update_start_tag(self, image: str, version: AwesomeVersion) -> Awaitable[None]: + async def update_start_tag(self, image: str, version: AwesomeVersion) -> None: """Update start tag to new version.""" - return self.sys_run_in_executor(self._update_start_tag, image, version) - - def _update_start_tag(self, image: str, version: AwesomeVersion) -> None: - """Update start tag to new version. - - Need run inside executor. - """ try: - docker_container = self.sys_docker.containers.get(self.name) - docker_image = self.sys_docker.images.get(f"{image}:{version!s}") - except (docker.errors.DockerException, requests.RequestException) as err: + docker_container = await self.sys_run_in_executor( + self.sys_docker.containers.get, self.name + ) + docker_image = await self.sys_docker.images.inspect(f"{image}:{version!s}") + except ( + aiodocker.DockerError, + docker.errors.DockerException, + requests.RequestException, + ) as err: raise DockerError( f"Can't get image or container to fix start tag: {err}", _LOGGER.error ) from err @@ -144,8 +145,14 @@ def _update_start_tag(self, image: str, version: AwesomeVersion) -> None: # If version tag if start_tag != "latest": continue - docker_image.tag(start_image, start_tag) - docker_image.tag(start_image, version.string) - - except (docker.errors.DockerException, requests.RequestException) as err: + await asyncio.gather( + self.sys_docker.images.tag( + docker_image["Id"], start_image, tag=start_tag + ), + self.sys_docker.images.tag( + docker_image["Id"], start_image, tag=version.string + ), + ) + + except (aiodocker.DockerError, requests.RequestException) as err: raise DockerError(f"Can't fix start tag: {err}", _LOGGER.error) from err diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 5f9625ab37e..2bb80338375 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -648,9 +648,32 @@ class DockerLogOutOfOrder(DockerError): class DockerNoSpaceOnDevice(DockerError): """Raise if a docker pull fails due to available space.""" + error_key = "docker_no_space_on_device" + message_template = "No space left on disk" + + def __init__(self, logger: Callable[..., None] | None = None) -> None: + """Raise & log.""" + super().__init__(None, logger=logger) + + +class DockerHubRateLimitExceeded(DockerError): + """Raise for docker hub rate limit exceeded error.""" + + error_key = "dockerhub_rate_limit_exceeded" + message_template = ( + "Your IP address has made too many requests to Docker Hub which activated a rate limit. " + "For more details see {dockerhub_rate_limit_url}" + ) + def __init__(self, logger: Callable[..., None] | None = None) -> None: """Raise & log.""" - super().__init__("No space left on disk", logger=logger) + super().__init__( + None, + logger=logger, + extra_fields={ + "dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit" + }, + ) class DockerJobError(DockerError, JobException): diff --git a/tests/addons/test_addon.py b/tests/addons/test_addon.py index 4c453bbbd30..2d26acabd27 100644 --- a/tests/addons/test_addon.py +++ b/tests/addons/test_addon.py @@ -3,22 +3,25 @@ import asyncio from datetime import timedelta import errno +from http import HTTPStatus from pathlib import Path -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import MagicMock, PropertyMock, call, patch +import aiodocker from awesomeversion import AwesomeVersion -from docker.errors import DockerException, ImageNotFound, NotFound +from docker.errors import APIError, DockerException, NotFound import pytest from securetar import SecureTarFile from supervisor.addons.addon import Addon from supervisor.addons.const import AddonBackupMode from supervisor.addons.model import AddonModel +from supervisor.config import CoreConfig from supervisor.const import AddonBoot, AddonState, BusEvent from supervisor.coresys import CoreSys from supervisor.docker.addon import DockerAddon from supervisor.docker.const import ContainerState -from supervisor.docker.manager import CommandReturn +from supervisor.docker.manager import CommandReturn, DockerAPI from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError from supervisor.hardware.helper import HwHelper @@ -861,16 +864,14 @@ async def test_addon_loads_wrong_image( container.remove.assert_called_with(force=True, v=True) # one for removing the addon, one for removing the addon builder - assert coresys.docker.images.remove.call_count == 2 + assert coresys.docker.images.delete.call_count == 2 - assert coresys.docker.images.remove.call_args_list[0].kwargs == { - "image": "local/aarch64-addon-ssh:latest", - "force": True, - } - assert coresys.docker.images.remove.call_args_list[1].kwargs == { - "image": "local/aarch64-addon-ssh:9.2.1", - "force": True, - } + assert coresys.docker.images.delete.call_args_list[0] == call( + "local/aarch64-addon-ssh:latest", force=True + ) + assert coresys.docker.images.delete.call_args_list[1] == call( + "local/aarch64-addon-ssh:9.2.1", force=True + ) mock_run_command.assert_called_once() assert mock_run_command.call_args.args[0] == "docker.io/library/docker" assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli" @@ -894,7 +895,9 @@ async def test_addon_loads_missing_image( mock_amd64_arch_supported, ): """Test addon corrects a missing image on load.""" - coresys.docker.images.get.side_effect = ImageNotFound("missing") + coresys.docker.images.inspect.side_effect = aiodocker.DockerError( + HTTPStatus.NOT_FOUND, {"message": "missing"} + ) with ( patch("pathlib.Path.is_file", return_value=True), @@ -926,41 +929,51 @@ async def test_addon_loads_missing_image( assert install_addon_ssh.image == "local/amd64-addon-ssh" +@pytest.mark.parametrize( + "pull_image_exc", + [APIError("error"), aiodocker.DockerError(400, {"message": "error"})], +) +@pytest.mark.usefixtures("container", "mock_amd64_arch_supported") async def test_addon_load_succeeds_with_docker_errors( coresys: CoreSys, install_addon_ssh: Addon, - container: MagicMock, caplog: pytest.LogCaptureFixture, - mock_amd64_arch_supported, + pull_image_exc: Exception, ): """Docker errors while building/pulling an image during load should not raise and fail setup.""" # Build env invalid failure - coresys.docker.images.get.side_effect = ImageNotFound("missing") + coresys.docker.images.inspect.side_effect = aiodocker.DockerError( + HTTPStatus.NOT_FOUND, {"message": "missing"} + ) caplog.clear() await install_addon_ssh.load() assert "Invalid build environment" in caplog.text # Image build failure - coresys.docker.images.build.side_effect = DockerException() caplog.clear() with ( patch("pathlib.Path.is_file", return_value=True), patch.object( - type(coresys.config), - "local_to_extern_path", - return_value="/addon/path/on/host", + CoreConfig, "local_to_extern_path", return_value="/addon/path/on/host" + ), + patch.object( + DockerAPI, + "run_command", + return_value=MagicMock(exit_code=1, output=b"error"), ), ): await install_addon_ssh.load() - assert "Can't build local/amd64-addon-ssh:9.2.1" in caplog.text + assert ( + "Can't build local/amd64-addon-ssh:9.2.1: Docker build failed for local/amd64-addon-ssh:9.2.1 (exit code 1). Build output:\nerror" + in caplog.text + ) # Image pull failure install_addon_ssh.data["image"] = "test/amd64-addon-ssh" - coresys.docker.images.build.reset_mock(side_effect=True) - coresys.docker.pull_image.side_effect = DockerException() caplog.clear() - await install_addon_ssh.load() - assert "Unknown error with test/amd64-addon-ssh:9.2.1" in caplog.text + with patch.object(DockerAPI, "pull_image", side_effect=pull_image_exc): + await install_addon_ssh.load() + assert "Can't install test/amd64-addon-ssh:9.2.1:" in caplog.text async def test_addon_manual_only_boot(coresys: CoreSys, install_addon_example: Addon): diff --git a/tests/addons/test_manager.py b/tests/addons/test_manager.py index 0c02b156f86..5f800da224c 100644 --- a/tests/addons/test_manager.py +++ b/tests/addons/test_manager.py @@ -4,7 +4,7 @@ from collections.abc import AsyncGenerator, Generator from copy import deepcopy from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, call, patch from awesomeversion import AwesomeVersion import pytest @@ -514,19 +514,13 @@ async def test_shared_image_kept_on_uninstall( latest = f"{install_addon_example.image}:latest" await coresys.addons.uninstall("local_example2") - coresys.docker.images.remove.assert_not_called() + coresys.docker.images.delete.assert_not_called() assert not coresys.addons.get("local_example2", local_only=True) await coresys.addons.uninstall("local_example") - assert coresys.docker.images.remove.call_count == 2 - assert coresys.docker.images.remove.call_args_list[0].kwargs == { - "image": latest, - "force": True, - } - assert coresys.docker.images.remove.call_args_list[1].kwargs == { - "image": image, - "force": True, - } + assert coresys.docker.images.delete.call_count == 2 + assert coresys.docker.images.delete.call_args_list[0] == call(latest, force=True) + assert coresys.docker.images.delete.call_args_list[1] == call(image, force=True) assert not coresys.addons.get("local_example", local_only=True) @@ -554,19 +548,17 @@ async def test_shared_image_kept_on_update( assert example_2.version == "1.2.0" assert install_addon_example_image.version == "1.2.0" - image_new = MagicMock() - image_new.id = "image_new" - image_old = MagicMock() - image_old.id = "image_old" - docker.images.get.side_effect = [image_new, image_old] + image_new = {"Id": "image_new", "RepoTags": ["image_new:latest"]} + image_old = {"Id": "image_old", "RepoTags": ["image_old:latest"]} + docker.images.inspect.side_effect = [image_new, image_old] docker.images.list.return_value = [image_new, image_old] with patch.object(DockerAPI, "pull_image", return_value=image_new): await coresys.addons.update("local_example2") - docker.images.remove.assert_not_called() + docker.images.delete.assert_not_called() assert example_2.version == "1.3.0" - docker.images.get.side_effect = [image_new] + docker.images.inspect.side_effect = [image_new] await coresys.addons.update("local_example_image") - docker.images.remove.assert_called_once_with("image_old", force=True) + docker.images.delete.assert_called_once_with("image_old", force=True) assert install_addon_example_image.version == "1.3.0" diff --git a/tests/api/test_homeassistant.py b/tests/api/test_homeassistant.py index d3d3adaef21..76e10aa16d3 100644 --- a/tests/api/test_homeassistant.py +++ b/tests/api/test_homeassistant.py @@ -19,7 +19,7 @@ from supervisor.homeassistant.module import HomeAssistant from tests.api import common_test_api_advanced_logs -from tests.common import load_json_fixture +from tests.common import AsyncIterator, load_json_fixture @pytest.mark.parametrize("legacy_route", [True, False]) @@ -283,9 +283,9 @@ async def test_api_progress_updates_home_assistant_update( """Test progress updates sent to Home Assistant for updates.""" coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.core.set_state(CoreState.RUNNING) - coresys.docker.docker.api.pull.return_value = load_json_fixture( - "docker_pull_image_log.json" - ) + + logs = load_json_fixture("docker_pull_image_log.json") + coresys.docker.images.pull.return_value = AsyncIterator(logs) coresys.homeassistant.version = AwesomeVersion("2025.8.0") with ( diff --git a/tests/api/test_store.py b/tests/api/test_store.py index e2841b5a537..760f33e3e8d 100644 --- a/tests/api/test_store.py +++ b/tests/api/test_store.py @@ -24,7 +24,7 @@ from supervisor.store.addon import AddonStore from supervisor.store.repository import Repository -from tests.common import load_json_fixture +from tests.common import AsyncIterator, load_json_fixture from tests.const import TEST_ADDON_SLUG REPO_URL = "https://github.com/awesome-developer/awesome-repo" @@ -732,9 +732,10 @@ async def test_api_progress_updates_addon_install_update( """Test progress updates sent to Home Assistant for installs/updates.""" coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.core.set_state(CoreState.RUNNING) - coresys.docker.docker.api.pull.return_value = load_json_fixture( - "docker_pull_image_log.json" - ) + + logs = load_json_fixture("docker_pull_image_log.json") + coresys.docker.images.pull.return_value = AsyncIterator(logs) + coresys.arch._supported_arch = ["amd64"] # pylint: disable=protected-access install_addon_example.data_store["version"] = AwesomeVersion("2.0.0") diff --git a/tests/api/test_supervisor.py b/tests/api/test_supervisor.py index 89ef87ce893..cd9d93fe5c5 100644 --- a/tests/api/test_supervisor.py +++ b/tests/api/test_supervisor.py @@ -19,7 +19,7 @@ from supervisor.updater import Updater from tests.api import common_test_api_advanced_logs -from tests.common import load_json_fixture +from tests.common import AsyncIterator, load_json_fixture from tests.dbus_service_mocks.base import DBusServiceMock from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService @@ -332,9 +332,9 @@ async def test_api_progress_updates_supervisor_update( """Test progress updates sent to Home Assistant for updates.""" coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.core.set_state(CoreState.RUNNING) - coresys.docker.docker.api.pull.return_value = load_json_fixture( - "docker_pull_image_log.json" - ) + + logs = load_json_fixture("docker_pull_image_log.json") + coresys.docker.images.pull.return_value = AsyncIterator(logs) with ( patch.object( diff --git a/tests/common.py b/tests/common.py index 944f4348cfa..08408979e77 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,13 +1,14 @@ """Common test functions.""" import asyncio +from collections.abc import Sequence from datetime import datetime from functools import partial from importlib import import_module from inspect import getclosurevars import json from pathlib import Path -from typing import Any +from typing import Any, Self from dbus_fast.aio.message_bus import MessageBus @@ -145,3 +146,22 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc, tb): """Exit the context manager.""" + + +class AsyncIterator: + """Make list/fixture into async iterator for test mocks.""" + + def __init__(self, seq: Sequence[Any]) -> None: + """Initialize with sequence.""" + self.iter = iter(seq) + + def __aiter__(self) -> Self: + """Implement aiter.""" + return self + + async def __anext__(self) -> Any: + """Return next in sequence.""" + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration() from None diff --git a/tests/conftest.py b/tests/conftest.py index 30e214d8cce..6936198b19f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch from uuid import uuid4 +from aiodocker.docker import DockerImages from aiohttp import ClientSession, web from aiohttp.test_utils import TestClient from awesomeversion import AwesomeVersion @@ -55,6 +56,7 @@ from supervisor.utils.dt import utcnow from .common import ( + AsyncIterator, MockResponse, load_binary_fixture, load_fixture, @@ -112,40 +114,46 @@ async def supervisor_name() -> None: @pytest.fixture async def docker() -> DockerAPI: """Mock DockerAPI.""" - images = [MagicMock(tags=["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"])] - image = MagicMock() - image.attrs = {"Os": "linux", "Architecture": "amd64"} + image_inspect = { + "Os": "linux", + "Architecture": "amd64", + "Id": "test123", + "RepoTags": ["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"], + } with ( patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()), - patch("supervisor.docker.manager.DockerAPI.images", return_value=MagicMock()), patch( "supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock() ), + patch("supervisor.docker.manager.DockerAPI.api", return_value=MagicMock()), + patch("supervisor.docker.manager.DockerAPI.info", return_value=MagicMock()), + patch("supervisor.docker.manager.DockerAPI.unload"), + patch("supervisor.docker.manager.aiodocker.Docker", return_value=MagicMock()), patch( - "supervisor.docker.manager.DockerAPI.api", - return_value=(api_mock := MagicMock()), - ), - patch("supervisor.docker.manager.DockerAPI.images.get", return_value=image), - patch("supervisor.docker.manager.DockerAPI.images.list", return_value=images), - patch( - "supervisor.docker.manager.DockerAPI.info", - return_value=MagicMock(), + "supervisor.docker.manager.DockerAPI.images", + new=PropertyMock( + return_value=(docker_images := MagicMock(spec=DockerImages)) + ), ), - patch("supervisor.docker.manager.DockerAPI.unload"), ): docker_obj = await DockerAPI(MagicMock()).post_init() docker_obj.config._data = {"registries": {}} with patch("supervisor.docker.monitor.DockerMonitor.load"): await docker_obj.load() + docker_images.inspect.return_value = image_inspect + docker_images.list.return_value = [image_inspect] + docker_images.import_image.return_value = [ + {"stream": "Loaded image: test:latest\n"} + ] + + docker_images.pull.return_value = AsyncIterator([{}]) + docker_obj.info.logging = "journald" docker_obj.info.storage = "overlay2" docker_obj.info.version = AwesomeVersion("1.0.0") - # Need an iterable for logs - api_mock.pull.return_value = [] - yield docker_obj @@ -838,11 +846,9 @@ async def container(docker: DockerAPI) -> MagicMock: """Mock attrs and status for container on attach.""" docker.containers.get.return_value = addon = MagicMock() docker.containers.create.return_value = addon - docker.images.build.return_value = (addon, "") addon.status = "stopped" addon.attrs = {"State": {"ExitCode": 0}} - with patch.object(DockerAPI, "pull_image", return_value=addon): - yield addon + yield addon @pytest.fixture diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index 321d1db0f49..adbf5a0c251 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -5,10 +5,10 @@ from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch +import aiodocker from awesomeversion import AwesomeVersion from docker.errors import DockerException, NotFound from docker.models.containers import Container -from docker.models.images import Image import pytest from requests import RequestException @@ -28,7 +28,7 @@ ) from supervisor.jobs import JobSchedulerOptions, SupervisorJob -from tests.common import load_json_fixture +from tests.common import AsyncIterator, load_json_fixture @pytest.fixture(autouse=True) @@ -57,35 +57,30 @@ async def test_docker_image_platform( platform: str, ): """Test platform set correctly from arch.""" - with patch.object( - coresys.docker.images, "get", return_value=Mock(id="test:1.2.3") - ) as get: - await test_docker_interface.install( - AwesomeVersion("1.2.3"), "test", arch=cpu_arch - ) - coresys.docker.docker.api.pull.assert_called_once_with( - "test", tag="1.2.3", platform=platform, stream=True, decode=True - ) - get.assert_called_once_with("test:1.2.3") + coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"} + await test_docker_interface.install(AwesomeVersion("1.2.3"), "test", arch=cpu_arch) + coresys.docker.images.pull.assert_called_once_with( + "test", tag="1.2.3", platform=platform, stream=True + ) + coresys.docker.images.inspect.assert_called_once_with("test:1.2.3") async def test_docker_image_default_platform( coresys: CoreSys, test_docker_interface: DockerInterface ): """Test platform set using supervisor arch when omitted.""" + coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"} with ( patch.object( type(coresys.supervisor), "arch", PropertyMock(return_value="i386") ), - patch.object( - coresys.docker.images, "get", return_value=Mock(id="test:1.2.3") - ) as get, ): await test_docker_interface.install(AwesomeVersion("1.2.3"), "test") - coresys.docker.docker.api.pull.assert_called_once_with( - "test", tag="1.2.3", platform="linux/386", stream=True, decode=True + coresys.docker.images.pull.assert_called_once_with( + "test", tag="1.2.3", platform="linux/386", stream=True ) - get.assert_called_once_with("test:1.2.3") + + coresys.docker.images.inspect.assert_called_once_with("test:1.2.3") @pytest.mark.parametrize( @@ -216,57 +211,40 @@ async def test_attach_existing_container( async def test_attach_container_failure(coresys: CoreSys): """Test attach fails to find container but finds image.""" - container_collection = MagicMock() - container_collection.get.side_effect = DockerException() - image_collection = MagicMock() - image_config = {"Image": "sha256:abc123"} - image_collection.get.return_value = Image({"Config": image_config}) - with ( - patch( - "supervisor.docker.manager.DockerAPI.containers", - new=PropertyMock(return_value=container_collection), - ), - patch( - "supervisor.docker.manager.DockerAPI.images", - new=PropertyMock(return_value=image_collection), - ), - patch.object(type(coresys.bus), "fire_event") as fire_event, - ): + coresys.docker.containers.get.side_effect = DockerException() + coresys.docker.images.inspect.return_value.setdefault("Config", {})["Image"] = ( + "sha256:abc123" + ) + with patch.object(type(coresys.bus), "fire_event") as fire_event: await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3")) assert not [ event for event in fire_event.call_args_list if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE ] - assert coresys.homeassistant.core.instance.meta_config == image_config + assert ( + coresys.homeassistant.core.instance.meta_config["Image"] == "sha256:abc123" + ) async def test_attach_total_failure(coresys: CoreSys): """Test attach fails to find container or image.""" - container_collection = MagicMock() - container_collection.get.side_effect = DockerException() - image_collection = MagicMock() - image_collection.get.side_effect = DockerException() - with ( - patch( - "supervisor.docker.manager.DockerAPI.containers", - new=PropertyMock(return_value=container_collection), - ), - patch( - "supervisor.docker.manager.DockerAPI.images", - new=PropertyMock(return_value=image_collection), - ), - pytest.raises(DockerError), - ): + coresys.docker.containers.get.side_effect = DockerException + coresys.docker.images.inspect.side_effect = aiodocker.DockerError( + 400, {"message": ""} + ) + with pytest.raises(DockerError): await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3")) -@pytest.mark.parametrize("err", [DockerException(), RequestException()]) +@pytest.mark.parametrize( + "err", [aiodocker.DockerError(400, {"message": ""}), RequestException()] +) async def test_image_pull_fail( coresys: CoreSys, capture_exception: Mock, err: Exception ): """Test failure to pull image.""" - coresys.docker.images.get.side_effect = err + coresys.docker.images.inspect.side_effect = err with pytest.raises(DockerError): await coresys.homeassistant.core.instance.install( AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64 @@ -298,8 +276,9 @@ async def test_install_fires_progress_events( coresys: CoreSys, test_docker_interface: DockerInterface ): """Test progress events are fired during an install for listeners.""" + # This is from a sample pull. Filtered log to just one per unique status for test - coresys.docker.docker.api.pull.return_value = [ + logs = [ { "status": "Pulling from home-assistant/odroid-n2-homeassistant", "id": "2025.7.2", @@ -321,7 +300,11 @@ async def test_install_fires_progress_events( "id": "1578b14a573c", }, {"status": "Pull complete", "progressDetail": {}, "id": "1578b14a573c"}, - {"status": "Verifying Checksum", "progressDetail": {}, "id": "6a1e931d8f88"}, + { + "status": "Verifying Checksum", + "progressDetail": {}, + "id": "6a1e931d8f88", + }, { "status": "Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d" }, @@ -329,6 +312,7 @@ async def test_install_fires_progress_events( "status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.2" }, ] + coresys.docker.images.pull.return_value = AsyncIterator(logs) events: list[PullLogEntry] = [] @@ -343,10 +327,10 @@ async def capture_log_entry(event: PullLogEntry) -> None: ), ): await test_docker_interface.install(AwesomeVersion("1.2.3"), "test") - coresys.docker.docker.api.pull.assert_called_once_with( - "test", tag="1.2.3", platform="linux/386", stream=True, decode=True + coresys.docker.images.pull.assert_called_once_with( + "test", tag="1.2.3", platform="linux/386", stream=True ) - coresys.docker.images.get.assert_called_once_with("test:1.2.3") + coresys.docker.images.inspect.assert_called_once_with("test:1.2.3") await asyncio.sleep(1) assert events == [ @@ -424,10 +408,11 @@ async def test_install_progress_rounding_does_not_cause_misses( ): """Test extremely close progress events do not create rounding issues.""" coresys.core.set_state(CoreState.RUNNING) + # Current numbers chosen to create a rounding issue with original code # Where a progress update came in with a value between the actual previous # value and what it was rounded to. It should not raise an out of order exception - coresys.docker.docker.api.pull.return_value = [ + logs = [ { "status": "Pulling from home-assistant/odroid-n2-homeassistant", "id": "2025.7.1", @@ -467,6 +452,7 @@ async def test_install_progress_rounding_does_not_cause_misses( "status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1" }, ] + coresys.docker.images.pull.return_value = AsyncIterator(logs) with ( patch.object( @@ -522,7 +508,8 @@ async def test_install_raises_on_pull_error( exc_msg: str, ): """Test exceptions raised from errors in pull log.""" - coresys.docker.docker.api.pull.return_value = [ + + logs = [ { "status": "Pulling from home-assistant/odroid-n2-homeassistant", "id": "2025.7.2", @@ -535,6 +522,7 @@ async def test_install_raises_on_pull_error( }, error_log, ] + coresys.docker.images.pull.return_value = AsyncIterator(logs) with pytest.raises(exc_type, match=exc_msg): await test_docker_interface.install(AwesomeVersion("1.2.3"), "test") @@ -548,11 +536,11 @@ async def test_install_progress_handles_download_restart( ): """Test install handles docker progress events that include a download restart.""" coresys.core.set_state(CoreState.RUNNING) + # Fixture emulates a download restart as it docker logs it # A log out of order exception should not be raised - coresys.docker.docker.api.pull.return_value = load_json_fixture( - "docker_pull_image_log_restart.json" - ) + logs = load_json_fixture("docker_pull_image_log_restart.json") + coresys.docker.images.pull.return_value = AsyncIterator(logs) with ( patch.object( diff --git a/tests/docker/test_manager.py b/tests/docker/test_manager.py index cae6d2b7ad3..dae636e3f22 100644 --- a/tests/docker/test_manager.py +++ b/tests/docker/test_manager.py @@ -1,9 +1,10 @@ """Test Docker manager.""" import asyncio +from pathlib import Path from unittest.mock import MagicMock, patch -from docker.errors import DockerException +from docker.errors import APIError, DockerException, NotFound import pytest from requests import RequestException @@ -20,7 +21,7 @@ async def test_run_command_success(docker: DockerAPI): mock_container.logs.return_value = b"command output" # Mock docker containers.run to return our mock container - docker.docker.containers.run.return_value = mock_container + docker.dockerpy.containers.run.return_value = mock_container # Execute the command result = docker.run_command( @@ -33,7 +34,7 @@ async def test_run_command_success(docker: DockerAPI): assert result.output == b"command output" # Verify docker.containers.run was called correctly - docker.docker.containers.run.assert_called_once_with( + docker.dockerpy.containers.run.assert_called_once_with( "alpine:3.18", command="echo hello", detach=True, @@ -55,7 +56,7 @@ async def test_run_command_with_defaults(docker: DockerAPI): mock_container.logs.return_value = b"error output" # Mock docker containers.run to return our mock container - docker.docker.containers.run.return_value = mock_container + docker.dockerpy.containers.run.return_value = mock_container # Execute the command with minimal parameters result = docker.run_command(image="ubuntu") @@ -66,7 +67,7 @@ async def test_run_command_with_defaults(docker: DockerAPI): assert result.output == b"error output" # Verify docker.containers.run was called with defaults - docker.docker.containers.run.assert_called_once_with( + docker.dockerpy.containers.run.assert_called_once_with( "ubuntu:latest", # default tag command=None, # default command detach=True, @@ -81,7 +82,7 @@ async def test_run_command_with_defaults(docker: DockerAPI): async def test_run_command_docker_exception(docker: DockerAPI): """Test command execution when Docker raises an exception.""" # Mock docker containers.run to raise DockerException - docker.docker.containers.run.side_effect = DockerException("Docker error") + docker.dockerpy.containers.run.side_effect = DockerException("Docker error") # Execute the command and expect DockerError with pytest.raises(DockerError, match="Can't execute command: Docker error"): @@ -91,7 +92,7 @@ async def test_run_command_docker_exception(docker: DockerAPI): async def test_run_command_request_exception(docker: DockerAPI): """Test command execution when requests raises an exception.""" # Mock docker containers.run to raise RequestException - docker.docker.containers.run.side_effect = RequestException("Connection error") + docker.dockerpy.containers.run.side_effect = RequestException("Connection error") # Execute the command and expect DockerError with pytest.raises(DockerError, match="Can't execute command: Connection error"): @@ -104,7 +105,7 @@ async def test_run_command_cleanup_on_exception(docker: DockerAPI): mock_container = MagicMock() # Mock docker.containers.run to return container, but container.wait to raise exception - docker.docker.containers.run.return_value = mock_container + docker.dockerpy.containers.run.return_value = mock_container mock_container.wait.side_effect = DockerException("Wait failed") # Execute the command and expect DockerError @@ -123,7 +124,7 @@ async def test_run_command_custom_stdout_stderr(docker: DockerAPI): mock_container.logs.return_value = b"output" # Mock docker containers.run to return our mock container - docker.docker.containers.run.return_value = mock_container + docker.dockerpy.containers.run.return_value = mock_container # Execute the command with custom stdout/stderr result = docker.run_command( @@ -150,7 +151,7 @@ async def test_run_container_with_cidfile( cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid" extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid" - docker.docker.containers.run.return_value = mock_container + docker.dockerpy.containers.run.return_value = mock_container # Mock container creation with patch.object( @@ -351,3 +352,101 @@ async def test_run_container_with_leftover_cidfile_directory( assert cidfile_path.read_text() == mock_container.id assert result == mock_container + + +async def test_repair(coresys: CoreSys, caplog: pytest.LogCaptureFixture): + """Test repair API.""" + coresys.docker.dockerpy.networks.get.side_effect = [ + hassio := MagicMock( + attrs={ + "Containers": { + "good": {"Name": "good"}, + "corrupt": {"Name": "corrupt"}, + "fail": {"Name": "fail"}, + } + } + ), + host := MagicMock(attrs={"Containers": {}}), + ] + coresys.docker.dockerpy.containers.get.side_effect = [ + MagicMock(), + NotFound("corrupt"), + DockerException("fail"), + ] + + await coresys.run_in_executor(coresys.docker.repair) + + coresys.docker.dockerpy.api.prune_containers.assert_called_once() + coresys.docker.dockerpy.api.prune_images.assert_called_once_with( + filters={"dangling": False} + ) + coresys.docker.dockerpy.api.prune_builds.assert_called_once() + coresys.docker.dockerpy.api.prune_volumes.assert_called_once() + coresys.docker.dockerpy.api.prune_networks.assert_called_once() + hassio.disconnect.assert_called_once_with("corrupt", force=True) + host.disconnect.assert_not_called() + assert "Docker fatal error on container fail on hassio" in caplog.text + + +async def test_repair_failures(coresys: CoreSys, caplog: pytest.LogCaptureFixture): + """Test repair proceeds best it can through failures.""" + coresys.docker.dockerpy.api.prune_containers.side_effect = APIError("fail") + coresys.docker.dockerpy.api.prune_images.side_effect = APIError("fail") + coresys.docker.dockerpy.api.prune_builds.side_effect = APIError("fail") + coresys.docker.dockerpy.api.prune_volumes.side_effect = APIError("fail") + coresys.docker.dockerpy.api.prune_networks.side_effect = APIError("fail") + coresys.docker.dockerpy.networks.get.side_effect = NotFound("missing") + + await coresys.run_in_executor(coresys.docker.repair) + + assert "Error for containers prune: fail" in caplog.text + assert "Error for images prune: fail" in caplog.text + assert "Error for builds prune: fail" in caplog.text + assert "Error for volumes prune: fail" in caplog.text + assert "Error for networks prune: fail" in caplog.text + assert "Error for networks hassio prune: missing" in caplog.text + assert "Error for networks host prune: missing" in caplog.text + + +@pytest.mark.parametrize("log_starter", [("Loaded image ID"), ("Loaded image")]) +async def test_import_image(coresys: CoreSys, tmp_path: Path, log_starter: str): + """Test importing an image into docker.""" + (test_tar := tmp_path / "test.tar").touch() + coresys.docker.images.import_image.return_value = [ + {"stream": f"{log_starter}: imported"} + ] + coresys.docker.images.inspect.return_value = {"Id": "imported"} + + image = await coresys.docker.import_image(test_tar) + + assert image["Id"] == "imported" + coresys.docker.images.inspect.assert_called_once_with("imported") + + +async def test_import_image_error(coresys: CoreSys, tmp_path: Path): + """Test failure importing an image into docker.""" + (test_tar := tmp_path / "test.tar").touch() + coresys.docker.images.import_image.return_value = [ + {"errorDetail": {"message": "fail"}} + ] + + with pytest.raises(DockerError, match="Can't import image from tar: fail"): + await coresys.docker.import_image(test_tar) + + coresys.docker.images.inspect.assert_not_called() + + +async def test_import_multiple_images_in_tar( + coresys: CoreSys, tmp_path: Path, caplog: pytest.LogCaptureFixture +): + """Test importing an image into docker.""" + (test_tar := tmp_path / "test.tar").touch() + coresys.docker.images.import_image.return_value = [ + {"stream": "Loaded image: imported-1"}, + {"stream": "Loaded image: imported-2"}, + ] + + assert await coresys.docker.import_image(test_tar) is None + + assert "Unexpected image count 2 while importing image from tar" in caplog.text + coresys.docker.images.inspect.assert_not_called() diff --git a/tests/homeassistant/test_core.py b/tests/homeassistant/test_core.py index 029258cc57a..069289f3dd6 100644 --- a/tests/homeassistant/test_core.py +++ b/tests/homeassistant/test_core.py @@ -1,11 +1,14 @@ """Test Home Assistant core.""" from datetime import datetime, timedelta -from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch +from http import HTTPStatus +from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch +import aiodocker from awesomeversion import AwesomeVersion -from docker.errors import APIError, DockerException, ImageNotFound, NotFound +from docker.errors import APIError, DockerException, NotFound import pytest +from requests import RequestException from time_machine import travel from supervisor.const import CpuArch @@ -23,8 +26,12 @@ from supervisor.homeassistant.api import APIState from supervisor.homeassistant.core import HomeAssistantCore from supervisor.homeassistant.module import HomeAssistant +from supervisor.resolution.const import ContextType, IssueType +from supervisor.resolution.data import Issue from supervisor.updater import Updater +from tests.common import AsyncIterator + async def test_update_fails_if_out_of_date(coresys: CoreSys): """Test update of Home Assistant fails when supervisor or plugin is out of date.""" @@ -52,11 +59,23 @@ async def test_update_fails_if_out_of_date(coresys: CoreSys): await coresys.homeassistant.core.update() -async def test_install_landingpage_docker_error( - coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + "err", + [ + aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}), + APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)), + ], +) +async def test_install_landingpage_docker_ratelimit_error( + coresys: CoreSys, + capture_exception: Mock, + caplog: pytest.LogCaptureFixture, + err: Exception, ): - """Test install landing page fails due to docker error.""" + """Test install landing page fails due to docker ratelimit error.""" coresys.security.force = True + coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])] + with ( patch.object(DockerHomeAssistant, "attach", side_effect=DockerError), patch.object( @@ -69,19 +88,35 @@ async def test_install_landingpage_docker_error( ), patch("supervisor.homeassistant.core.asyncio.sleep") as sleep, ): - coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()] await coresys.homeassistant.core.install_landingpage() sleep.assert_awaited_once_with(30) assert "Failed to install landingpage, retrying after 30sec" in caplog.text capture_exception.assert_not_called() + assert ( + Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM) + in coresys.resolution.issues + ) +@pytest.mark.parametrize( + "err", + [ + aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}), + APIError("fail"), + DockerException(), + RequestException(), + OSError(), + ], +) async def test_install_landingpage_other_error( - coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture + coresys: CoreSys, + capture_exception: Mock, + caplog: pytest.LogCaptureFixture, + err: Exception, ): """Test install landing page fails due to other error.""" - coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()] + coresys.docker.images.inspect.side_effect = [err, MagicMock()] with ( patch.object(DockerHomeAssistant, "attach", side_effect=DockerError), @@ -102,11 +137,23 @@ async def test_install_landingpage_other_error( capture_exception.assert_called_once_with(err) -async def test_install_docker_error( - coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + "err", + [ + aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}), + APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)), + ], +) +async def test_install_docker_ratelimit_error( + coresys: CoreSys, + capture_exception: Mock, + caplog: pytest.LogCaptureFixture, + err: Exception, ): - """Test install fails due to docker error.""" + """Test install fails due to docker ratelimit error.""" coresys.security.force = True + coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])] + with ( patch.object(HomeAssistantCore, "start"), patch.object(DockerHomeAssistant, "cleanup"), @@ -123,19 +170,35 @@ async def test_install_docker_error( ), patch("supervisor.homeassistant.core.asyncio.sleep") as sleep, ): - coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()] await coresys.homeassistant.core.install() sleep.assert_awaited_once_with(30) assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text capture_exception.assert_not_called() + assert ( + Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM) + in coresys.resolution.issues + ) +@pytest.mark.parametrize( + "err", + [ + aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}), + APIError("fail"), + DockerException(), + RequestException(), + OSError(), + ], +) async def test_install_other_error( - coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture + coresys: CoreSys, + capture_exception: Mock, + caplog: pytest.LogCaptureFixture, + err: Exception, ): """Test install fails due to other error.""" - coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()] + coresys.docker.images.inspect.side_effect = [err, MagicMock()] with ( patch.object(HomeAssistantCore, "start"), @@ -161,21 +224,29 @@ async def test_install_other_error( @pytest.mark.parametrize( - "container_exists,image_exists", [(False, True), (True, False), (True, True)] + ("container_exc", "image_exc", "remove_calls"), + [ + (NotFound("missing"), None, []), + ( + None, + aiodocker.DockerError(404, {"message": "missing"}), + [call(force=True, v=True)], + ), + (None, None, [call(force=True, v=True)]), + ], ) +@pytest.mark.usefixtures("path_extern") async def test_start( - coresys: CoreSys, container_exists: bool, image_exists: bool, path_extern + coresys: CoreSys, + container_exc: DockerException | None, + image_exc: aiodocker.DockerError | None, + remove_calls: list[call], ): """Test starting Home Assistant.""" - if image_exists: - coresys.docker.images.get.return_value.id = "123" - else: - coresys.docker.images.get.side_effect = ImageNotFound("missing") - - if container_exists: - coresys.docker.containers.get.return_value.image.id = "123" - else: - coresys.docker.containers.get.side_effect = NotFound("missing") + coresys.docker.images.inspect.return_value = {"Id": "123"} + coresys.docker.images.inspect.side_effect = image_exc + coresys.docker.containers.get.return_value.id = "123" + coresys.docker.containers.get.side_effect = container_exc with ( patch.object( @@ -198,18 +269,14 @@ async def test_start( assert run.call_args.kwargs["hostname"] == "homeassistant" coresys.docker.containers.get.return_value.stop.assert_not_called() - if container_exists: - coresys.docker.containers.get.return_value.remove.assert_called_once_with( - force=True, - v=True, - ) - else: - coresys.docker.containers.get.return_value.remove.assert_not_called() + assert ( + coresys.docker.containers.get.return_value.remove.call_args_list == remove_calls + ) async def test_start_existing_container(coresys: CoreSys, path_extern): """Test starting Home Assistant when container exists and is viable.""" - coresys.docker.images.get.return_value.id = "123" + coresys.docker.images.inspect.return_value = {"Id": "123"} coresys.docker.containers.get.return_value.image.id = "123" coresys.docker.containers.get.return_value.status = "exited" @@ -394,24 +461,32 @@ async def test_core_loads_wrong_image_for_machine( """Test core is loaded with wrong image for machine.""" coresys.homeassistant.set_image("ghcr.io/home-assistant/odroid-n2-homeassistant") coresys.homeassistant.version = AwesomeVersion("2024.4.0") - container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} - await coresys.homeassistant.core.load() + with patch.object( + DockerAPI, + "pull_image", + return_value={ + "Id": "abc123", + "Config": {"Labels": {"io.hass.version": "2024.4.0"}}, + }, + ) as pull_image: + container.attrs |= pull_image.return_value + await coresys.homeassistant.core.load() + pull_image.assert_called_once_with( + ANY, + "ghcr.io/home-assistant/qemux86-64-homeassistant", + "2024.4.0", + platform="linux/amd64", + ) container.remove.assert_called_once_with(force=True, v=True) - assert coresys.docker.images.remove.call_args_list[0].kwargs == { - "image": "ghcr.io/home-assistant/odroid-n2-homeassistant:latest", - "force": True, - } - assert coresys.docker.images.remove.call_args_list[1].kwargs == { - "image": "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0", - "force": True, - } - coresys.docker.pull_image.assert_called_once_with( - ANY, - "ghcr.io/home-assistant/qemux86-64-homeassistant", - "2024.4.0", - platform="linux/amd64", + assert coresys.docker.images.delete.call_args_list[0] == call( + "ghcr.io/home-assistant/odroid-n2-homeassistant:latest", + force=True, + ) + assert coresys.docker.images.delete.call_args_list[1] == call( + "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0", + force=True, ) assert ( coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" @@ -428,8 +503,8 @@ async def test_core_load_allows_image_override(coresys: CoreSys, container: Magi await coresys.homeassistant.core.load() container.remove.assert_not_called() - coresys.docker.images.remove.assert_not_called() - coresys.docker.images.get.assert_not_called() + coresys.docker.images.delete.assert_not_called() + coresys.docker.images.inspect.assert_not_called() assert ( coresys.homeassistant.image == "ghcr.io/home-assistant/odroid-n2-homeassistant" ) @@ -440,27 +515,36 @@ async def test_core_loads_wrong_image_for_architecture( ): """Test core is loaded with wrong image for architecture.""" coresys.homeassistant.version = AwesomeVersion("2024.4.0") - container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} - coresys.docker.images.get("ghcr.io/home-assistant/qemux86-64-homeassistant").attrs[ - "Architecture" - ] = "arm64" - - await coresys.homeassistant.core.load() + coresys.docker.images.inspect.return_value = img_data = ( + coresys.docker.images.inspect.return_value + | { + "Architecture": "arm64", + "Config": {"Labels": {"io.hass.version": "2024.4.0"}}, + } + ) + container.attrs |= img_data + + with patch.object( + DockerAPI, + "pull_image", + return_value=img_data | {"Architecture": "amd64"}, + ) as pull_image: + await coresys.homeassistant.core.load() + pull_image.assert_called_once_with( + ANY, + "ghcr.io/home-assistant/qemux86-64-homeassistant", + "2024.4.0", + platform="linux/amd64", + ) container.remove.assert_called_once_with(force=True, v=True) - assert coresys.docker.images.remove.call_args_list[0].kwargs == { - "image": "ghcr.io/home-assistant/qemux86-64-homeassistant:latest", - "force": True, - } - assert coresys.docker.images.remove.call_args_list[1].kwargs == { - "image": "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0", - "force": True, - } - coresys.docker.pull_image.assert_called_once_with( - ANY, - "ghcr.io/home-assistant/qemux86-64-homeassistant", - "2024.4.0", - platform="linux/amd64", + assert coresys.docker.images.delete.call_args_list[0] == call( + "ghcr.io/home-assistant/qemux86-64-homeassistant:latest", + force=True, + ) + assert coresys.docker.images.delete.call_args_list[1] == call( + "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0", + force=True, ) assert ( coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" diff --git a/tests/plugins/test_plugin_base.py b/tests/plugins/test_plugin_base.py index c47144a5b57..6910da74d46 100644 --- a/tests/plugins/test_plugin_base.py +++ b/tests/plugins/test_plugin_base.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path -from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch +from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch from awesomeversion import AwesomeVersion import pytest @@ -11,6 +11,7 @@ from supervisor.coresys import CoreSys from supervisor.docker.const import ContainerState from supervisor.docker.interface import DockerInterface +from supervisor.docker.manager import DockerAPI from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.exceptions import ( AudioError, @@ -362,21 +363,26 @@ async def test_load_with_incorrect_image( plugin.version = AwesomeVersion("2024.4.0") container.status = "running" - container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} + coresys.docker.images.inspect.return_value = img_data = ( + coresys.docker.images.inspect.return_value + | {"Config": {"Labels": {"io.hass.version": "2024.4.0"}}} + ) + container.attrs |= img_data - await plugin.load() + with patch.object(DockerAPI, "pull_image", return_value=img_data) as pull_image: + await plugin.load() + pull_image.assert_called_once_with( + ANY, correct_image, "2024.4.0", platform="linux/amd64" + ) container.remove.assert_called_once_with(force=True, v=True) - assert coresys.docker.images.remove.call_args_list[0].kwargs == { - "image": f"{old_image}:latest", - "force": True, - } - assert coresys.docker.images.remove.call_args_list[1].kwargs == { - "image": f"{old_image}:2024.4.0", - "force": True, - } - coresys.docker.pull_image.assert_called_once_with( - ANY, correct_image, "2024.4.0", platform="linux/amd64" + assert coresys.docker.images.delete.call_args_list[0] == call( + f"{old_image}:latest", + force=True, + ) + assert coresys.docker.images.delete.call_args_list[1] == call( + f"{old_image}:2024.4.0", + force=True, ) assert plugin.image == correct_image diff --git a/tests/resolution/fixup/test_addon_execute_repair.py b/tests/resolution/fixup/test_addon_execute_repair.py index 26fe5413054..7946d2234bd 100644 --- a/tests/resolution/fixup/test_addon_execute_repair.py +++ b/tests/resolution/fixup/test_addon_execute_repair.py @@ -1,8 +1,9 @@ """Test fixup addon execute repair.""" -from unittest.mock import MagicMock, patch +from http import HTTPStatus +from unittest.mock import patch -from docker.errors import NotFound +import aiodocker import pytest from supervisor.addons.addon import Addon @@ -17,7 +18,9 @@ async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon): """Test fixup rebuilds addon's container.""" - docker.images.get.side_effect = NotFound("missing") + docker.images.inspect.side_effect = aiodocker.DockerError( + HTTPStatus.NOT_FOUND, {"message": "missing"} + ) install_addon_ssh.data["image"] = "test_image" addon_execute_repair = FixupAddonExecuteRepair(coresys) @@ -41,7 +44,9 @@ async def test_fixup_max_auto_attempts( docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon ): """Test fixup stops being auto-applied after 5 failures.""" - docker.images.get.side_effect = NotFound("missing") + docker.images.inspect.side_effect = aiodocker.DockerError( + HTTPStatus.NOT_FOUND, {"message": "missing"} + ) install_addon_ssh.data["image"] = "test_image" addon_execute_repair = FixupAddonExecuteRepair(coresys) @@ -82,8 +87,6 @@ async def test_fixup_image_exists( docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon ): """Test fixup dismisses if image exists.""" - docker.images.get.return_value = MagicMock() - addon_execute_repair = FixupAddonExecuteRepair(coresys) assert addon_execute_repair.auto is True