Compare commits

..

4 Commits

Author SHA1 Message Date
Mike Degatano
30cc172199 Migrate images from dockerpy to aiodocker (#6252)
* Migrate images from dockerpy to aiodocker

* Add missing coverage and fix bug in repair

* Bind libraries to different files and refactor images.pull

* Use the same socket again

Try using the same socket again.

* Fix pytest

---------

Co-authored-by: Stefan Agner <stefan@agner.ch>
2025-11-12 20:54:06 +01:00
Stefan Agner
69ae8db13c Add context to Sentry events during setup phase (#6308)
* Add context to Sentry events during setup phase

Since not all properties are safe to access the current code avoids
adding any context during initialization and setup phase. However,
quite some reports are during the setup phase. This change adds some
context to events during setup phase as well, to make debugging easier.

* Drop default arch (not available during setup)
2025-11-12 14:49:04 -05:00
Stefan Agner
d85aedc42b Avoid using deprecated 'id' field in Docker events (#6307) 2025-11-12 20:44:01 +01:00
dependabot[bot]
d541fe5c3a Bump sentry-sdk from 2.43.0 to 2.44.0 (#6306) 2025-11-11 22:28:34 -08:00
25 changed files with 764 additions and 616 deletions

View File

@@ -1,4 +1,5 @@
aiodns==3.5.0 aiodns==3.5.0
aiodocker==0.24.0
aiohttp==3.13.2 aiohttp==3.13.2
atomicwrites-homeassistant==1.4.1 atomicwrites-homeassistant==1.4.1
attrs==25.4.0 attrs==25.4.0
@@ -24,7 +25,7 @@ pyudev==0.24.4
PyYAML==6.0.3 PyYAML==6.0.3
requests==2.32.5 requests==2.32.5
securetar==2025.2.1 securetar==2025.2.1
sentry-sdk==2.43.0 sentry-sdk==2.44.0
setuptools==80.9.0 setuptools==80.9.0
voluptuous==0.15.2 voluptuous==0.15.2
dbus-fast==2.45.1 dbus-fast==2.45.1

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from asyncio import Task
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
import logging import logging
from typing import Any from typing import Any
@@ -38,11 +39,13 @@ class Bus(CoreSysAttributes):
self._listeners.setdefault(event, []).append(listener) self._listeners.setdefault(event, []).append(listener)
return 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.""" """Fire an event to the bus."""
_LOGGER.debug("Fire event '%s' with '%s'", event, reference) _LOGGER.debug("Fire event '%s' with '%s'", event, reference)
tasks: list[Task] = []
for listener in self._listeners.get(event, []): 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: def remove_listener(self, listener: EventListener) -> None:
"""Unregister an listener.""" """Unregister an listener."""

View File

@@ -9,6 +9,7 @@ import os
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
import aiodocker
from attr import evolve from attr import evolve
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
import docker import docker
@@ -717,19 +718,21 @@ class DockerAddon(DockerInterface):
error_message = f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}" 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) raise docker.errors.DockerException(error_message)
addon_image = self.sys_docker.images.get(addon_image_tag) return addon_image_tag, logs
return addon_image, logs
try: 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) _LOGGER.debug("Build %s:%s done: %s", self.image, version, log)
# Update meta data # 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) _LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
raise DockerError() from err raise DockerError() from err
@@ -751,11 +754,8 @@ class DockerAddon(DockerInterface):
) )
async def import_image(self, tar_file: Path) -> None: async def import_image(self, tar_file: Path) -> None:
"""Import a tar file as image.""" """Import a tar file as image."""
docker_image = await self.sys_run_in_executor( if docker_image := await self.sys_docker.import_image(tar_file):
self.sys_docker.import_image, tar_file self._meta = docker_image
)
if docker_image:
self._meta = docker_image.attrs
_LOGGER.info("Importing image %s and version %s", tar_file, self.version) _LOGGER.info("Importing image %s and version %s", tar_file, self.version)
with suppress(DockerError): with suppress(DockerError):
@@ -769,17 +769,21 @@ class DockerAddon(DockerInterface):
version: AwesomeVersion | None = None, version: AwesomeVersion | None = None,
) -> None: ) -> None:
"""Check if old version exists and cleanup other versions of image not in use.""" """Check if old version exists and cleanup other versions of image not in use."""
await self.sys_run_in_executor( if not (use_image := image or self.image):
self.sys_docker.cleanup_old_images, raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
(image := image or self.image), if not (use_version := version or self.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, {old_image} if old_image else None,
keep_images={ keep_images={
f"{addon.image}:{addon.version}" f"{addon.image}:{addon.version}"
for addon in self.sys_addons.installed for addon in self.sys_addons.installed
if addon.slug != self.addon.slug if addon.slug != self.addon.slug
and addon.image and addon.image
and addon.image in {old_image, image} and addon.image in {old_image, use_image}
}, },
) )

View File

@@ -1,6 +1,5 @@
"""Init file for Supervisor Docker object.""" """Init file for Supervisor Docker object."""
from collections.abc import Awaitable
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
import re import re
@@ -236,11 +235,10 @@ class DockerHomeAssistant(DockerInterface):
environment={ENV_TIME: self.sys_timezone}, 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 True if Docker container exists."""
return self.sys_run_in_executor( if not self.sys_homeassistant.version:
self.sys_docker.container_is_initialized, return False
self.name, return await self.sys_docker.container_is_initialized(
self.image, self.name, self.image, self.sys_homeassistant.version
self.sys_homeassistant.version,
) )

View File

@@ -6,17 +6,18 @@ from abc import ABC, abstractmethod
from collections import defaultdict from collections import defaultdict
from collections.abc import Awaitable from collections.abc import Awaitable
from contextlib import suppress from contextlib import suppress
from http import HTTPStatus
import logging import logging
import re import re
from time import time from time import time
from typing import Any, cast from typing import Any, cast
from uuid import uuid4 from uuid import uuid4
import aiodocker
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from awesomeversion.strategy import AwesomeVersionStrategy from awesomeversion.strategy import AwesomeVersionStrategy
import docker import docker
from docker.models.containers import Container from docker.models.containers import Container
from docker.models.images import Image
import requests import requests
from ..bus import EventListener from ..bus import EventListener
@@ -33,6 +34,7 @@ from ..coresys import CoreSys
from ..exceptions import ( from ..exceptions import (
DockerAPIError, DockerAPIError,
DockerError, DockerError,
DockerHubRateLimitExceeded,
DockerJobError, DockerJobError,
DockerLogOutOfOrder, DockerLogOutOfOrder,
DockerNotFound, DockerNotFound,
@@ -215,7 +217,7 @@ class DockerInterface(JobGroup, ABC):
if not credentials: if not credentials:
return 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( # noqa: C901 def _process_pull_image_log( # noqa: C901
self, install_job_id: str, reference: PullLogEntry self, install_job_id: str, reference: PullLogEntry
@@ -309,30 +311,18 @@ class DockerInterface(JobGroup, ABC):
stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING} stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING}
and reference.progress_detail and reference.progress_detail
): ):
# For containerd snapshotter, extracting phase has total=None
# In that case, use the download_total from the downloading phase
current_extra: dict[str, Any] = job.extra if job.extra else {}
if (
stage == PullImageLayerStage.DOWNLOADING
and reference.progress_detail.total
):
# Store download total for use in extraction phase with containerd snapshotter
current_extra["download_total"] = reference.progress_detail.total
job.update( job.update(
progress=progress, progress=progress,
stage=stage.status, stage=stage.status,
extra={ extra={
"current": reference.progress_detail.current, "current": reference.progress_detail.current,
"total": reference.progress_detail.total "total": reference.progress_detail.total,
or current_extra.get("download_total"),
"download_total": current_extra.get("download_total"),
}, },
) )
else: else:
# If we reach DOWNLOAD_COMPLETE without ever having set extra (small layers that skip # If we reach DOWNLOAD_COMPLETE without ever having set extra (small layers that skip
# the downloading phase), set a minimal extra so aggregate progress calculation can proceed # the downloading phase), set a minimal extra so aggregate progress calculation can proceed
extra: dict[str, Any] | None = job.extra extra = job.extra
if stage == PullImageLayerStage.DOWNLOAD_COMPLETE and not job.extra: if stage == PullImageLayerStage.DOWNLOAD_COMPLETE and not job.extra:
extra = {"current": 1, "total": 1} extra = {"current": 1, "total": 1}
@@ -358,11 +348,7 @@ class DockerInterface(JobGroup, ABC):
for job in layer_jobs: for job in layer_jobs:
if not job.extra: if not job.extra:
return return
# Use download_total if available (for containerd snapshotter), otherwise use total total += job.extra["total"]
layer_total = job.extra.get("download_total") or job.extra.get("total")
if layer_total is None:
return
total += layer_total
install_job.extra = {"total": total} install_job.extra = {"total": total}
else: else:
total = install_job.extra["total"] total = install_job.extra["total"]
@@ -373,11 +359,7 @@ class DockerInterface(JobGroup, ABC):
for job in layer_jobs: for job in layer_jobs:
if not job.extra: if not job.extra:
return return
# Use download_total if available (for containerd snapshotter), otherwise use total progress += job.progress * (job.extra["total"] / total)
layer_total = job.extra.get("download_total") or job.extra.get("total")
if layer_total is None:
return
progress += job.progress * (layer_total / total)
job_stage = PullImageLayerStage.from_status(cast(str, job.stage)) job_stage = PullImageLayerStage.from_status(cast(str, job.stage))
if job_stage < PullImageLayerStage.EXTRACTING: if job_stage < PullImageLayerStage.EXTRACTING:
@@ -438,8 +420,7 @@ class DockerInterface(JobGroup, ABC):
) )
# Pull new image # Pull new image
docker_image = await self.sys_run_in_executor( docker_image = await self.sys_docker.pull_image(
self.sys_docker.pull_image,
self.sys_jobs.current.uuid, self.sys_jobs.current.uuid,
image, image,
str(version), str(version),
@@ -451,22 +432,37 @@ class DockerInterface(JobGroup, ABC):
_LOGGER.info( _LOGGER.info(
"Tagging image %s with version %s as latest", image, version "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: except docker.errors.APIError as err:
if err.status_code == 429: if err.status_code == HTTPStatus.TOO_MANY_REQUESTS:
self.sys_resolution.create_issue( self.sys_resolution.create_issue(
IssueType.DOCKER_RATELIMIT, IssueType.DOCKER_RATELIMIT,
ContextType.SYSTEM, ContextType.SYSTEM,
suggestions=[SuggestionType.REGISTRY_LOGIN], suggestions=[SuggestionType.REGISTRY_LOGIN],
) )
_LOGGER.info( raise DockerHubRateLimitExceeded(_LOGGER.error) from err
"Your IP address has made too many requests to Docker Hub which activated a rate limit. " await async_capture_exception(err)
"For more details see https://www.home-assistant.io/more-info/dockerhub-rate-limit"
)
raise DockerError( raise DockerError(
f"Can't install {image}:{version!s}: {err}", _LOGGER.error f"Can't install {image}:{version!s}: {err}", _LOGGER.error
) from err ) from err
except (docker.errors.DockerException, requests.RequestException) as 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:
await async_capture_exception(err) await async_capture_exception(err)
raise DockerError( raise DockerError(
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
@@ -475,14 +471,12 @@ class DockerInterface(JobGroup, ABC):
if listener: if listener:
self.sys_bus.remove_listener(listener) self.sys_bus.remove_listener(listener)
self._meta = docker_image.attrs self._meta = docker_image
async def exists(self) -> bool: async def exists(self) -> bool:
"""Return True if Docker image exists in local repository.""" """Return True if Docker image exists in local repository."""
with suppress(docker.errors.DockerException, requests.RequestException): with suppress(aiodocker.DockerError, requests.RequestException):
await self.sys_run_in_executor( await self.sys_docker.images.inspect(f"{self.image}:{self.version!s}")
self.sys_docker.images.get, f"{self.image}:{self.version!s}"
)
return True return True
return False return False
@@ -541,11 +535,11 @@ class DockerInterface(JobGroup, ABC):
), ),
) )
with suppress(docker.errors.DockerException, requests.RequestException): with suppress(aiodocker.DockerError, requests.RequestException):
if not self._meta and self.image: 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}" f"{self.image}:{version!s}"
).attrs )
# Successful? # Successful?
if not self._meta: if not self._meta:
@@ -613,14 +607,17 @@ class DockerInterface(JobGroup, ABC):
) )
async def remove(self, *, remove_image: bool = True) -> None: async def remove(self, *, remove_image: bool = True) -> None:
"""Remove Docker images.""" """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 # Cleanup container
with suppress(DockerError): with suppress(DockerError):
await self.stop() await self.stop()
if remove_image: if remove_image:
await self.sys_run_in_executor( await self.sys_docker.remove_image(self.image, self.version)
self.sys_docker.remove_image, self.image, self.version
)
self._meta = None self._meta = None
@@ -642,18 +639,16 @@ class DockerInterface(JobGroup, ABC):
image_name = f"{expected_image}:{version!s}" image_name = f"{expected_image}:{version!s}"
if self.image == expected_image: if self.image == expected_image:
try: try:
image: Image = await self.sys_run_in_executor( image = await self.sys_docker.images.inspect(image_name)
self.sys_docker.images.get, image_name except (aiodocker.DockerError, requests.RequestException) as err:
)
except (docker.errors.DockerException, requests.RequestException) as err:
raise DockerError( raise DockerError(
f"Could not get {image_name} for check due to: {err!s}", f"Could not get {image_name} for check due to: {err!s}",
_LOGGER.error, _LOGGER.error,
) from err ) from err
image_arch = f"{image.attrs['Os']}/{image.attrs['Architecture']}" image_arch = f"{image['Os']}/{image['Architecture']}"
if "Variant" in image.attrs: if "Variant" in image:
image_arch = f"{image_arch}/{image.attrs['Variant']}" image_arch = f"{image_arch}/{image['Variant']}"
# If we have an image and its the right arch, all set # If we have an image and its the right arch, all set
# It seems that newer Docker version return a variant for arm64 images. # It seems that newer Docker version return a variant for arm64 images.
@@ -715,11 +710,13 @@ class DockerInterface(JobGroup, ABC):
version: AwesomeVersion | None = None, version: AwesomeVersion | None = None,
) -> None: ) -> None:
"""Check if old version exists and cleanup.""" """Check if old version exists and cleanup."""
await self.sys_run_in_executor( if not (use_image := image or self.image):
self.sys_docker.cleanup_old_images, raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
image or self.image, if not (use_version := version or self.version):
version or self.version, raise DockerError("Cannot determine version from metadata!", _LOGGER.error)
{old_image} if old_image else None,
await self.sys_docker.cleanup_old_images(
use_image, use_version, {old_image} if old_image else None
) )
@Job( @Job(
@@ -771,10 +768,10 @@ class DockerInterface(JobGroup, ABC):
"""Return latest version of local image.""" """Return latest version of local image."""
available_version: list[AwesomeVersion] = [] available_version: list[AwesomeVersion] = []
try: try:
for image in await self.sys_run_in_executor( for image in await self.sys_docker.images.list(
self.sys_docker.images.list, self.image filters=f'{{"reference": ["{self.image}"]}}'
): ):
for tag in image.tags: for tag in image["RepoTags"]:
version = AwesomeVersion(tag.partition(":")[2]) version = AwesomeVersion(tag.partition(":")[2])
if version.strategy == AwesomeVersionStrategy.UNKNOWN: if version.strategy == AwesomeVersionStrategy.UNKNOWN:
continue continue
@@ -783,7 +780,7 @@ class DockerInterface(JobGroup, ABC):
if not available_version: if not available_version:
raise ValueError() raise ValueError()
except (docker.errors.DockerException, ValueError) as err: except (aiodocker.DockerError, ValueError) as err:
raise DockerNotFound( raise DockerNotFound(
f"No version found for {self.image}", _LOGGER.info f"No version found for {self.image}", _LOGGER.info
) from err ) from err

View File

@@ -6,20 +6,24 @@ import asyncio
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from functools import partial from functools import partial
from http import HTTPStatus
from ipaddress import IPv4Address from ipaddress import IPv4Address
import json
import logging import logging
import os import os
from pathlib import Path from pathlib import Path
import re
from typing import Any, Final, Self, cast from typing import Any, Final, Self, cast
import aiodocker
from aiodocker.images import DockerImages
from aiohttp import ClientSession, ClientTimeout, UnixConnector
import attr import attr
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
from docker import errors as docker_errors from docker import errors as docker_errors
from docker.api.client import APIClient from docker.api.client import APIClient
from docker.client import DockerClient from docker.client import DockerClient
from docker.errors import DockerException, ImageNotFound, NotFound
from docker.models.containers import Container, ContainerCollection from docker.models.containers import Container, ContainerCollection
from docker.models.images import Image, ImageCollection
from docker.models.networks import Network from docker.models.networks import Network
from docker.types.daemon import CancellableStream from docker.types.daemon import CancellableStream
import requests import requests
@@ -53,6 +57,7 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
MIN_SUPPORTED_DOCKER: Final = AwesomeVersion("24.0.0") MIN_SUPPORTED_DOCKER: Final = AwesomeVersion("24.0.0")
DOCKER_NETWORK_HOST: Final = "host" DOCKER_NETWORK_HOST: Final = "host"
RE_IMPORT_IMAGE_STREAM = re.compile(r"(^Loaded image ID: |^Loaded image: )(.+)$")
@attr.s(frozen=True) @attr.s(frozen=True)
@@ -204,7 +209,15 @@ class DockerAPI(CoreSysAttributes):
def __init__(self, coresys: CoreSys): def __init__(self, coresys: CoreSys):
"""Initialize Docker base wrapper.""" """Initialize Docker base wrapper."""
self.coresys = coresys 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._network: DockerNetwork | None = None
self._info: DockerInfo | None = None self._info: DockerInfo | None = None
self.config: DockerConfig = DockerConfig() self.config: DockerConfig = DockerConfig()
@@ -212,28 +225,28 @@ class DockerAPI(CoreSysAttributes):
async def post_init(self) -> Self: async def post_init(self) -> Self:
"""Post init actions that must be done in event loop.""" """Post init actions that must be done in event loop."""
self._docker = await asyncio.get_running_loop().run_in_executor( self._dockerpy = await asyncio.get_running_loop().run_in_executor(
None, None,
partial( partial(
DockerClient, DockerClient,
base_url=f"unix:/{str(SOCKET_DOCKER)}", base_url=f"unix:/{SOCKET_DOCKER.as_posix()}",
version="auto", version="auto",
timeout=900, timeout=900,
), ),
) )
self._info = DockerInfo.new(self.docker.info()) self._info = DockerInfo.new(self.dockerpy.info())
await self.config.read_data() 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 self.config.enable_ipv6, self.config.mtu
) )
return self return self
@property @property
def docker(self) -> DockerClient: def dockerpy(self) -> DockerClient:
"""Get docker API client.""" """Get docker API client."""
if not self._docker: if not self._dockerpy:
raise RuntimeError("Docker API Client not initialized!") raise RuntimeError("Docker API Client not initialized!")
return self._docker return self._dockerpy
@property @property
def network(self) -> DockerNetwork: def network(self) -> DockerNetwork:
@@ -243,19 +256,19 @@ class DockerAPI(CoreSysAttributes):
return self._network return self._network
@property @property
def images(self) -> ImageCollection: def images(self) -> DockerImages:
"""Return API images.""" """Return API images."""
return self.docker.images return self.docker.images
@property @property
def containers(self) -> ContainerCollection: def containers(self) -> ContainerCollection:
"""Return API containers.""" """Return API containers."""
return self.docker.containers return self.dockerpy.containers
@property @property
def api(self) -> APIClient: def api(self) -> APIClient:
"""Return API containers.""" """Return API containers."""
return self.docker.api return self.dockerpy.api
@property @property
def info(self) -> DockerInfo: def info(self) -> DockerInfo:
@@ -267,7 +280,7 @@ class DockerAPI(CoreSysAttributes):
@property @property
def events(self) -> CancellableStream: def events(self) -> CancellableStream:
"""Return docker event stream.""" """Return docker event stream."""
return self.docker.events(decode=True) return self.dockerpy.events(decode=True)
@property @property
def monitor(self) -> DockerMonitor: def monitor(self) -> DockerMonitor:
@@ -383,7 +396,7 @@ class DockerAPI(CoreSysAttributes):
with suppress(DockerError): with suppress(DockerError):
self.network.detach_default_bridge(container) self.network.detach_default_bridge(container)
else: 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 # Check if container is register on host
# https://github.com/moby/moby/issues/23302 # https://github.com/moby/moby/issues/23302
@@ -410,35 +423,32 @@ class DockerAPI(CoreSysAttributes):
return container return container
def pull_image( async def pull_image(
self, self,
job_id: str, job_id: str,
repository: str, repository: str,
tag: str = "latest", tag: str = "latest",
platform: str | None = None, platform: str | None = None,
) -> Image: ) -> dict[str, Any]:
"""Pull the specified image and return it. """Pull the specified image and return it.
This mimics the high level API of images.pull but provides better error handling by raising 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 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 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. on the bus so listeners can use that to update status for users.
Must be run in executor.
""" """
pull_log = self.docker.api.pull( async for e in self.images.pull(
repository, tag=tag, platform=platform, stream=True, decode=True repository, tag=tag, platform=platform, stream=True
) ):
for e in pull_log:
entry = PullLogEntry.from_pull_log_dict(job_id, e) entry = PullLogEntry.from_pull_log_dict(job_id, e)
if entry.error: if entry.error:
raise entry.exception raise entry.exception
self.sys_loop.call_soon_threadsafe( await asyncio.gather(
self.sys_bus.fire_event, BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry *self.sys_bus.fire_event(BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry)
) )
sep = "@" if tag.startswith("sha256:") else ":" 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( def run_command(
self, self,
@@ -459,7 +469,7 @@ class DockerAPI(CoreSysAttributes):
_LOGGER.info("Runing command '%s' on %s", command, image_with_tag) _LOGGER.info("Runing command '%s' on %s", command, image_with_tag)
container = None container = None
try: try:
container = self.docker.containers.run( container = self.dockerpy.containers.run(
image_with_tag, image_with_tag,
command=command, command=command,
detach=True, detach=True,
@@ -487,35 +497,35 @@ class DockerAPI(CoreSysAttributes):
"""Repair local docker overlayfs2 issues.""" """Repair local docker overlayfs2 issues."""
_LOGGER.info("Prune stale containers") _LOGGER.info("Prune stale containers")
try: try:
output = self.docker.api.prune_containers() output = self.dockerpy.api.prune_containers()
_LOGGER.debug("Containers prune: %s", output) _LOGGER.debug("Containers prune: %s", output)
except docker_errors.APIError as err: except docker_errors.APIError as err:
_LOGGER.warning("Error for containers prune: %s", err) _LOGGER.warning("Error for containers prune: %s", err)
_LOGGER.info("Prune stale images") _LOGGER.info("Prune stale images")
try: 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) _LOGGER.debug("Images prune: %s", output)
except docker_errors.APIError as err: except docker_errors.APIError as err:
_LOGGER.warning("Error for images prune: %s", err) _LOGGER.warning("Error for images prune: %s", err)
_LOGGER.info("Prune stale builds") _LOGGER.info("Prune stale builds")
try: try:
output = self.docker.api.prune_builds() output = self.dockerpy.api.prune_builds()
_LOGGER.debug("Builds prune: %s", output) _LOGGER.debug("Builds prune: %s", output)
except docker_errors.APIError as err: except docker_errors.APIError as err:
_LOGGER.warning("Error for builds prune: %s", err) _LOGGER.warning("Error for builds prune: %s", err)
_LOGGER.info("Prune stale volumes") _LOGGER.info("Prune stale volumes")
try: try:
output = self.docker.api.prune_builds() output = self.dockerpy.api.prune_volumes()
_LOGGER.debug("Volumes prune: %s", output) _LOGGER.debug("Volumes prune: %s", output)
except docker_errors.APIError as err: except docker_errors.APIError as err:
_LOGGER.warning("Error for volumes prune: %s", err) _LOGGER.warning("Error for volumes prune: %s", err)
_LOGGER.info("Prune stale networks") _LOGGER.info("Prune stale networks")
try: try:
output = self.docker.api.prune_networks() output = self.dockerpy.api.prune_networks()
_LOGGER.debug("Networks prune: %s", output) _LOGGER.debug("Networks prune: %s", output)
except docker_errors.APIError as err: except docker_errors.APIError as err:
_LOGGER.warning("Error for networks prune: %s", err) _LOGGER.warning("Error for networks prune: %s", err)
@@ -537,11 +547,11 @@ class DockerAPI(CoreSysAttributes):
Fix: https://github.com/moby/moby/issues/23302 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(): for cid, data in network.attrs.get("Containers", {}).items():
try: try:
self.docker.containers.get(cid) self.dockerpy.containers.get(cid)
continue continue
except docker_errors.NotFound: except docker_errors.NotFound:
_LOGGER.debug( _LOGGER.debug(
@@ -556,22 +566,26 @@ class DockerAPI(CoreSysAttributes):
with suppress(docker_errors.DockerException, requests.RequestException): with suppress(docker_errors.DockerException, requests.RequestException):
network.disconnect(data.get("Name", cid), force=True) network.disconnect(data.get("Name", cid), force=True)
def container_is_initialized( async def container_is_initialized(
self, name: str, image: str, version: AwesomeVersion self, name: str, image: str, version: AwesomeVersion
) -> bool: ) -> bool:
"""Return True if docker container exists in good state and is built from expected image.""" """Return True if docker container exists in good state and is built from expected image."""
try: try:
docker_container = self.containers.get(name) docker_container = await self.sys_run_in_executor(self.containers.get, name)
docker_image = self.images.get(f"{image}:{version}") docker_image = await self.images.inspect(f"{image}:{version}")
except NotFound: except docker_errors.NotFound:
return False 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 raise DockerError() from err
# Check the image is correct and state is good # Check the image is correct and state is good
return ( return (
docker_container.image is not None 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") and docker_container.status in ("exited", "running", "created")
) )
@@ -581,18 +595,18 @@ class DockerAPI(CoreSysAttributes):
"""Stop/remove Docker container.""" """Stop/remove Docker container."""
try: try:
docker_container: Container = self.containers.get(name) docker_container: Container = self.containers.get(name)
except NotFound: except docker_errors.NotFound:
raise DockerNotFound() from None raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err: except (docker_errors.DockerException, requests.RequestException) as err:
raise DockerError() from err raise DockerError() from err
if docker_container.status == "running": if docker_container.status == "running":
_LOGGER.info("Stopping %s application", name) _LOGGER.info("Stopping %s application", name)
with suppress(DockerException, requests.RequestException): with suppress(docker_errors.DockerException, requests.RequestException):
docker_container.stop(timeout=timeout) docker_container.stop(timeout=timeout)
if remove_container: if remove_container:
with suppress(DockerException, requests.RequestException): with suppress(docker_errors.DockerException, requests.RequestException):
_LOGGER.info("Cleaning %s application", name) _LOGGER.info("Cleaning %s application", name)
docker_container.remove(force=True, v=True) docker_container.remove(force=True, v=True)
@@ -604,11 +618,11 @@ class DockerAPI(CoreSysAttributes):
"""Start Docker container.""" """Start Docker container."""
try: try:
docker_container: Container = self.containers.get(name) docker_container: Container = self.containers.get(name)
except NotFound: except docker_errors.NotFound:
raise DockerNotFound( raise DockerNotFound(
f"{name} not found for starting up", _LOGGER.error f"{name} not found for starting up", _LOGGER.error
) from None ) from None
except (DockerException, requests.RequestException) as err: except (docker_errors.DockerException, requests.RequestException) as err:
raise DockerError( raise DockerError(
f"Could not get {name} for starting up", _LOGGER.error f"Could not get {name} for starting up", _LOGGER.error
) from err ) from err
@@ -616,36 +630,36 @@ class DockerAPI(CoreSysAttributes):
_LOGGER.info("Starting %s", name) _LOGGER.info("Starting %s", name)
try: try:
docker_container.start() 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 raise DockerError(f"Can't start {name}: {err}", _LOGGER.error) from err
def restart_container(self, name: str, timeout: int) -> None: def restart_container(self, name: str, timeout: int) -> None:
"""Restart docker container.""" """Restart docker container."""
try: try:
container: Container = self.containers.get(name) container: Container = self.containers.get(name)
except NotFound: except docker_errors.NotFound:
raise DockerNotFound() from None raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err: except (docker_errors.DockerException, requests.RequestException) as err:
raise DockerError() from err raise DockerError() from err
_LOGGER.info("Restarting %s", name) _LOGGER.info("Restarting %s", name)
try: try:
container.restart(timeout=timeout) 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 raise DockerError(f"Can't restart {name}: {err}", _LOGGER.warning) from err
def container_logs(self, name: str, tail: int = 100) -> bytes: def container_logs(self, name: str, tail: int = 100) -> bytes:
"""Return Docker logs of container.""" """Return Docker logs of container."""
try: try:
docker_container: Container = self.containers.get(name) docker_container: Container = self.containers.get(name)
except NotFound: except docker_errors.NotFound:
raise DockerNotFound() from None raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err: except (docker_errors.DockerException, requests.RequestException) as err:
raise DockerError() from err raise DockerError() from err
try: try:
return docker_container.logs(tail=tail, stdout=True, stderr=True) 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( raise DockerError(
f"Can't grep logs from {name}: {err}", _LOGGER.warning f"Can't grep logs from {name}: {err}", _LOGGER.warning
) from err ) from err
@@ -654,9 +668,9 @@ class DockerAPI(CoreSysAttributes):
"""Read and return stats from container.""" """Read and return stats from container."""
try: try:
docker_container: Container = self.containers.get(name) docker_container: Container = self.containers.get(name)
except NotFound: except docker_errors.NotFound:
raise DockerNotFound() from None raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err: except (docker_errors.DockerException, requests.RequestException) as err:
raise DockerError() from err raise DockerError() from err
# container is not running # container is not running
@@ -665,7 +679,7 @@ class DockerAPI(CoreSysAttributes):
try: try:
return docker_container.stats(stream=False) return docker_container.stats(stream=False)
except (DockerException, requests.RequestException) as err: except (docker_errors.DockerException, requests.RequestException) as err:
raise DockerError( raise DockerError(
f"Can't read stats from {name}: {err}", _LOGGER.error f"Can't read stats from {name}: {err}", _LOGGER.error
) from err ) from err
@@ -674,61 +688,84 @@ class DockerAPI(CoreSysAttributes):
"""Execute a command inside Docker container.""" """Execute a command inside Docker container."""
try: try:
docker_container: Container = self.containers.get(name) docker_container: Container = self.containers.get(name)
except NotFound: except docker_errors.NotFound:
raise DockerNotFound() from None raise DockerNotFound() from None
except (DockerException, requests.RequestException) as err: except (docker_errors.DockerException, requests.RequestException) as err:
raise DockerError() from err raise DockerError() from err
# Execute # Execute
try: try:
code, output = docker_container.exec_run(command) 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 raise DockerError() from err
return CommandReturn(code, output) return CommandReturn(code, output)
def remove_image( async def remove_image(
self, image: str, version: AwesomeVersion, latest: bool = True self, image: str, version: AwesomeVersion, latest: bool = True
) -> None: ) -> None:
"""Remove a Docker image by version and latest.""" """Remove a Docker image by version and latest."""
try: try:
if latest: if latest:
_LOGGER.info("Removing image %s with latest", image) _LOGGER.info("Removing image %s with latest", image)
with suppress(ImageNotFound): try:
self.images.remove(image=f"{image}:latest", force=True) 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) _LOGGER.info("Removing image %s with %s", image, version)
with suppress(ImageNotFound): try:
self.images.remove(image=f"{image}:{version!s}", force=True) 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( raise DockerError(
f"Can't remove image {image}: {err}", _LOGGER.warning f"Can't remove image {image}: {err}", _LOGGER.warning
) from err ) 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.""" """Import a tar file as image."""
try: try:
with tar_file.open("rb") as read_tar: 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:
if len(docker_image_list) != 1:
_LOGGER.warning(
"Unexpected image count %d while importing image from tar",
len(docker_image_list),
)
return None
return docker_image_list[0]
except (DockerException, OSError) as err:
raise DockerError( raise DockerError(
f"Can't import image from tar: {err}", _LOGGER.error f"Can't import image from tar: {err}", _LOGGER.error
) from err ) from err
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,
)
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"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: def export_image(self, image: str, version: AwesomeVersion, tar_file: Path) -> None:
"""Export current images into a tar file.""" """Export current images into a tar file."""
try: try:
docker_image = self.api.get_image(f"{image}:{version}") 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( raise DockerError(
f"Can't fetch image {image}: {err}", _LOGGER.error f"Can't fetch image {image}: {err}", _LOGGER.error
) from err ) from err
@@ -745,7 +782,7 @@ class DockerAPI(CoreSysAttributes):
_LOGGER.info("Export image %s done", image) _LOGGER.info("Export image %s done", image)
def cleanup_old_images( async def cleanup_old_images(
self, self,
current_image: str, current_image: str,
current_version: AwesomeVersion, current_version: AwesomeVersion,
@@ -756,46 +793,57 @@ class DockerAPI(CoreSysAttributes):
"""Clean up old versions of an image.""" """Clean up old versions of an image."""
image = f"{current_image}:{current_version!s}" image = f"{current_image}:{current_version!s}"
try: try:
keep = {cast(str, self.images.get(image).id)} try:
except ImageNotFound: image_attr = await self.images.inspect(image)
raise DockerNotFound( except aiodocker.DockerError as err:
f"{current_image} not found for cleanup", _LOGGER.warning if err.status == HTTPStatus.NOT_FOUND:
) from None raise DockerNotFound(
except (DockerException, requests.RequestException) as err: f"{current_image} not found for cleanup", _LOGGER.warning
) from None
raise
except (aiodocker.DockerError, requests.RequestException) as err:
raise DockerError( raise DockerError(
f"Can't get {current_image} for cleanup", _LOGGER.warning f"Can't get {current_image} for cleanup", _LOGGER.warning
) from err ) from err
keep = {cast(str, image_attr["Id"])}
if keep_images: if keep_images:
keep_images -= {image} keep_images -= {image}
try: results = await asyncio.gather(
for image in keep_images: *[self.images.inspect(image) for image in keep_images],
# If its not found, no need to preserve it from getting removed return_exceptions=True,
with suppress(ImageNotFound): )
keep.add(cast(str, self.images.get(image).id)) for result in results:
except (DockerException, requests.RequestException) as err: # If its not found, no need to preserve it from getting removed
raise DockerError( if (
f"Failed to get one or more images from {keep} during cleanup", isinstance(result, aiodocker.DockerError)
_LOGGER.warning, and result.status == HTTPStatus.NOT_FOUND
) from err ):
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 # Cleanup old and current
image_names = list( image_names = list(
old_images | {current_image} if old_images else {current_image} old_images | {current_image} if old_images else {current_image}
) )
try: try:
# This API accepts a list of image names. Tested and confirmed working on docker==7.1.0 images_list = await self.images.list(
# Its typing does say only `str` though. Bit concerning, could an update break this? filters=json.dumps({"reference": image_names})
images_list = self.images.list(name=image_names) # type: ignore )
except (DockerException, requests.RequestException) as err: except (aiodocker.DockerError, requests.RequestException) as err:
raise DockerError( raise DockerError(
f"Corrupt docker overlayfs found: {err}", _LOGGER.warning f"Corrupt docker overlayfs found: {err}", _LOGGER.warning
) from err ) from err
for docker_image in images_list: for docker_image in images_list:
if docker_image.id in keep: if docker_image["Id"] in keep:
continue continue
with suppress(DockerException, requests.RequestException): with suppress(aiodocker.DockerError, requests.RequestException):
_LOGGER.info("Cleanup images: %s", docker_image.tags) _LOGGER.info("Cleanup images: %s", docker_image["RepoTags"])
self.images.remove(docker_image.id, force=True) await self.images.delete(docker_image["Id"], force=True)

View File

@@ -89,7 +89,7 @@ class DockerMonitor(CoreSysAttributes, Thread):
DockerContainerStateEvent( DockerContainerStateEvent(
name=attributes["name"], name=attributes["name"],
state=container_state, state=container_state,
id=event["id"], id=event["Actor"]["ID"],
time=event["time"], time=event["time"],
), ),
) )

View File

@@ -1,10 +1,12 @@
"""Init file for Supervisor Docker object.""" """Init file for Supervisor Docker object."""
import asyncio
from collections.abc import Awaitable from collections.abc import Awaitable
from ipaddress import IPv4Address from ipaddress import IPv4Address
import logging import logging
import os import os
import aiodocker
from awesomeversion.awesomeversion import AwesomeVersion from awesomeversion.awesomeversion import AwesomeVersion
import docker import docker
import requests import requests
@@ -112,19 +114,18 @@ class DockerSupervisor(DockerInterface):
name="docker_supervisor_update_start_tag", name="docker_supervisor_update_start_tag",
concurrency=JobConcurrency.GROUP_QUEUE, 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.""" """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: try:
docker_container = self.sys_docker.containers.get(self.name) docker_container = await self.sys_run_in_executor(
docker_image = self.sys_docker.images.get(f"{image}:{version!s}") self.sys_docker.containers.get, self.name
except (docker.errors.DockerException, requests.RequestException) as err: )
docker_image = await self.sys_docker.images.inspect(f"{image}:{version!s}")
except (
aiodocker.DockerError,
docker.errors.DockerException,
requests.RequestException,
) as err:
raise DockerError( raise DockerError(
f"Can't get image or container to fix start tag: {err}", _LOGGER.error f"Can't get image or container to fix start tag: {err}", _LOGGER.error
) from err ) from err
@@ -144,8 +145,14 @@ class DockerSupervisor(DockerInterface):
# If version tag # If version tag
if start_tag != "latest": if start_tag != "latest":
continue continue
docker_image.tag(start_image, start_tag) await asyncio.gather(
docker_image.tag(start_image, version.string) 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 (docker.errors.DockerException, requests.RequestException) as err: except (aiodocker.DockerError, requests.RequestException) as err:
raise DockerError(f"Can't fix start tag: {err}", _LOGGER.error) from err raise DockerError(f"Can't fix start tag: {err}", _LOGGER.error) from err

View File

@@ -639,9 +639,32 @@ class DockerLogOutOfOrder(DockerError):
class DockerNoSpaceOnDevice(DockerError): class DockerNoSpaceOnDevice(DockerError):
"""Raise if a docker pull fails due to available space.""" """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: def __init__(self, logger: Callable[..., None] | None = None) -> None:
"""Raise & log.""" """Raise & log."""
super().__init__("No space left on disk", logger=logger) 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__(
None,
logger=logger,
extra_fields={
"dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit"
},
)
class DockerJobError(DockerError, JobException): class DockerJobError(DockerError, JobException):

View File

@@ -64,6 +64,19 @@ def filter_data(coresys: CoreSys, event: Event, hint: Hint) -> Event | None:
# Not full startup - missing information # Not full startup - missing information
if coresys.core.state in (CoreState.INITIALIZE, CoreState.SETUP): if coresys.core.state in (CoreState.INITIALIZE, CoreState.SETUP):
# During SETUP, we have basic system info available for better debugging
if coresys.core.state == CoreState.SETUP:
event.setdefault("contexts", {}).update(
{
"versions": {
"docker": coresys.docker.info.version,
"supervisor": coresys.supervisor.version,
},
"host": {
"machine": coresys.machine,
},
}
)
return event return event
# List installed addons # List installed addons

View File

@@ -3,22 +3,25 @@
import asyncio import asyncio
from datetime import timedelta from datetime import timedelta
import errno import errno
from http import HTTPStatus
from pathlib import Path 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 awesomeversion import AwesomeVersion
from docker.errors import DockerException, ImageNotFound, NotFound from docker.errors import APIError, DockerException, NotFound
import pytest import pytest
from securetar import SecureTarFile from securetar import SecureTarFile
from supervisor.addons.addon import Addon from supervisor.addons.addon import Addon
from supervisor.addons.const import AddonBackupMode from supervisor.addons.const import AddonBackupMode
from supervisor.addons.model import AddonModel from supervisor.addons.model import AddonModel
from supervisor.config import CoreConfig
from supervisor.const import AddonBoot, AddonState, BusEvent from supervisor.const import AddonBoot, AddonState, BusEvent
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.docker.addon import DockerAddon from supervisor.docker.addon import DockerAddon
from supervisor.docker.const import ContainerState 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.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
from supervisor.hardware.helper import HwHelper 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) container.remove.assert_called_with(force=True, v=True)
# one for removing the addon, one for removing the addon builder # 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 == { assert coresys.docker.images.delete.call_args_list[0] == call(
"image": "local/aarch64-addon-ssh:latest", "local/aarch64-addon-ssh:latest", force=True
"force": True, )
} assert coresys.docker.images.delete.call_args_list[1] == call(
assert coresys.docker.images.remove.call_args_list[1].kwargs == { "local/aarch64-addon-ssh:9.2.1", force=True
"image": "local/aarch64-addon-ssh:9.2.1", )
"force": True,
}
mock_run_command.assert_called_once() mock_run_command.assert_called_once()
assert mock_run_command.call_args.args[0] == "docker.io/library/docker" assert mock_run_command.call_args.args[0] == "docker.io/library/docker"
assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli" 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, mock_amd64_arch_supported,
): ):
"""Test addon corrects a missing image on load.""" """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 ( with (
patch("pathlib.Path.is_file", return_value=True), 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" 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( async def test_addon_load_succeeds_with_docker_errors(
coresys: CoreSys, coresys: CoreSys,
install_addon_ssh: Addon, install_addon_ssh: Addon,
container: MagicMock,
caplog: pytest.LogCaptureFixture, 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.""" """Docker errors while building/pulling an image during load should not raise and fail setup."""
# Build env invalid failure # 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() caplog.clear()
await install_addon_ssh.load() await install_addon_ssh.load()
assert "Invalid build environment" in caplog.text assert "Invalid build environment" in caplog.text
# Image build failure # Image build failure
coresys.docker.images.build.side_effect = DockerException()
caplog.clear() caplog.clear()
with ( with (
patch("pathlib.Path.is_file", return_value=True), patch("pathlib.Path.is_file", return_value=True),
patch.object( patch.object(
type(coresys.config), CoreConfig, "local_to_extern_path", return_value="/addon/path/on/host"
"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() 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 # Image pull failure
install_addon_ssh.data["image"] = "test/amd64-addon-ssh" 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() caplog.clear()
await install_addon_ssh.load() with patch.object(DockerAPI, "pull_image", side_effect=pull_image_exc):
assert "Unknown error with test/amd64-addon-ssh:9.2.1" in caplog.text 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): async def test_addon_manual_only_boot(coresys: CoreSys, install_addon_example: Addon):

View File

@@ -4,7 +4,7 @@ import asyncio
from collections.abc import AsyncGenerator, Generator from collections.abc import AsyncGenerator, Generator
from copy import deepcopy from copy import deepcopy
from pathlib import Path 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 from awesomeversion import AwesomeVersion
import pytest import pytest
@@ -514,19 +514,13 @@ async def test_shared_image_kept_on_uninstall(
latest = f"{install_addon_example.image}:latest" latest = f"{install_addon_example.image}:latest"
await coresys.addons.uninstall("local_example2") 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) assert not coresys.addons.get("local_example2", local_only=True)
await coresys.addons.uninstall("local_example") await coresys.addons.uninstall("local_example")
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 == { assert coresys.docker.images.delete.call_args_list[0] == call(latest, force=True)
"image": latest, assert coresys.docker.images.delete.call_args_list[1] == call(image, force=True)
"force": True,
}
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
"image": image,
"force": True,
}
assert not coresys.addons.get("local_example", local_only=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 example_2.version == "1.2.0"
assert install_addon_example_image.version == "1.2.0" assert install_addon_example_image.version == "1.2.0"
image_new = MagicMock() image_new = {"Id": "image_new", "RepoTags": ["image_new:latest"]}
image_new.id = "image_new" image_old = {"Id": "image_old", "RepoTags": ["image_old:latest"]}
image_old = MagicMock() docker.images.inspect.side_effect = [image_new, image_old]
image_old.id = "image_old"
docker.images.get.side_effect = [image_new, image_old]
docker.images.list.return_value = [image_new, image_old] docker.images.list.return_value = [image_new, image_old]
with patch.object(DockerAPI, "pull_image", return_value=image_new): with patch.object(DockerAPI, "pull_image", return_value=image_new):
await coresys.addons.update("local_example2") 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" 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") 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" assert install_addon_example_image.version == "1.3.0"

View File

@@ -19,7 +19,7 @@ from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.homeassistant.module import HomeAssistant from supervisor.homeassistant.module import HomeAssistant
from tests.api import common_test_api_advanced_logs 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]) @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.""" """Test progress updates sent to Home Assistant for updates."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.core.set_state(CoreState.RUNNING) 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") coresys.homeassistant.version = AwesomeVersion("2025.8.0")
with ( with (

View File

@@ -24,7 +24,7 @@ from supervisor.homeassistant.module import HomeAssistant
from supervisor.store.addon import AddonStore from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository 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 from tests.const import TEST_ADDON_SLUG
REPO_URL = "https://github.com/awesome-developer/awesome-repo" 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.""" """Test progress updates sent to Home Assistant for installs/updates."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.core.set_state(CoreState.RUNNING) 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 coresys.arch._supported_arch = ["amd64"] # pylint: disable=protected-access
install_addon_example.data_store["version"] = AwesomeVersion("2.0.0") install_addon_example.data_store["version"] = AwesomeVersion("2.0.0")

View File

@@ -19,7 +19,7 @@ from supervisor.supervisor import Supervisor
from supervisor.updater import Updater from supervisor.updater import Updater
from tests.api import common_test_api_advanced_logs 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.base import DBusServiceMock
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService 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.""" """Test progress updates sent to Home Assistant for updates."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000 coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.core.set_state(CoreState.RUNNING) 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 ( with (
patch.object( patch.object(

View File

@@ -1,13 +1,14 @@
"""Common test functions.""" """Common test functions."""
import asyncio import asyncio
from collections.abc import Sequence
from datetime import datetime from datetime import datetime
from functools import partial from functools import partial
from importlib import import_module from importlib import import_module
from inspect import getclosurevars from inspect import getclosurevars
import json import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any, Self
from dbus_fast.aio.message_bus import MessageBus from dbus_fast.aio.message_bus import MessageBus
@@ -145,3 +146,22 @@ class MockResponse:
async def __aexit__(self, exc_type, exc, tb): async def __aexit__(self, exc_type, exc, tb):
"""Exit the context manager.""" """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

View File

@@ -9,6 +9,7 @@ import subprocess
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
from uuid import uuid4 from uuid import uuid4
from aiodocker.docker import DockerImages
from aiohttp import ClientSession, web from aiohttp import ClientSession, web
from aiohttp.test_utils import TestClient from aiohttp.test_utils import TestClient
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
@@ -55,6 +56,7 @@ from supervisor.store.repository import Repository
from supervisor.utils.dt import utcnow from supervisor.utils.dt import utcnow
from .common import ( from .common import (
AsyncIterator,
MockResponse, MockResponse,
load_binary_fixture, load_binary_fixture,
load_fixture, load_fixture,
@@ -112,40 +114,46 @@ async def supervisor_name() -> None:
@pytest.fixture @pytest.fixture
async def docker() -> DockerAPI: async def docker() -> DockerAPI:
"""Mock DockerAPI.""" """Mock DockerAPI."""
images = [MagicMock(tags=["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"])] image_inspect = {
image = MagicMock() "Os": "linux",
image.attrs = {"Os": "linux", "Architecture": "amd64"} "Architecture": "amd64",
"Id": "test123",
"RepoTags": ["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"],
}
with ( with (
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()), patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
patch("supervisor.docker.manager.DockerAPI.images", return_value=MagicMock()),
patch( patch(
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock() "supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
), ),
patch( patch("supervisor.docker.manager.DockerAPI.api", return_value=MagicMock()),
"supervisor.docker.manager.DockerAPI.api", patch("supervisor.docker.manager.DockerAPI.info", return_value=MagicMock()),
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(),
),
patch("supervisor.docker.manager.DockerAPI.unload"), patch("supervisor.docker.manager.DockerAPI.unload"),
patch("supervisor.docker.manager.aiodocker.Docker", return_value=MagicMock()),
patch(
"supervisor.docker.manager.DockerAPI.images",
new=PropertyMock(
return_value=(docker_images := MagicMock(spec=DockerImages))
),
),
): ):
docker_obj = await DockerAPI(MagicMock()).post_init() docker_obj = await DockerAPI(MagicMock()).post_init()
docker_obj.config._data = {"registries": {}} docker_obj.config._data = {"registries": {}}
with patch("supervisor.docker.monitor.DockerMonitor.load"): with patch("supervisor.docker.monitor.DockerMonitor.load"):
await docker_obj.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.logging = "journald"
docker_obj.info.storage = "overlay2" docker_obj.info.storage = "overlay2"
docker_obj.info.version = AwesomeVersion("1.0.0") docker_obj.info.version = AwesomeVersion("1.0.0")
# Need an iterable for logs
api_mock.pull.return_value = []
yield docker_obj yield docker_obj
@@ -838,11 +846,9 @@ async def container(docker: DockerAPI) -> MagicMock:
"""Mock attrs and status for container on attach.""" """Mock attrs and status for container on attach."""
docker.containers.get.return_value = addon = MagicMock() docker.containers.get.return_value = addon = MagicMock()
docker.containers.create.return_value = addon docker.containers.create.return_value = addon
docker.images.build.return_value = (addon, "")
addon.status = "stopped" addon.status = "stopped"
addon.attrs = {"State": {"ExitCode": 0}} addon.attrs = {"State": {"ExitCode": 0}}
with patch.object(DockerAPI, "pull_image", return_value=addon): yield addon
yield addon
@pytest.fixture @pytest.fixture

View File

@@ -5,10 +5,10 @@ from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch
import aiodocker
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
from docker.errors import DockerException, NotFound from docker.errors import DockerException, NotFound
from docker.models.containers import Container from docker.models.containers import Container
from docker.models.images import Image
import pytest import pytest
from requests import RequestException from requests import RequestException
@@ -28,7 +28,7 @@ from supervisor.exceptions import (
) )
from supervisor.jobs import JobSchedulerOptions, SupervisorJob from supervisor.jobs import JobSchedulerOptions, SupervisorJob
from tests.common import load_json_fixture from tests.common import AsyncIterator, load_json_fixture
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -48,35 +48,30 @@ async def test_docker_image_platform(
platform: str, platform: str,
): ):
"""Test platform set correctly from arch.""" """Test platform set correctly from arch."""
with patch.object( coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
coresys.docker.images, "get", return_value=Mock(id="test:1.2.3") await test_docker_interface.install(AwesomeVersion("1.2.3"), "test", arch=cpu_arch)
) as get: coresys.docker.images.pull.assert_called_once_with(
await test_docker_interface.install( "test", tag="1.2.3", platform=platform, stream=True
AwesomeVersion("1.2.3"), "test", arch=cpu_arch )
) coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
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")
async def test_docker_image_default_platform( async def test_docker_image_default_platform(
coresys: CoreSys, test_docker_interface: DockerInterface coresys: CoreSys, test_docker_interface: DockerInterface
): ):
"""Test platform set using supervisor arch when omitted.""" """Test platform set using supervisor arch when omitted."""
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
with ( with (
patch.object( patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value="i386") 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") await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
coresys.docker.docker.api.pull.assert_called_once_with( coresys.docker.images.pull.assert_called_once_with(
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True "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( @pytest.mark.parametrize(
@@ -207,57 +202,40 @@ async def test_attach_existing_container(
async def test_attach_container_failure(coresys: CoreSys): async def test_attach_container_failure(coresys: CoreSys):
"""Test attach fails to find container but finds image.""" """Test attach fails to find container but finds image."""
container_collection = MagicMock() coresys.docker.containers.get.side_effect = DockerException()
container_collection.get.side_effect = DockerException() coresys.docker.images.inspect.return_value.setdefault("Config", {})["Image"] = (
image_collection = MagicMock() "sha256:abc123"
image_config = {"Image": "sha256:abc123"} )
image_collection.get.return_value = Image({"Config": image_config}) with patch.object(type(coresys.bus), "fire_event") as fire_event:
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,
):
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3")) await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
assert not [ assert not [
event event
for event in fire_event.call_args_list for event in fire_event.call_args_list
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE 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): async def test_attach_total_failure(coresys: CoreSys):
"""Test attach fails to find container or image.""" """Test attach fails to find container or image."""
container_collection = MagicMock() coresys.docker.containers.get.side_effect = DockerException
container_collection.get.side_effect = DockerException() coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
image_collection = MagicMock() 400, {"message": ""}
image_collection.get.side_effect = DockerException() )
with ( with pytest.raises(DockerError):
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),
):
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3")) 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( async def test_image_pull_fail(
coresys: CoreSys, capture_exception: Mock, err: Exception coresys: CoreSys, capture_exception: Mock, err: Exception
): ):
"""Test failure to pull image.""" """Test failure to pull image."""
coresys.docker.images.get.side_effect = err coresys.docker.images.inspect.side_effect = err
with pytest.raises(DockerError): with pytest.raises(DockerError):
await coresys.homeassistant.core.instance.install( await coresys.homeassistant.core.instance.install(
AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64 AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64
@@ -289,8 +267,9 @@ async def test_install_fires_progress_events(
coresys: CoreSys, test_docker_interface: DockerInterface coresys: CoreSys, test_docker_interface: DockerInterface
): ):
"""Test progress events are fired during an install for listeners.""" """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 # 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", "status": "Pulling from home-assistant/odroid-n2-homeassistant",
"id": "2025.7.2", "id": "2025.7.2",
@@ -312,7 +291,11 @@ async def test_install_fires_progress_events(
"id": "1578b14a573c", "id": "1578b14a573c",
}, },
{"status": "Pull complete", "progressDetail": {}, "id": "1578b14a573c"}, {"status": "Pull complete", "progressDetail": {}, "id": "1578b14a573c"},
{"status": "Verifying Checksum", "progressDetail": {}, "id": "6a1e931d8f88"}, {
"status": "Verifying Checksum",
"progressDetail": {},
"id": "6a1e931d8f88",
},
{ {
"status": "Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d" "status": "Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d"
}, },
@@ -320,6 +303,7 @@ async def test_install_fires_progress_events(
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.2" "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] = [] events: list[PullLogEntry] = []
@@ -334,10 +318,10 @@ async def test_install_fires_progress_events(
), ),
): ):
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test") await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
coresys.docker.docker.api.pull.assert_called_once_with( coresys.docker.images.pull.assert_called_once_with(
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True "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) await asyncio.sleep(1)
assert events == [ assert events == [
@@ -415,10 +399,11 @@ async def test_install_progress_rounding_does_not_cause_misses(
): ):
"""Test extremely close progress events do not create rounding issues.""" """Test extremely close progress events do not create rounding issues."""
coresys.core.set_state(CoreState.RUNNING) coresys.core.set_state(CoreState.RUNNING)
# Current numbers chosen to create a rounding issue with original code # Current numbers chosen to create a rounding issue with original code
# Where a progress update came in with a value between the actual previous # 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 # 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", "status": "Pulling from home-assistant/odroid-n2-homeassistant",
"id": "2025.7.1", "id": "2025.7.1",
@@ -458,6 +443,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" "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 ( with (
patch.object( patch.object(
@@ -513,7 +499,8 @@ async def test_install_raises_on_pull_error(
exc_msg: str, exc_msg: str,
): ):
"""Test exceptions raised from errors in pull log.""" """Test exceptions raised from errors in pull log."""
coresys.docker.docker.api.pull.return_value = [
logs = [
{ {
"status": "Pulling from home-assistant/odroid-n2-homeassistant", "status": "Pulling from home-assistant/odroid-n2-homeassistant",
"id": "2025.7.2", "id": "2025.7.2",
@@ -526,6 +513,7 @@ async def test_install_raises_on_pull_error(
}, },
error_log, error_log,
] ]
coresys.docker.images.pull.return_value = AsyncIterator(logs)
with pytest.raises(exc_type, match=exc_msg): with pytest.raises(exc_type, match=exc_msg):
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test") await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
@@ -539,11 +527,11 @@ async def test_install_progress_handles_download_restart(
): ):
"""Test install handles docker progress events that include a download restart.""" """Test install handles docker progress events that include a download restart."""
coresys.core.set_state(CoreState.RUNNING) coresys.core.set_state(CoreState.RUNNING)
# Fixture emulates a download restart as it docker logs it # Fixture emulates a download restart as it docker logs it
# A log out of order exception should not be raised # A log out of order exception should not be raised
coresys.docker.docker.api.pull.return_value = load_json_fixture( logs = load_json_fixture("docker_pull_image_log_restart.json")
"docker_pull_image_log_restart.json" coresys.docker.images.pull.return_value = AsyncIterator(logs)
)
with ( with (
patch.object( patch.object(
@@ -586,7 +574,7 @@ async def test_install_progress_handles_layers_skipping_download(
# Reproduce EXACT sequence from SupervisorNoUpdateProgressLogs.txt: # Reproduce EXACT sequence from SupervisorNoUpdateProgressLogs.txt:
# Small layer (02a6e69d8d00) completes BEFORE normal layer (3f4a84073184) starts downloading # Small layer (02a6e69d8d00) completes BEFORE normal layer (3f4a84073184) starts downloading
coresys.docker.docker.api.pull.return_value = [ logs = [
{"status": "Pulling from test/image", "id": "latest"}, {"status": "Pulling from test/image", "id": "latest"},
# Small layer that skips downloading (02a6e69d8d00 in logs, 96 bytes) # Small layer that skips downloading (02a6e69d8d00 in logs, 96 bytes)
{"status": "Pulling fs layer", "progressDetail": {}, "id": "02a6e69d8d00"}, {"status": "Pulling fs layer", "progressDetail": {}, "id": "02a6e69d8d00"},
@@ -634,6 +622,7 @@ async def test_install_progress_handles_layers_skipping_download(
{"status": "Digest: sha256:test"}, {"status": "Digest: sha256:test"},
{"status": "Status: Downloaded newer image for test/image:latest"}, {"status": "Status: Downloaded newer image for test/image:latest"},
] ]
coresys.docker.images.pull.return_value = AsyncIterator(logs)
# Capture immutable snapshots of install job progress using job.as_dict() # Capture immutable snapshots of install job progress using job.as_dict()
# This solves the mutable object problem - we snapshot state at call time # This solves the mutable object problem - we snapshot state at call time
@@ -675,50 +664,3 @@ async def test_install_progress_handles_layers_skipping_download(
assert job.done is True assert job.done is True
assert job.progress == 100 assert job.progress == 100
capture_exception.assert_not_called() capture_exception.assert_not_called()
async def test_install_progress_handles_containerd_snapshotter(
coresys: CoreSys,
test_docker_interface: DockerInterface,
capture_exception: Mock,
):
"""Test install handles containerd snapshotter format where extraction has no total bytes.
With containerd snapshotter, the extraction phase reports time elapsed in seconds
rather than bytes extracted. The progress_detail has format:
{"current": <seconds>, "units": "s"} with total=None
This test ensures we handle this gracefully by using the download size for
aggregate progress calculation.
"""
coresys.core.set_state(CoreState.RUNNING)
# Fixture emulates containerd snapshotter pull log format
coresys.docker.docker.api.pull.return_value = load_json_fixture(
"docker_pull_image_log_containerd.json"
)
with patch.object(
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
):
event = asyncio.Event()
job, install_task = coresys.jobs.schedule_job(
test_docker_interface.install,
JobSchedulerOptions(),
AwesomeVersion("1.2.3"),
"test",
)
async def listen_for_job_end(reference: SupervisorJob):
if reference.uuid != job.uuid:
return
event.set()
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end)
await install_task
await event.wait()
# Job should complete successfully without exceptions
assert job.done is True
assert job.progress == 100
capture_exception.assert_not_called()

View File

@@ -1,9 +1,10 @@
"""Test Docker manager.""" """Test Docker manager."""
import asyncio import asyncio
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from docker.errors import DockerException from docker.errors import APIError, DockerException, NotFound
import pytest import pytest
from requests import RequestException 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_container.logs.return_value = b"command output"
# Mock docker containers.run to return our mock container # 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 # Execute the command
result = docker.run_command( result = docker.run_command(
@@ -33,7 +34,7 @@ async def test_run_command_success(docker: DockerAPI):
assert result.output == b"command output" assert result.output == b"command output"
# Verify docker.containers.run was called correctly # 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", "alpine:3.18",
command="echo hello", command="echo hello",
detach=True, detach=True,
@@ -55,7 +56,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
mock_container.logs.return_value = b"error output" mock_container.logs.return_value = b"error output"
# Mock docker containers.run to return our mock container # 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 # Execute the command with minimal parameters
result = docker.run_command(image="ubuntu") 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" assert result.output == b"error output"
# Verify docker.containers.run was called with defaults # 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 "ubuntu:latest", # default tag
command=None, # default command command=None, # default command
detach=True, detach=True,
@@ -81,7 +82,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
async def test_run_command_docker_exception(docker: DockerAPI): async def test_run_command_docker_exception(docker: DockerAPI):
"""Test command execution when Docker raises an exception.""" """Test command execution when Docker raises an exception."""
# Mock docker containers.run to raise DockerException # 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 # Execute the command and expect DockerError
with pytest.raises(DockerError, match="Can't execute command: Docker error"): 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): async def test_run_command_request_exception(docker: DockerAPI):
"""Test command execution when requests raises an exception.""" """Test command execution when requests raises an exception."""
# Mock docker containers.run to raise RequestException # 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 # Execute the command and expect DockerError
with pytest.raises(DockerError, match="Can't execute command: Connection error"): 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_container = MagicMock()
# Mock docker.containers.run to return container, but container.wait to raise exception # 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") mock_container.wait.side_effect = DockerException("Wait failed")
# Execute the command and expect DockerError # 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_container.logs.return_value = b"output"
# Mock docker containers.run to return our mock container # 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 # Execute the command with custom stdout/stderr
result = docker.run_command( 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" cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
extern_cidfile_path = coresys.config.path_extern_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 # Mock container creation
with patch.object( 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 cidfile_path.read_text() == mock_container.id
assert result == mock_container 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()

View File

@@ -88,7 +88,7 @@ async def test_events(
): ):
"""Test events created from docker events.""" """Test events created from docker events."""
event["Actor"]["Attributes"]["name"] = "some_container" event["Actor"]["Attributes"]["name"] = "some_container"
event["id"] = "abc123" event["Actor"]["ID"] = "abc123"
event["time"] = 123 event["time"] = 123
with ( with (
patch( patch(
@@ -131,12 +131,12 @@ async def test_unlabeled_container(coresys: CoreSys):
new=PropertyMock( new=PropertyMock(
return_value=[ return_value=[
{ {
"id": "abc123",
"time": 123, "time": 123,
"Type": "container", "Type": "container",
"Action": "die", "Action": "die",
"Actor": { "Actor": {
"Attributes": {"name": "homeassistant", "exitCode": "137"} "ID": "abc123",
"Attributes": {"name": "homeassistant", "exitCode": "137"},
}, },
} }
] ]

View File

@@ -1,122 +0,0 @@
[
{
"status": "Pulling from home-assistant/test-image",
"id": "2025.7.1"
},
{
"status": "Pulling fs layer",
"progressDetail": {},
"id": "layer1"
},
{
"status": "Pulling fs layer",
"progressDetail": {},
"id": "layer2"
},
{
"status": "Downloading",
"progressDetail": {
"current": 1048576,
"total": 5178461
},
"progress": "[===========> ] 1.049MB/5.178MB",
"id": "layer1"
},
{
"status": "Downloading",
"progressDetail": {
"current": 5178461,
"total": 5178461
},
"progress": "[==================================================>] 5.178MB/5.178MB",
"id": "layer1"
},
{
"status": "Download complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer1"
},
{
"status": "Downloading",
"progressDetail": {
"current": 1048576,
"total": 10485760
},
"progress": "[=====> ] 1.049MB/10.49MB",
"id": "layer2"
},
{
"status": "Downloading",
"progressDetail": {
"current": 10485760,
"total": 10485760
},
"progress": "[==================================================>] 10.49MB/10.49MB",
"id": "layer2"
},
{
"status": "Download complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer2"
},
{
"status": "Extracting",
"progressDetail": {
"current": 1,
"units": "s"
},
"progress": "1 s",
"id": "layer1"
},
{
"status": "Extracting",
"progressDetail": {
"current": 5,
"units": "s"
},
"progress": "5 s",
"id": "layer1"
},
{
"status": "Pull complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer1"
},
{
"status": "Extracting",
"progressDetail": {
"current": 1,
"units": "s"
},
"progress": "1 s",
"id": "layer2"
},
{
"status": "Extracting",
"progressDetail": {
"current": 3,
"units": "s"
},
"progress": "3 s",
"id": "layer2"
},
{
"status": "Pull complete",
"progressDetail": {
"hidecounts": true
},
"id": "layer2"
},
{
"status": "Digest: sha256:abc123"
},
{
"status": "Status: Downloaded newer image for test/image:2025.7.1"
}
]

View File

@@ -1,11 +1,14 @@
"""Test Home Assistant core.""" """Test Home Assistant core."""
from datetime import datetime, timedelta 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 awesomeversion import AwesomeVersion
from docker.errors import APIError, DockerException, ImageNotFound, NotFound from docker.errors import APIError, DockerException, NotFound
import pytest import pytest
from requests import RequestException
from time_machine import travel from time_machine import travel
from supervisor.const import CpuArch from supervisor.const import CpuArch
@@ -23,8 +26,12 @@ from supervisor.exceptions import (
from supervisor.homeassistant.api import APIState from supervisor.homeassistant.api import APIState
from supervisor.homeassistant.core import HomeAssistantCore from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.homeassistant.module import HomeAssistant 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 supervisor.updater import Updater
from tests.common import AsyncIterator
async def test_update_fails_if_out_of_date(coresys: CoreSys): 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.""" """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() await coresys.homeassistant.core.update()
async def test_install_landingpage_docker_error( @pytest.mark.parametrize(
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture "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.security.force = True
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
with ( with (
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError), patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
patch.object( patch.object(
@@ -69,19 +88,35 @@ async def test_install_landingpage_docker_error(
), ),
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep, patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
): ):
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
await coresys.homeassistant.core.install_landingpage() await coresys.homeassistant.core.install_landingpage()
sleep.assert_awaited_once_with(30) sleep.assert_awaited_once_with(30)
assert "Failed to install landingpage, retrying after 30sec" in caplog.text assert "Failed to install landingpage, retrying after 30sec" in caplog.text
capture_exception.assert_not_called() 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( 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.""" """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 ( with (
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError), 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) capture_exception.assert_called_once_with(err)
async def test_install_docker_error( @pytest.mark.parametrize(
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture "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.security.force = True
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
with ( with (
patch.object(HomeAssistantCore, "start"), patch.object(HomeAssistantCore, "start"),
patch.object(DockerHomeAssistant, "cleanup"), patch.object(DockerHomeAssistant, "cleanup"),
@@ -123,19 +170,35 @@ async def test_install_docker_error(
), ),
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep, patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
): ):
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
await coresys.homeassistant.core.install() await coresys.homeassistant.core.install()
sleep.assert_awaited_once_with(30) sleep.assert_awaited_once_with(30)
assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text
capture_exception.assert_not_called() 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( 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.""" """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 ( with (
patch.object(HomeAssistantCore, "start"), patch.object(HomeAssistantCore, "start"),
@@ -161,21 +224,29 @@ async def test_install_other_error(
@pytest.mark.parametrize( @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( 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.""" """Test starting Home Assistant."""
if image_exists: coresys.docker.images.inspect.return_value = {"Id": "123"}
coresys.docker.images.get.return_value.id = "123" coresys.docker.images.inspect.side_effect = image_exc
else: coresys.docker.containers.get.return_value.id = "123"
coresys.docker.images.get.side_effect = ImageNotFound("missing") coresys.docker.containers.get.side_effect = container_exc
if container_exists:
coresys.docker.containers.get.return_value.image.id = "123"
else:
coresys.docker.containers.get.side_effect = NotFound("missing")
with ( with (
patch.object( patch.object(
@@ -198,18 +269,14 @@ async def test_start(
assert run.call_args.kwargs["hostname"] == "homeassistant" assert run.call_args.kwargs["hostname"] == "homeassistant"
coresys.docker.containers.get.return_value.stop.assert_not_called() coresys.docker.containers.get.return_value.stop.assert_not_called()
if container_exists: assert (
coresys.docker.containers.get.return_value.remove.assert_called_once_with( coresys.docker.containers.get.return_value.remove.call_args_list == remove_calls
force=True, )
v=True,
)
else:
coresys.docker.containers.get.return_value.remove.assert_not_called()
async def test_start_existing_container(coresys: CoreSys, path_extern): async def test_start_existing_container(coresys: CoreSys, path_extern):
"""Test starting Home Assistant when container exists and is viable.""" """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.image.id = "123"
coresys.docker.containers.get.return_value.status = "exited" 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.""" """Test core is loaded with wrong image for machine."""
coresys.homeassistant.set_image("ghcr.io/home-assistant/odroid-n2-homeassistant") coresys.homeassistant.set_image("ghcr.io/home-assistant/odroid-n2-homeassistant")
coresys.homeassistant.version = AwesomeVersion("2024.4.0") 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) container.remove.assert_called_once_with(force=True, v=True)
assert coresys.docker.images.remove.call_args_list[0].kwargs == { assert coresys.docker.images.delete.call_args_list[0] == call(
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:latest", "ghcr.io/home-assistant/odroid-n2-homeassistant:latest",
"force": True, force=True,
} )
assert coresys.docker.images.remove.call_args_list[1].kwargs == { assert coresys.docker.images.delete.call_args_list[1] == call(
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0", "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0",
"force": True, 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 ( assert (
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" 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() await coresys.homeassistant.core.load()
container.remove.assert_not_called() container.remove.assert_not_called()
coresys.docker.images.remove.assert_not_called() coresys.docker.images.delete.assert_not_called()
coresys.docker.images.get.assert_not_called() coresys.docker.images.inspect.assert_not_called()
assert ( assert (
coresys.homeassistant.image == "ghcr.io/home-assistant/odroid-n2-homeassistant" 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.""" """Test core is loaded with wrong image for architecture."""
coresys.homeassistant.version = AwesomeVersion("2024.4.0") coresys.homeassistant.version = AwesomeVersion("2024.4.0")
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}} coresys.docker.images.inspect.return_value = img_data = (
coresys.docker.images.get("ghcr.io/home-assistant/qemux86-64-homeassistant").attrs[ coresys.docker.images.inspect.return_value
"Architecture" | {
] = "arm64" "Architecture": "arm64",
"Config": {"Labels": {"io.hass.version": "2024.4.0"}},
}
)
container.attrs |= img_data
await coresys.homeassistant.core.load() 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) container.remove.assert_called_once_with(force=True, v=True)
assert coresys.docker.images.remove.call_args_list[0].kwargs == { assert coresys.docker.images.delete.call_args_list[0] == call(
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:latest", "ghcr.io/home-assistant/qemux86-64-homeassistant:latest",
"force": True, force=True,
} )
assert coresys.docker.images.remove.call_args_list[1].kwargs == { assert coresys.docker.images.delete.call_args_list[1] == call(
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0", "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0",
"force": True, 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 ( assert (
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant" coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"

View File

@@ -115,7 +115,17 @@ async def test_not_started(coresys):
assert filter_data(coresys, SAMPLE_EVENT, {}) == SAMPLE_EVENT assert filter_data(coresys, SAMPLE_EVENT, {}) == SAMPLE_EVENT
await coresys.core.set_state(CoreState.SETUP) await coresys.core.set_state(CoreState.SETUP)
assert filter_data(coresys, SAMPLE_EVENT, {}) == SAMPLE_EVENT filtered = filter_data(coresys, SAMPLE_EVENT, {})
# During SETUP, we should have basic system info available
assert "contexts" in filtered
assert "versions" in filtered["contexts"]
assert "docker" in filtered["contexts"]["versions"]
assert "supervisor" in filtered["contexts"]["versions"]
assert "host" in filtered["contexts"]
assert "machine" in filtered["contexts"]["host"]
assert filtered["contexts"]["versions"]["docker"] == coresys.docker.info.version
assert filtered["contexts"]["versions"]["supervisor"] == coresys.supervisor.version
assert filtered["contexts"]["host"]["machine"] == coresys.machine
async def test_defaults(coresys): async def test_defaults(coresys):

View File

@@ -2,7 +2,7 @@
import asyncio import asyncio
from pathlib import Path 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 from awesomeversion import AwesomeVersion
import pytest import pytest
@@ -11,6 +11,7 @@ from supervisor.const import BusEvent, CpuArch
from supervisor.coresys import CoreSys from supervisor.coresys import CoreSys
from supervisor.docker.const import ContainerState from supervisor.docker.const import ContainerState
from supervisor.docker.interface import DockerInterface from supervisor.docker.interface import DockerInterface
from supervisor.docker.manager import DockerAPI
from supervisor.docker.monitor import DockerContainerStateEvent from supervisor.docker.monitor import DockerContainerStateEvent
from supervisor.exceptions import ( from supervisor.exceptions import (
AudioError, AudioError,
@@ -359,21 +360,26 @@ async def test_load_with_incorrect_image(
plugin.version = AwesomeVersion("2024.4.0") plugin.version = AwesomeVersion("2024.4.0")
container.status = "running" 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) container.remove.assert_called_once_with(force=True, v=True)
assert coresys.docker.images.remove.call_args_list[0].kwargs == { assert coresys.docker.images.delete.call_args_list[0] == call(
"image": f"{old_image}:latest", f"{old_image}:latest",
"force": True, force=True,
} )
assert coresys.docker.images.remove.call_args_list[1].kwargs == { assert coresys.docker.images.delete.call_args_list[1] == call(
"image": f"{old_image}:2024.4.0", f"{old_image}:2024.4.0",
"force": True, force=True,
}
coresys.docker.pull_image.assert_called_once_with(
ANY, correct_image, "2024.4.0", platform="linux/amd64"
) )
assert plugin.image == correct_image assert plugin.image == correct_image

View File

@@ -1,8 +1,9 @@
"""Test fixup addon execute repair.""" """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 import pytest
from supervisor.addons.addon import Addon from supervisor.addons.addon import Addon
@@ -17,7 +18,9 @@ from supervisor.resolution.fixups.addon_execute_repair import FixupAddonExecuteR
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon): async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
"""Test fixup rebuilds addon's container.""" """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" install_addon_ssh.data["image"] = "test_image"
addon_execute_repair = FixupAddonExecuteRepair(coresys) addon_execute_repair = FixupAddonExecuteRepair(coresys)
@@ -41,7 +44,9 @@ async def test_fixup_max_auto_attempts(
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
): ):
"""Test fixup stops being auto-applied after 5 failures.""" """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" install_addon_ssh.data["image"] = "test_image"
addon_execute_repair = FixupAddonExecuteRepair(coresys) addon_execute_repair = FixupAddonExecuteRepair(coresys)
@@ -82,8 +87,6 @@ async def test_fixup_image_exists(
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
): ):
"""Test fixup dismisses if image exists.""" """Test fixup dismisses if image exists."""
docker.images.get.return_value = MagicMock()
addon_execute_repair = FixupAddonExecuteRepair(coresys) addon_execute_repair = FixupAddonExecuteRepair(coresys)
assert addon_execute_repair.auto is True assert addon_execute_repair.auto is True