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