mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-10-19 16:49:35 +00:00
Compare commits
3 Commits
docker-to-
...
remove-dep
Author | SHA1 | Date | |
---|---|---|---|
![]() |
66a3766b5a | ||
![]() |
7031a58083 | ||
![]() |
3c0e62f6ba |
1
.github/workflows/stale.yml
vendored
1
.github/workflows/stale.yml
vendored
@@ -16,7 +16,6 @@ jobs:
|
|||||||
days-before-close: 7
|
days-before-close: 7
|
||||||
stale-issue-label: "stale"
|
stale-issue-label: "stale"
|
||||||
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
|
exempt-issue-labels: "no-stale,Help%20wanted,help-wanted,pinned,rfc,security"
|
||||||
only-issue-types: "bug"
|
|
||||||
stale-issue-message: >
|
stale-issue-message: >
|
||||||
There hasn't been any activity on this issue recently. Due to the
|
There hasn't been any activity on this issue recently. Due to the
|
||||||
high number of incoming GitHub notifications, we have to clean some
|
high number of incoming GitHub notifications, we have to clean some
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
aiodns==3.5.0
|
aiodns==3.5.0
|
||||||
aiodocker==0.24.0
|
|
||||||
aiohttp==3.13.0
|
aiohttp==3.13.0
|
||||||
atomicwrites-homeassistant==1.4.1
|
atomicwrites-homeassistant==1.4.1
|
||||||
attrs==25.4.0
|
attrs==25.4.0
|
||||||
@@ -20,11 +19,11 @@ jinja2==3.1.6
|
|||||||
log-rate-limit==1.4.2
|
log-rate-limit==1.4.2
|
||||||
orjson==3.11.3
|
orjson==3.11.3
|
||||||
pulsectl==24.12.0
|
pulsectl==24.12.0
|
||||||
pyudev==0.24.4
|
pyudev==0.24.3
|
||||||
PyYAML==6.0.3
|
PyYAML==6.0.3
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
securetar==2025.2.1
|
securetar==2025.2.1
|
||||||
sentry-sdk==2.41.0
|
sentry-sdk==2.40.0
|
||||||
setuptools==80.9.0
|
setuptools==80.9.0
|
||||||
voluptuous==0.15.2
|
voluptuous==0.15.2
|
||||||
dbus-fast==2.44.5
|
dbus-fast==2.44.5
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
astroid==4.0.1
|
astroid==3.3.11
|
||||||
coverage==7.10.7
|
coverage==7.10.7
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
pre-commit==4.3.0
|
pre-commit==4.3.0
|
||||||
pylint==4.0.1
|
pylint==3.3.9
|
||||||
pytest-aiohttp==1.1.0
|
pytest-aiohttp==1.1.0
|
||||||
pytest-asyncio==0.25.2
|
pytest-asyncio==0.25.2
|
||||||
pytest-cov==7.0.0
|
pytest-cov==7.0.0
|
||||||
@@ -10,7 +10,7 @@ pytest-timeout==2.4.0
|
|||||||
pytest==8.4.2
|
pytest==8.4.2
|
||||||
ruff==0.14.0
|
ruff==0.14.0
|
||||||
time-machine==2.19.0
|
time-machine==2.19.0
|
||||||
types-docker==7.1.0.20251009
|
types-docker==7.1.0.20250916
|
||||||
types-pyyaml==6.0.12.20250915
|
types-pyyaml==6.0.12.20250915
|
||||||
types-requests==2.32.4.20250913
|
types-requests==2.32.4.20250913
|
||||||
urllib3==2.5.0
|
urllib3==2.5.0
|
||||||
|
@@ -108,8 +108,7 @@ class APISupervisor(CoreSysAttributes):
|
|||||||
ATTR_AUTO_UPDATE: self.sys_updater.auto_update,
|
ATTR_AUTO_UPDATE: self.sys_updater.auto_update,
|
||||||
ATTR_DETECT_BLOCKING_IO: BlockBusterManager.is_enabled(),
|
ATTR_DETECT_BLOCKING_IO: BlockBusterManager.is_enabled(),
|
||||||
ATTR_COUNTRY: self.sys_config.country,
|
ATTR_COUNTRY: self.sys_config.country,
|
||||||
# Depricated
|
# Deprecated
|
||||||
ATTR_WAIT_BOOT: self.sys_config.wait_boot,
|
|
||||||
ATTR_ADDONS: [
|
ATTR_ADDONS: [
|
||||||
{
|
{
|
||||||
ATTR_NAME: addon.name,
|
ATTR_NAME: addon.name,
|
||||||
@@ -123,10 +122,6 @@ class APISupervisor(CoreSysAttributes):
|
|||||||
}
|
}
|
||||||
for addon in self.sys_addons.local.values()
|
for addon in self.sys_addons.local.values()
|
||||||
],
|
],
|
||||||
ATTR_ADDONS_REPOSITORIES: [
|
|
||||||
{ATTR_NAME: store.name, ATTR_SLUG: store.slug}
|
|
||||||
for store in self.sys_store.all
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
@@ -182,20 +177,10 @@ class APISupervisor(CoreSysAttributes):
|
|||||||
self.sys_config.detect_blocking_io = False
|
self.sys_config.detect_blocking_io = False
|
||||||
BlockBusterManager.deactivate()
|
BlockBusterManager.deactivate()
|
||||||
|
|
||||||
# Deprecated
|
|
||||||
if ATTR_WAIT_BOOT in body:
|
|
||||||
self.sys_config.wait_boot = body[ATTR_WAIT_BOOT]
|
|
||||||
|
|
||||||
# Save changes before processing addons in case of errors
|
# Save changes before processing addons in case of errors
|
||||||
await self.sys_updater.save_data()
|
await self.sys_updater.save_data()
|
||||||
await self.sys_config.save_data()
|
await self.sys_config.save_data()
|
||||||
|
|
||||||
# Remove: 2022.9
|
|
||||||
if ATTR_ADDONS_REPOSITORIES in body:
|
|
||||||
await asyncio.shield(
|
|
||||||
self.sys_store.update_repositories(set(body[ATTR_ADDONS_REPOSITORIES]))
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.sys_resolution.evaluate.evaluate_system()
|
await self.sys_resolution.evaluate.evaluate_system()
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
|
@@ -9,7 +9,6 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
from attr import evolve
|
from attr import evolve
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
import docker
|
import docker
|
||||||
@@ -718,21 +717,19 @@ class DockerAddon(DockerInterface):
|
|||||||
error_message = f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}"
|
error_message = f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}"
|
||||||
raise docker.errors.DockerException(error_message)
|
raise docker.errors.DockerException(error_message)
|
||||||
|
|
||||||
return addon_image_tag, logs
|
addon_image = self.sys_docker.images.get(addon_image_tag)
|
||||||
|
|
||||||
|
return addon_image, logs
|
||||||
|
|
||||||
try:
|
try:
|
||||||
addon_image_tag, log = await self.sys_run_in_executor(build_image)
|
docker_image, log = await self.sys_run_in_executor(build_image)
|
||||||
|
|
||||||
_LOGGER.debug("Build %s:%s done: %s", self.image, version, log)
|
_LOGGER.debug("Build %s:%s done: %s", self.image, version, log)
|
||||||
|
|
||||||
# Update meta data
|
# Update meta data
|
||||||
self._meta = await self.sys_docker.images.inspect(addon_image_tag)
|
self._meta = docker_image.attrs
|
||||||
|
|
||||||
except (
|
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||||
docker.errors.DockerException,
|
|
||||||
requests.RequestException,
|
|
||||||
aiodocker.DockerError,
|
|
||||||
) as err:
|
|
||||||
_LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
|
_LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
@@ -754,8 +751,11 @@ class DockerAddon(DockerInterface):
|
|||||||
)
|
)
|
||||||
async def import_image(self, tar_file: Path) -> None:
|
async def import_image(self, tar_file: Path) -> None:
|
||||||
"""Import a tar file as image."""
|
"""Import a tar file as image."""
|
||||||
if docker_image := await self.sys_docker.import_image(tar_file):
|
docker_image = await self.sys_run_in_executor(
|
||||||
self._meta = docker_image
|
self.sys_docker.import_image, tar_file
|
||||||
|
)
|
||||||
|
if docker_image:
|
||||||
|
self._meta = docker_image.attrs
|
||||||
_LOGGER.info("Importing image %s and version %s", tar_file, self.version)
|
_LOGGER.info("Importing image %s and version %s", tar_file, self.version)
|
||||||
|
|
||||||
with suppress(DockerError):
|
with suppress(DockerError):
|
||||||
@@ -769,21 +769,17 @@ class DockerAddon(DockerInterface):
|
|||||||
version: AwesomeVersion | None = None,
|
version: AwesomeVersion | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check if old version exists and cleanup other versions of image not in use."""
|
"""Check if old version exists and cleanup other versions of image not in use."""
|
||||||
if not (use_image := image or self.image):
|
await self.sys_run_in_executor(
|
||||||
raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
|
self.sys_docker.cleanup_old_images,
|
||||||
if not (use_version := version or self.version):
|
(image := image or self.image),
|
||||||
raise DockerError("Cannot determine version from metadata!", _LOGGER.error)
|
version or self.version,
|
||||||
|
|
||||||
await self.sys_docker.cleanup_old_images(
|
|
||||||
use_image,
|
|
||||||
use_version,
|
|
||||||
{old_image} if old_image else None,
|
{old_image} if old_image else None,
|
||||||
keep_images={
|
keep_images={
|
||||||
f"{addon.image}:{addon.version}"
|
f"{addon.image}:{addon.version}"
|
||||||
for addon in self.sys_addons.installed
|
for addon in self.sys_addons.installed
|
||||||
if addon.slug != self.addon.slug
|
if addon.slug != self.addon.slug
|
||||||
and addon.image
|
and addon.image
|
||||||
and addon.image in {old_image, use_image}
|
and addon.image in {old_image, image}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
"""Init file for Supervisor Docker object."""
|
"""Init file for Supervisor Docker object."""
|
||||||
|
|
||||||
|
from collections.abc import Awaitable
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
@@ -235,12 +236,13 @@ class DockerHomeAssistant(DockerInterface):
|
|||||||
environment={ENV_TIME: self.sys_timezone},
|
environment={ENV_TIME: self.sys_timezone},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def is_initialize(self) -> bool:
|
def is_initialize(self) -> Awaitable[bool]:
|
||||||
"""Return True if Docker container exists."""
|
"""Return True if Docker container exists."""
|
||||||
if not self.sys_homeassistant.version:
|
return self.sys_run_in_executor(
|
||||||
return False
|
self.sys_docker.container_is_initialized,
|
||||||
return await self.sys_docker.container_is_initialized(
|
self.name,
|
||||||
self.name, self.image, self.sys_homeassistant.version
|
self.image,
|
||||||
|
self.sys_homeassistant.version,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _validate_trust(self, image_id: str) -> None:
|
async def _validate_trust(self, image_id: str) -> None:
|
||||||
|
@@ -6,18 +6,17 @@ from abc import ABC, abstractmethod
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from http import HTTPStatus
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from time import time
|
from time import time
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
from awesomeversion.strategy import AwesomeVersionStrategy
|
from awesomeversion.strategy import AwesomeVersionStrategy
|
||||||
import docker
|
import docker
|
||||||
from docker.models.containers import Container
|
from docker.models.containers import Container
|
||||||
|
from docker.models.images import Image
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from ..bus import EventListener
|
from ..bus import EventListener
|
||||||
@@ -36,7 +35,6 @@ from ..exceptions import (
|
|||||||
CodeNotaryUntrusted,
|
CodeNotaryUntrusted,
|
||||||
DockerAPIError,
|
DockerAPIError,
|
||||||
DockerError,
|
DockerError,
|
||||||
DockerHubRateLimitExceeded,
|
|
||||||
DockerJobError,
|
DockerJobError,
|
||||||
DockerLogOutOfOrder,
|
DockerLogOutOfOrder,
|
||||||
DockerNotFound,
|
DockerNotFound,
|
||||||
@@ -220,7 +218,7 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
if not credentials:
|
if not credentials:
|
||||||
return
|
return
|
||||||
|
|
||||||
await self.sys_run_in_executor(self.sys_docker.dockerpy.login, **credentials)
|
await self.sys_run_in_executor(self.sys_docker.docker.login, **credentials)
|
||||||
|
|
||||||
def _process_pull_image_log(
|
def _process_pull_image_log(
|
||||||
self, install_job_id: str, reference: PullLogEntry
|
self, install_job_id: str, reference: PullLogEntry
|
||||||
@@ -417,7 +415,8 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Pull new image
|
# Pull new image
|
||||||
docker_image = await self.sys_docker.pull_image(
|
docker_image = await self.sys_run_in_executor(
|
||||||
|
self.sys_docker.pull_image,
|
||||||
self.sys_jobs.current.uuid,
|
self.sys_jobs.current.uuid,
|
||||||
image,
|
image,
|
||||||
str(version),
|
str(version),
|
||||||
@@ -426,11 +425,13 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
|
|
||||||
# Validate content
|
# Validate content
|
||||||
try:
|
try:
|
||||||
await self._validate_trust(cast(str, docker_image["Id"]))
|
await self._validate_trust(cast(str, docker_image.id))
|
||||||
except CodeNotaryError:
|
except CodeNotaryError:
|
||||||
with suppress(aiodocker.DockerError, requests.RequestException):
|
with suppress(docker.errors.DockerException):
|
||||||
await self.sys_docker.images.delete(
|
await self.sys_run_in_executor(
|
||||||
f"{image}:{version!s}", force=True
|
self.sys_docker.images.remove,
|
||||||
|
image=f"{image}:{version!s}",
|
||||||
|
force=True,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -439,25 +440,22 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Tagging image %s with version %s as latest", image, version
|
"Tagging image %s with version %s as latest", image, version
|
||||||
)
|
)
|
||||||
await self.sys_docker.images.tag(
|
await self.sys_run_in_executor(docker_image.tag, image, tag="latest")
|
||||||
docker_image["Id"], image, tag="latest"
|
|
||||||
)
|
|
||||||
except docker.errors.APIError as err:
|
except docker.errors.APIError as err:
|
||||||
if err.status_code == HTTPStatus.TOO_MANY_REQUESTS:
|
if err.status_code == 429:
|
||||||
self.sys_resolution.create_issue(
|
self.sys_resolution.create_issue(
|
||||||
IssueType.DOCKER_RATELIMIT,
|
IssueType.DOCKER_RATELIMIT,
|
||||||
ContextType.SYSTEM,
|
ContextType.SYSTEM,
|
||||||
suggestions=[SuggestionType.REGISTRY_LOGIN],
|
suggestions=[SuggestionType.REGISTRY_LOGIN],
|
||||||
)
|
)
|
||||||
raise DockerHubRateLimitExceeded(_LOGGER.error) from err
|
_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 DockerError(
|
raise DockerError(
|
||||||
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
|
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
|
||||||
) from err
|
) from err
|
||||||
except (
|
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||||
aiodocker.DockerError,
|
|
||||||
docker.errors.DockerException,
|
|
||||||
requests.RequestException,
|
|
||||||
) as err:
|
|
||||||
await async_capture_exception(err)
|
await async_capture_exception(err)
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
|
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
|
||||||
@@ -476,12 +474,14 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
if listener:
|
if listener:
|
||||||
self.sys_bus.remove_listener(listener)
|
self.sys_bus.remove_listener(listener)
|
||||||
|
|
||||||
self._meta = docker_image
|
self._meta = docker_image.attrs
|
||||||
|
|
||||||
async def exists(self) -> bool:
|
async def exists(self) -> bool:
|
||||||
"""Return True if Docker image exists in local repository."""
|
"""Return True if Docker image exists in local repository."""
|
||||||
with suppress(aiodocker.DockerError, requests.RequestException):
|
with suppress(docker.errors.DockerException, requests.RequestException):
|
||||||
await self.sys_docker.images.inspect(f"{self.image}:{self.version!s}")
|
await self.sys_run_in_executor(
|
||||||
|
self.sys_docker.images.get, f"{self.image}:{self.version!s}"
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -540,11 +540,11 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
with suppress(aiodocker.DockerError, requests.RequestException):
|
with suppress(docker.errors.DockerException, requests.RequestException):
|
||||||
if not self._meta and self.image:
|
if not self._meta and self.image:
|
||||||
self._meta = await self.sys_docker.images.inspect(
|
self._meta = self.sys_docker.images.get(
|
||||||
f"{self.image}:{version!s}"
|
f"{self.image}:{version!s}"
|
||||||
)
|
).attrs
|
||||||
|
|
||||||
# Successful?
|
# Successful?
|
||||||
if not self._meta:
|
if not self._meta:
|
||||||
@@ -612,17 +612,14 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
)
|
)
|
||||||
async def remove(self, *, remove_image: bool = True) -> None:
|
async def remove(self, *, remove_image: bool = True) -> None:
|
||||||
"""Remove Docker images."""
|
"""Remove Docker images."""
|
||||||
if not self.image or not self.version:
|
|
||||||
raise DockerError(
|
|
||||||
"Cannot determine image and/or version from metadata!", _LOGGER.error
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cleanup container
|
# Cleanup container
|
||||||
with suppress(DockerError):
|
with suppress(DockerError):
|
||||||
await self.stop()
|
await self.stop()
|
||||||
|
|
||||||
if remove_image:
|
if remove_image:
|
||||||
await self.sys_docker.remove_image(self.image, self.version)
|
await self.sys_run_in_executor(
|
||||||
|
self.sys_docker.remove_image, self.image, self.version
|
||||||
|
)
|
||||||
|
|
||||||
self._meta = None
|
self._meta = None
|
||||||
|
|
||||||
@@ -644,16 +641,18 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
image_name = f"{expected_image}:{version!s}"
|
image_name = f"{expected_image}:{version!s}"
|
||||||
if self.image == expected_image:
|
if self.image == expected_image:
|
||||||
try:
|
try:
|
||||||
image = await self.sys_docker.images.inspect(image_name)
|
image: Image = await self.sys_run_in_executor(
|
||||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
self.sys_docker.images.get, image_name
|
||||||
|
)
|
||||||
|
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Could not get {image_name} for check due to: {err!s}",
|
f"Could not get {image_name} for check due to: {err!s}",
|
||||||
_LOGGER.error,
|
_LOGGER.error,
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
image_arch = f"{image['Os']}/{image['Architecture']}"
|
image_arch = f"{image.attrs['Os']}/{image.attrs['Architecture']}"
|
||||||
if "Variant" in image:
|
if "Variant" in image.attrs:
|
||||||
image_arch = f"{image_arch}/{image['Variant']}"
|
image_arch = f"{image_arch}/{image.attrs['Variant']}"
|
||||||
|
|
||||||
# If we have an image and its the right arch, all set
|
# If we have an image and its the right arch, all set
|
||||||
# It seems that newer Docker version return a variant for arm64 images.
|
# It seems that newer Docker version return a variant for arm64 images.
|
||||||
@@ -715,13 +714,11 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
version: AwesomeVersion | None = None,
|
version: AwesomeVersion | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Check if old version exists and cleanup."""
|
"""Check if old version exists and cleanup."""
|
||||||
if not (use_image := image or self.image):
|
await self.sys_run_in_executor(
|
||||||
raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
|
self.sys_docker.cleanup_old_images,
|
||||||
if not (use_version := version or self.version):
|
image or self.image,
|
||||||
raise DockerError("Cannot determine version from metadata!", _LOGGER.error)
|
version or self.version,
|
||||||
|
{old_image} if old_image else None,
|
||||||
await self.sys_docker.cleanup_old_images(
|
|
||||||
use_image, use_version, {old_image} if old_image else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Job(
|
@Job(
|
||||||
@@ -773,10 +770,10 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
"""Return latest version of local image."""
|
"""Return latest version of local image."""
|
||||||
available_version: list[AwesomeVersion] = []
|
available_version: list[AwesomeVersion] = []
|
||||||
try:
|
try:
|
||||||
for image in await self.sys_docker.images.list(
|
for image in await self.sys_run_in_executor(
|
||||||
filters=f'{{"reference": ["{self.image}"]}}'
|
self.sys_docker.images.list, self.image
|
||||||
):
|
):
|
||||||
for tag in image["RepoTags"]:
|
for tag in image.tags:
|
||||||
version = AwesomeVersion(tag.partition(":")[2])
|
version = AwesomeVersion(tag.partition(":")[2])
|
||||||
if version.strategy == AwesomeVersionStrategy.UNKNOWN:
|
if version.strategy == AwesomeVersionStrategy.UNKNOWN:
|
||||||
continue
|
continue
|
||||||
@@ -785,7 +782,7 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
if not available_version:
|
if not available_version:
|
||||||
raise ValueError()
|
raise ValueError()
|
||||||
|
|
||||||
except (aiodocker.DockerError, ValueError) as err:
|
except (docker.errors.DockerException, ValueError) as err:
|
||||||
raise DockerNotFound(
|
raise DockerNotFound(
|
||||||
f"No version found for {self.image}", _LOGGER.info
|
f"No version found for {self.image}", _LOGGER.info
|
||||||
) from err
|
) from err
|
||||||
@@ -824,10 +821,10 @@ class DockerInterface(JobGroup, ABC):
|
|||||||
async def check_trust(self) -> None:
|
async def check_trust(self) -> None:
|
||||||
"""Check trust of exists Docker image."""
|
"""Check trust of exists Docker image."""
|
||||||
try:
|
try:
|
||||||
image = await self.sys_docker.images.inspect(
|
image = await self.sys_run_in_executor(
|
||||||
f"{self.image}:{self.version!s}"
|
self.sys_docker.images.get, f"{self.image}:{self.version!s}"
|
||||||
)
|
)
|
||||||
except (aiodocker.DockerError, requests.RequestException):
|
except (docker.errors.DockerException, requests.RequestException):
|
||||||
return
|
return
|
||||||
|
|
||||||
await self._validate_trust(cast(str, image["Id"]))
|
await self._validate_trust(cast(str, image.id))
|
||||||
|
@@ -6,23 +6,20 @@ import asyncio
|
|||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from http import HTTPStatus
|
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
|
||||||
from typing import Any, Final, Self, cast
|
from typing import Any, Final, Self, cast
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
from aiodocker.images import DockerImages
|
|
||||||
import attr
|
import attr
|
||||||
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
|
||||||
from docker import errors as docker_errors
|
from docker import errors as docker_errors
|
||||||
from docker.api.client import APIClient
|
from docker.api.client import APIClient
|
||||||
from docker.client import DockerClient
|
from docker.client import DockerClient
|
||||||
|
from docker.errors import DockerException, ImageNotFound, NotFound
|
||||||
from docker.models.containers import Container, ContainerCollection
|
from docker.models.containers import Container, ContainerCollection
|
||||||
|
from docker.models.images import Image, ImageCollection
|
||||||
from docker.models.networks import Network
|
from docker.models.networks import Network
|
||||||
from docker.types.daemon import CancellableStream
|
from docker.types.daemon import CancellableStream
|
||||||
import requests
|
import requests
|
||||||
@@ -56,7 +53,6 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
MIN_SUPPORTED_DOCKER: Final = AwesomeVersion("24.0.0")
|
MIN_SUPPORTED_DOCKER: Final = AwesomeVersion("24.0.0")
|
||||||
DOCKER_NETWORK_HOST: Final = "host"
|
DOCKER_NETWORK_HOST: Final = "host"
|
||||||
RE_IMPORT_IMAGE_STREAM = re.compile(r"(^Loaded image ID: |^Loaded image: )(.+)$")
|
|
||||||
|
|
||||||
|
|
||||||
@attr.s(frozen=True)
|
@attr.s(frozen=True)
|
||||||
@@ -208,12 +204,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
def __init__(self, coresys: CoreSys):
|
def __init__(self, coresys: CoreSys):
|
||||||
"""Initialize Docker base wrapper."""
|
"""Initialize Docker base wrapper."""
|
||||||
self.coresys = coresys
|
self.coresys = coresys
|
||||||
# We keep both until we can fully refactor to aiodocker
|
self._docker: DockerClient | None = None
|
||||||
self._dockerpy: DockerClient | None = None
|
|
||||||
self.docker: aiodocker.Docker = aiodocker.Docker(
|
|
||||||
url=f"unix:/{str(SOCKET_DOCKER)}", api_version="auto"
|
|
||||||
)
|
|
||||||
|
|
||||||
self._network: DockerNetwork | None = None
|
self._network: DockerNetwork | None = None
|
||||||
self._info: DockerInfo | None = None
|
self._info: DockerInfo | None = None
|
||||||
self.config: DockerConfig = DockerConfig()
|
self.config: DockerConfig = DockerConfig()
|
||||||
@@ -221,7 +212,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
|
|
||||||
async def post_init(self) -> Self:
|
async def post_init(self) -> Self:
|
||||||
"""Post init actions that must be done in event loop."""
|
"""Post init actions that must be done in event loop."""
|
||||||
self._dockerpy = await asyncio.get_running_loop().run_in_executor(
|
self._docker = await asyncio.get_running_loop().run_in_executor(
|
||||||
None,
|
None,
|
||||||
partial(
|
partial(
|
||||||
DockerClient,
|
DockerClient,
|
||||||
@@ -230,19 +221,19 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
timeout=900,
|
timeout=900,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._info = DockerInfo.new(self.dockerpy.info())
|
self._info = DockerInfo.new(self.docker.info())
|
||||||
await self.config.read_data()
|
await self.config.read_data()
|
||||||
self._network = await DockerNetwork(self.dockerpy).post_init(
|
self._network = await DockerNetwork(self.docker).post_init(
|
||||||
self.config.enable_ipv6, self.config.mtu
|
self.config.enable_ipv6, self.config.mtu
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dockerpy(self) -> DockerClient:
|
def docker(self) -> DockerClient:
|
||||||
"""Get docker API client."""
|
"""Get docker API client."""
|
||||||
if not self._dockerpy:
|
if not self._docker:
|
||||||
raise RuntimeError("Docker API Client not initialized!")
|
raise RuntimeError("Docker API Client not initialized!")
|
||||||
return self._dockerpy
|
return self._docker
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def network(self) -> DockerNetwork:
|
def network(self) -> DockerNetwork:
|
||||||
@@ -252,19 +243,19 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
return self._network
|
return self._network
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def images(self) -> DockerImages:
|
def images(self) -> ImageCollection:
|
||||||
"""Return API images."""
|
"""Return API images."""
|
||||||
return self.docker.images
|
return self.docker.images
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def containers(self) -> ContainerCollection:
|
def containers(self) -> ContainerCollection:
|
||||||
"""Return API containers."""
|
"""Return API containers."""
|
||||||
return self.dockerpy.containers
|
return self.docker.containers
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api(self) -> APIClient:
|
def api(self) -> APIClient:
|
||||||
"""Return API containers."""
|
"""Return API containers."""
|
||||||
return self.dockerpy.api
|
return self.docker.api
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def info(self) -> DockerInfo:
|
def info(self) -> DockerInfo:
|
||||||
@@ -276,7 +267,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
@property
|
@property
|
||||||
def events(self) -> CancellableStream:
|
def events(self) -> CancellableStream:
|
||||||
"""Return docker event stream."""
|
"""Return docker event stream."""
|
||||||
return self.dockerpy.events(decode=True)
|
return self.docker.events(decode=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def monitor(self) -> DockerMonitor:
|
def monitor(self) -> DockerMonitor:
|
||||||
@@ -392,7 +383,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
with suppress(DockerError):
|
with suppress(DockerError):
|
||||||
self.network.detach_default_bridge(container)
|
self.network.detach_default_bridge(container)
|
||||||
else:
|
else:
|
||||||
host_network: Network = self.dockerpy.networks.get(DOCKER_NETWORK_HOST)
|
host_network: Network = self.docker.networks.get(DOCKER_NETWORK_HOST)
|
||||||
|
|
||||||
# Check if container is register on host
|
# Check if container is register on host
|
||||||
# https://github.com/moby/moby/issues/23302
|
# https://github.com/moby/moby/issues/23302
|
||||||
@@ -419,36 +410,35 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
|
|
||||||
return container
|
return container
|
||||||
|
|
||||||
async def pull_image(
|
def pull_image(
|
||||||
self,
|
self,
|
||||||
job_id: str,
|
job_id: str,
|
||||||
repository: str,
|
repository: str,
|
||||||
tag: str = "latest",
|
tag: str = "latest",
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> Image:
|
||||||
"""Pull the specified image and return it.
|
"""Pull the specified image and return it.
|
||||||
|
|
||||||
This mimics the high level API of images.pull but provides better error handling by raising
|
This mimics the high level API of images.pull but provides better error handling by raising
|
||||||
based on a docker error on pull. Whereas the high level API ignores all errors on pull and
|
based on a docker error on pull. Whereas the high level API ignores all errors on pull and
|
||||||
raises only if the get fails afterwards. Additionally it fires progress reports for the pull
|
raises only if the get fails afterwards. Additionally it fires progress reports for the pull
|
||||||
on the bus so listeners can use that to update status for users.
|
on the bus so listeners can use that to update status for users.
|
||||||
|
|
||||||
|
Must be run in executor.
|
||||||
"""
|
"""
|
||||||
|
pull_log = self.docker.api.pull(
|
||||||
def api_pull():
|
repository, tag=tag, platform=platform, stream=True, decode=True
|
||||||
pull_log = self.dockerpy.api.pull(
|
)
|
||||||
repository, tag=tag, platform=platform, stream=True, decode=True
|
for e in pull_log:
|
||||||
|
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
|
||||||
)
|
)
|
||||||
for e in pull_log:
|
|
||||||
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 self.sys_run_in_executor(api_pull)
|
|
||||||
sep = "@" if tag.startswith("sha256:") else ":"
|
sep = "@" if tag.startswith("sha256:") else ":"
|
||||||
return await self.images.inspect(f"{repository}{sep}{tag}")
|
return self.images.get(f"{repository}{sep}{tag}")
|
||||||
|
|
||||||
def run_command(
|
def run_command(
|
||||||
self,
|
self,
|
||||||
@@ -469,7 +459,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
_LOGGER.info("Runing command '%s' on %s", command, image_with_tag)
|
_LOGGER.info("Runing command '%s' on %s", command, image_with_tag)
|
||||||
container = None
|
container = None
|
||||||
try:
|
try:
|
||||||
container = self.dockerpy.containers.run(
|
container = self.docker.containers.run(
|
||||||
image_with_tag,
|
image_with_tag,
|
||||||
command=command,
|
command=command,
|
||||||
detach=True,
|
detach=True,
|
||||||
@@ -497,35 +487,35 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
"""Repair local docker overlayfs2 issues."""
|
"""Repair local docker overlayfs2 issues."""
|
||||||
_LOGGER.info("Prune stale containers")
|
_LOGGER.info("Prune stale containers")
|
||||||
try:
|
try:
|
||||||
output = self.dockerpy.api.prune_containers()
|
output = self.docker.api.prune_containers()
|
||||||
_LOGGER.debug("Containers prune: %s", output)
|
_LOGGER.debug("Containers prune: %s", output)
|
||||||
except docker_errors.APIError as err:
|
except docker_errors.APIError as err:
|
||||||
_LOGGER.warning("Error for containers prune: %s", err)
|
_LOGGER.warning("Error for containers prune: %s", err)
|
||||||
|
|
||||||
_LOGGER.info("Prune stale images")
|
_LOGGER.info("Prune stale images")
|
||||||
try:
|
try:
|
||||||
output = self.dockerpy.api.prune_images(filters={"dangling": False})
|
output = self.docker.api.prune_images(filters={"dangling": False})
|
||||||
_LOGGER.debug("Images prune: %s", output)
|
_LOGGER.debug("Images prune: %s", output)
|
||||||
except docker_errors.APIError as err:
|
except docker_errors.APIError as err:
|
||||||
_LOGGER.warning("Error for images prune: %s", err)
|
_LOGGER.warning("Error for images prune: %s", err)
|
||||||
|
|
||||||
_LOGGER.info("Prune stale builds")
|
_LOGGER.info("Prune stale builds")
|
||||||
try:
|
try:
|
||||||
output = self.dockerpy.api.prune_builds()
|
output = self.docker.api.prune_builds()
|
||||||
_LOGGER.debug("Builds prune: %s", output)
|
_LOGGER.debug("Builds prune: %s", output)
|
||||||
except docker_errors.APIError as err:
|
except docker_errors.APIError as err:
|
||||||
_LOGGER.warning("Error for builds prune: %s", err)
|
_LOGGER.warning("Error for builds prune: %s", err)
|
||||||
|
|
||||||
_LOGGER.info("Prune stale volumes")
|
_LOGGER.info("Prune stale volumes")
|
||||||
try:
|
try:
|
||||||
output = self.dockerpy.api.prune_volumes()
|
output = self.docker.api.prune_builds()
|
||||||
_LOGGER.debug("Volumes prune: %s", output)
|
_LOGGER.debug("Volumes prune: %s", output)
|
||||||
except docker_errors.APIError as err:
|
except docker_errors.APIError as err:
|
||||||
_LOGGER.warning("Error for volumes prune: %s", err)
|
_LOGGER.warning("Error for volumes prune: %s", err)
|
||||||
|
|
||||||
_LOGGER.info("Prune stale networks")
|
_LOGGER.info("Prune stale networks")
|
||||||
try:
|
try:
|
||||||
output = self.dockerpy.api.prune_networks()
|
output = self.docker.api.prune_networks()
|
||||||
_LOGGER.debug("Networks prune: %s", output)
|
_LOGGER.debug("Networks prune: %s", output)
|
||||||
except docker_errors.APIError as err:
|
except docker_errors.APIError as err:
|
||||||
_LOGGER.warning("Error for networks prune: %s", err)
|
_LOGGER.warning("Error for networks prune: %s", err)
|
||||||
@@ -547,11 +537,11 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
|
|
||||||
Fix: https://github.com/moby/moby/issues/23302
|
Fix: https://github.com/moby/moby/issues/23302
|
||||||
"""
|
"""
|
||||||
network: Network = self.dockerpy.networks.get(network_name)
|
network: Network = self.docker.networks.get(network_name)
|
||||||
|
|
||||||
for cid, data in network.attrs.get("Containers", {}).items():
|
for cid, data in network.attrs.get("Containers", {}).items():
|
||||||
try:
|
try:
|
||||||
self.dockerpy.containers.get(cid)
|
self.docker.containers.get(cid)
|
||||||
continue
|
continue
|
||||||
except docker_errors.NotFound:
|
except docker_errors.NotFound:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
@@ -566,26 +556,22 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
with suppress(docker_errors.DockerException, requests.RequestException):
|
with suppress(docker_errors.DockerException, requests.RequestException):
|
||||||
network.disconnect(data.get("Name", cid), force=True)
|
network.disconnect(data.get("Name", cid), force=True)
|
||||||
|
|
||||||
async def container_is_initialized(
|
def container_is_initialized(
|
||||||
self, name: str, image: str, version: AwesomeVersion
|
self, name: str, image: str, version: AwesomeVersion
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True if docker container exists in good state and is built from expected image."""
|
"""Return True if docker container exists in good state and is built from expected image."""
|
||||||
try:
|
try:
|
||||||
docker_container = await self.sys_run_in_executor(self.containers.get, name)
|
docker_container = self.containers.get(name)
|
||||||
docker_image = await self.images.inspect(f"{image}:{version}")
|
docker_image = self.images.get(f"{image}:{version}")
|
||||||
except docker_errors.NotFound:
|
except NotFound:
|
||||||
return False
|
return False
|
||||||
except aiodocker.DockerError as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
if err.status == HTTPStatus.NOT_FOUND:
|
|
||||||
return False
|
|
||||||
raise DockerError() from err
|
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
# Check the image is correct and state is good
|
# Check the image is correct and state is good
|
||||||
return (
|
return (
|
||||||
docker_container.image is not None
|
docker_container.image is not None
|
||||||
and docker_container.image.id == docker_image["Id"]
|
and docker_container.image.id == docker_image.id
|
||||||
and docker_container.status in ("exited", "running", "created")
|
and docker_container.status in ("exited", "running", "created")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -595,18 +581,18 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
"""Stop/remove Docker container."""
|
"""Stop/remove Docker container."""
|
||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except NotFound:
|
||||||
raise DockerNotFound() from None
|
raise DockerNotFound() from None
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
if docker_container.status == "running":
|
if docker_container.status == "running":
|
||||||
_LOGGER.info("Stopping %s application", name)
|
_LOGGER.info("Stopping %s application", name)
|
||||||
with suppress(docker_errors.DockerException, requests.RequestException):
|
with suppress(DockerException, requests.RequestException):
|
||||||
docker_container.stop(timeout=timeout)
|
docker_container.stop(timeout=timeout)
|
||||||
|
|
||||||
if remove_container:
|
if remove_container:
|
||||||
with suppress(docker_errors.DockerException, requests.RequestException):
|
with suppress(DockerException, requests.RequestException):
|
||||||
_LOGGER.info("Cleaning %s application", name)
|
_LOGGER.info("Cleaning %s application", name)
|
||||||
docker_container.remove(force=True, v=True)
|
docker_container.remove(force=True, v=True)
|
||||||
|
|
||||||
@@ -618,11 +604,11 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
"""Start Docker container."""
|
"""Start Docker container."""
|
||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except NotFound:
|
||||||
raise DockerNotFound(
|
raise DockerNotFound(
|
||||||
f"{name} not found for starting up", _LOGGER.error
|
f"{name} not found for starting up", _LOGGER.error
|
||||||
) from None
|
) from None
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Could not get {name} for starting up", _LOGGER.error
|
f"Could not get {name} for starting up", _LOGGER.error
|
||||||
) from err
|
) from err
|
||||||
@@ -630,36 +616,36 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
_LOGGER.info("Starting %s", name)
|
_LOGGER.info("Starting %s", name)
|
||||||
try:
|
try:
|
||||||
docker_container.start()
|
docker_container.start()
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(f"Can't start {name}: {err}", _LOGGER.error) from err
|
raise DockerError(f"Can't start {name}: {err}", _LOGGER.error) from err
|
||||||
|
|
||||||
def restart_container(self, name: str, timeout: int) -> None:
|
def restart_container(self, name: str, timeout: int) -> None:
|
||||||
"""Restart docker container."""
|
"""Restart docker container."""
|
||||||
try:
|
try:
|
||||||
container: Container = self.containers.get(name)
|
container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except NotFound:
|
||||||
raise DockerNotFound() from None
|
raise DockerNotFound() from None
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
_LOGGER.info("Restarting %s", name)
|
_LOGGER.info("Restarting %s", name)
|
||||||
try:
|
try:
|
||||||
container.restart(timeout=timeout)
|
container.restart(timeout=timeout)
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(f"Can't restart {name}: {err}", _LOGGER.warning) from err
|
raise DockerError(f"Can't restart {name}: {err}", _LOGGER.warning) from err
|
||||||
|
|
||||||
def container_logs(self, name: str, tail: int = 100) -> bytes:
|
def container_logs(self, name: str, tail: int = 100) -> bytes:
|
||||||
"""Return Docker logs of container."""
|
"""Return Docker logs of container."""
|
||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except NotFound:
|
||||||
raise DockerNotFound() from None
|
raise DockerNotFound() from None
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return docker_container.logs(tail=tail, stdout=True, stderr=True)
|
return docker_container.logs(tail=tail, stdout=True, stderr=True)
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Can't grep logs from {name}: {err}", _LOGGER.warning
|
f"Can't grep logs from {name}: {err}", _LOGGER.warning
|
||||||
) from err
|
) from err
|
||||||
@@ -668,9 +654,9 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
"""Read and return stats from container."""
|
"""Read and return stats from container."""
|
||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except NotFound:
|
||||||
raise DockerNotFound() from None
|
raise DockerNotFound() from None
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
# container is not running
|
# container is not running
|
||||||
@@ -679,7 +665,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
return docker_container.stats(stream=False)
|
return docker_container.stats(stream=False)
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Can't read stats from {name}: {err}", _LOGGER.error
|
f"Can't read stats from {name}: {err}", _LOGGER.error
|
||||||
) from err
|
) from err
|
||||||
@@ -688,84 +674,61 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
"""Execute a command inside Docker container."""
|
"""Execute a command inside Docker container."""
|
||||||
try:
|
try:
|
||||||
docker_container: Container = self.containers.get(name)
|
docker_container: Container = self.containers.get(name)
|
||||||
except docker_errors.NotFound:
|
except NotFound:
|
||||||
raise DockerNotFound() from None
|
raise DockerNotFound() from None
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
# Execute
|
# Execute
|
||||||
try:
|
try:
|
||||||
code, output = docker_container.exec_run(command)
|
code, output = docker_container.exec_run(command)
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError() from err
|
raise DockerError() from err
|
||||||
|
|
||||||
return CommandReturn(code, output)
|
return CommandReturn(code, output)
|
||||||
|
|
||||||
async def remove_image(
|
def remove_image(
|
||||||
self, image: str, version: AwesomeVersion, latest: bool = True
|
self, image: str, version: AwesomeVersion, latest: bool = True
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Remove a Docker image by version and latest."""
|
"""Remove a Docker image by version and latest."""
|
||||||
try:
|
try:
|
||||||
if latest:
|
if latest:
|
||||||
_LOGGER.info("Removing image %s with latest", image)
|
_LOGGER.info("Removing image %s with latest", image)
|
||||||
try:
|
with suppress(ImageNotFound):
|
||||||
await self.images.delete(f"{image}:latest", force=True)
|
self.images.remove(image=f"{image}:latest", force=True)
|
||||||
except aiodocker.DockerError as err:
|
|
||||||
if err.status != HTTPStatus.NOT_FOUND:
|
|
||||||
raise
|
|
||||||
|
|
||||||
_LOGGER.info("Removing image %s with %s", image, version)
|
_LOGGER.info("Removing image %s with %s", image, version)
|
||||||
try:
|
with suppress(ImageNotFound):
|
||||||
await self.images.delete(f"{image}:{version!s}", force=True)
|
self.images.remove(image=f"{image}:{version!s}", force=True)
|
||||||
except aiodocker.DockerError as err:
|
|
||||||
if err.status != HTTPStatus.NOT_FOUND:
|
|
||||||
raise
|
|
||||||
|
|
||||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Can't remove image {image}: {err}", _LOGGER.warning
|
f"Can't remove image {image}: {err}", _LOGGER.warning
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
async def import_image(self, tar_file: Path) -> dict[str, Any] | None:
|
def import_image(self, tar_file: Path) -> Image | None:
|
||||||
"""Import a tar file as image."""
|
"""Import a tar file as image."""
|
||||||
try:
|
try:
|
||||||
with tar_file.open("rb") as read_tar:
|
with tar_file.open("rb") as read_tar:
|
||||||
resp: list[dict[str, Any]] = self.images.import_image(read_tar)
|
docker_image_list: list[Image] = self.images.load(read_tar) # type: ignore
|
||||||
except (aiodocker.DockerError, OSError) as err:
|
|
||||||
|
if len(docker_image_list) != 1:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Unexpected image count %d while importing image from tar",
|
||||||
|
len(docker_image_list),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return docker_image_list[0]
|
||||||
|
except (DockerException, OSError) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Can't import image from tar: {err}", _LOGGER.error
|
f"Can't import image from tar: {err}", _LOGGER.error
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
docker_image_list: list[str] = []
|
|
||||||
for chunk in resp:
|
|
||||||
if "errorDetail" in chunk:
|
|
||||||
raise DockerError(
|
|
||||||
f"Can't import image from tar: {chunk['errorDetail']['message']}",
|
|
||||||
_LOGGER.error,
|
|
||||||
)
|
|
||||||
if "stream" in chunk:
|
|
||||||
if match := RE_IMPORT_IMAGE_STREAM.search(chunk["stream"]):
|
|
||||||
docker_image_list.append(match.group(2))
|
|
||||||
|
|
||||||
if len(docker_image_list) != 1:
|
|
||||||
_LOGGER.warning(
|
|
||||||
"Unexpected image count %d while importing image from tar",
|
|
||||||
len(docker_image_list),
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return await self.images.inspect(docker_image_list[0])
|
|
||||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
|
||||||
raise DockerError(
|
|
||||||
f"Could not inspect imported image due to: {err!s}", _LOGGER.error
|
|
||||||
) from err
|
|
||||||
|
|
||||||
def export_image(self, image: str, version: AwesomeVersion, tar_file: Path) -> None:
|
def export_image(self, image: str, version: AwesomeVersion, tar_file: Path) -> None:
|
||||||
"""Export current images into a tar file."""
|
"""Export current images into a tar file."""
|
||||||
try:
|
try:
|
||||||
docker_image = self.api.get_image(f"{image}:{version}")
|
docker_image = self.api.get_image(f"{image}:{version}")
|
||||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Can't fetch image {image}: {err}", _LOGGER.error
|
f"Can't fetch image {image}: {err}", _LOGGER.error
|
||||||
) from err
|
) from err
|
||||||
@@ -782,7 +745,7 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
|
|
||||||
_LOGGER.info("Export image %s done", image)
|
_LOGGER.info("Export image %s done", image)
|
||||||
|
|
||||||
async def cleanup_old_images(
|
def cleanup_old_images(
|
||||||
self,
|
self,
|
||||||
current_image: str,
|
current_image: str,
|
||||||
current_version: AwesomeVersion,
|
current_version: AwesomeVersion,
|
||||||
@@ -793,57 +756,46 @@ class DockerAPI(CoreSysAttributes):
|
|||||||
"""Clean up old versions of an image."""
|
"""Clean up old versions of an image."""
|
||||||
image = f"{current_image}:{current_version!s}"
|
image = f"{current_image}:{current_version!s}"
|
||||||
try:
|
try:
|
||||||
try:
|
keep = {cast(str, self.images.get(image).id)}
|
||||||
image_attr = await self.images.inspect(image)
|
except ImageNotFound:
|
||||||
except aiodocker.DockerError as err:
|
raise DockerNotFound(
|
||||||
if err.status == HTTPStatus.NOT_FOUND:
|
f"{current_image} not found for cleanup", _LOGGER.warning
|
||||||
raise DockerNotFound(
|
) from None
|
||||||
f"{current_image} not found for cleanup", _LOGGER.warning
|
except (DockerException, requests.RequestException) as err:
|
||||||
) from None
|
|
||||||
raise
|
|
||||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Can't get {current_image} for cleanup", _LOGGER.warning
|
f"Can't get {current_image} for cleanup", _LOGGER.warning
|
||||||
) from err
|
) from err
|
||||||
keep = {cast(str, image_attr["Id"])}
|
|
||||||
|
|
||||||
if keep_images:
|
if keep_images:
|
||||||
keep_images -= {image}
|
keep_images -= {image}
|
||||||
results = await asyncio.gather(
|
try:
|
||||||
*[self.images.inspect(image) for image in keep_images],
|
for image in keep_images:
|
||||||
return_exceptions=True,
|
# If its not found, no need to preserve it from getting removed
|
||||||
)
|
with suppress(ImageNotFound):
|
||||||
for result in results:
|
keep.add(cast(str, self.images.get(image).id))
|
||||||
# If its not found, no need to preserve it from getting removed
|
except (DockerException, requests.RequestException) as err:
|
||||||
if (
|
raise DockerError(
|
||||||
isinstance(result, aiodocker.DockerError)
|
f"Failed to get one or more images from {keep} during cleanup",
|
||||||
and result.status == HTTPStatus.NOT_FOUND
|
_LOGGER.warning,
|
||||||
):
|
) from err
|
||||||
continue
|
|
||||||
if isinstance(result, BaseException):
|
|
||||||
raise DockerError(
|
|
||||||
f"Failed to get one or more images from {keep} during cleanup",
|
|
||||||
_LOGGER.warning,
|
|
||||||
) from result
|
|
||||||
keep.add(cast(str, result["Id"]))
|
|
||||||
|
|
||||||
# Cleanup old and current
|
# Cleanup old and current
|
||||||
image_names = list(
|
image_names = list(
|
||||||
old_images | {current_image} if old_images else {current_image}
|
old_images | {current_image} if old_images else {current_image}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
images_list = await self.images.list(
|
# This API accepts a list of image names. Tested and confirmed working on docker==7.1.0
|
||||||
filters=json.dumps({"reference": image_names})
|
# 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 (aiodocker.DockerError, requests.RequestException) as err:
|
except (DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Corrupt docker overlayfs found: {err}", _LOGGER.warning
|
f"Corrupt docker overlayfs found: {err}", _LOGGER.warning
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
for docker_image in images_list:
|
for docker_image in images_list:
|
||||||
if docker_image["Id"] in keep:
|
if docker_image.id in keep:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with suppress(aiodocker.DockerError, requests.RequestException):
|
with suppress(DockerException, requests.RequestException):
|
||||||
_LOGGER.info("Cleanup images: %s", docker_image["RepoTags"])
|
_LOGGER.info("Cleanup images: %s", docker_image.tags)
|
||||||
await self.images.delete(docker_image["Id"], force=True)
|
self.images.remove(docker_image.id, force=True)
|
||||||
|
@@ -1,12 +1,10 @@
|
|||||||
"""Init file for Supervisor Docker object."""
|
"""Init file for Supervisor Docker object."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from ipaddress import IPv4Address
|
from ipaddress import IPv4Address
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
from awesomeversion.awesomeversion import AwesomeVersion
|
from awesomeversion.awesomeversion import AwesomeVersion
|
||||||
import docker
|
import docker
|
||||||
import requests
|
import requests
|
||||||
@@ -114,18 +112,19 @@ class DockerSupervisor(DockerInterface):
|
|||||||
name="docker_supervisor_update_start_tag",
|
name="docker_supervisor_update_start_tag",
|
||||||
concurrency=JobConcurrency.GROUP_QUEUE,
|
concurrency=JobConcurrency.GROUP_QUEUE,
|
||||||
)
|
)
|
||||||
async def update_start_tag(self, image: str, version: AwesomeVersion) -> None:
|
def update_start_tag(self, image: str, version: AwesomeVersion) -> Awaitable[None]:
|
||||||
"""Update start tag to new version."""
|
"""Update start tag to new version."""
|
||||||
|
return self.sys_run_in_executor(self._update_start_tag, image, version)
|
||||||
|
|
||||||
|
def _update_start_tag(self, image: str, version: AwesomeVersion) -> None:
|
||||||
|
"""Update start tag to new version.
|
||||||
|
|
||||||
|
Need run inside executor.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
docker_container = await self.sys_run_in_executor(
|
docker_container = self.sys_docker.containers.get(self.name)
|
||||||
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_image = await self.sys_docker.images.inspect(f"{image}:{version!s}")
|
|
||||||
except (
|
|
||||||
aiodocker.DockerError,
|
|
||||||
docker.errors.DockerException,
|
|
||||||
requests.RequestException,
|
|
||||||
) as err:
|
|
||||||
raise DockerError(
|
raise DockerError(
|
||||||
f"Can't get image or container to fix start tag: {err}", _LOGGER.error
|
f"Can't get image or container to fix start tag: {err}", _LOGGER.error
|
||||||
) from err
|
) from err
|
||||||
@@ -145,14 +144,8 @@ class DockerSupervisor(DockerInterface):
|
|||||||
# If version tag
|
# If version tag
|
||||||
if start_tag != "latest":
|
if start_tag != "latest":
|
||||||
continue
|
continue
|
||||||
await asyncio.gather(
|
docker_image.tag(start_image, start_tag)
|
||||||
self.sys_docker.images.tag(
|
docker_image.tag(start_image, version.string)
|
||||||
docker_image["Id"], start_image, tag=start_tag
|
|
||||||
),
|
|
||||||
self.sys_docker.images.tag(
|
|
||||||
docker_image["Id"], start_image, tag=version.string
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
except (aiodocker.DockerError, requests.RequestException) as err:
|
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||||
raise DockerError(f"Can't fix start tag: {err}", _LOGGER.error) from err
|
raise DockerError(f"Can't fix start tag: {err}", _LOGGER.error) from err
|
||||||
|
@@ -648,32 +648,9 @@ class DockerLogOutOfOrder(DockerError):
|
|||||||
class DockerNoSpaceOnDevice(DockerError):
|
class DockerNoSpaceOnDevice(DockerError):
|
||||||
"""Raise if a docker pull fails due to available space."""
|
"""Raise if a docker pull fails due to available space."""
|
||||||
|
|
||||||
error_key = "docker_no_space_on_device"
|
|
||||||
message_template = "No space left on disk"
|
|
||||||
|
|
||||||
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
||||||
"""Raise & log."""
|
"""Raise & log."""
|
||||||
super().__init__(None, logger=logger)
|
super().__init__("No space left on disk", logger=logger)
|
||||||
|
|
||||||
|
|
||||||
class DockerHubRateLimitExceeded(DockerError):
|
|
||||||
"""Raise for docker hub rate limit exceeded error."""
|
|
||||||
|
|
||||||
error_key = "dockerhub_rate_limit_exceeded"
|
|
||||||
message_template = (
|
|
||||||
"Your IP address has made too many requests to Docker Hub which activated a rate limit. "
|
|
||||||
"For more details see {dockerhub_rate_limit_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(self, logger: Callable[..., None] | None = None) -> None:
|
|
||||||
"""Raise & log."""
|
|
||||||
super().__init__(
|
|
||||||
None,
|
|
||||||
logger=logger,
|
|
||||||
extra_fields={
|
|
||||||
"dockerhub_rate_limit_url": "https://www.home-assistant.io/more-info/dockerhub-rate-limit"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DockerJobError(DockerError, JobException):
|
class DockerJobError(DockerError, JobException):
|
||||||
|
@@ -8,7 +8,7 @@ from ..const import UnsupportedReason
|
|||||||
from .base import EvaluateBase
|
from .base import EvaluateBase
|
||||||
|
|
||||||
EXPECTED_LOGGING = "journald"
|
EXPECTED_LOGGING = "journald"
|
||||||
EXPECTED_STORAGE = ("overlay2", "overlayfs")
|
EXPECTED_STORAGE = "overlay2"
|
||||||
|
|
||||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,18 +41,14 @@ class EvaluateDockerConfiguration(EvaluateBase):
|
|||||||
storage_driver = self.sys_docker.info.storage
|
storage_driver = self.sys_docker.info.storage
|
||||||
logging_driver = self.sys_docker.info.logging
|
logging_driver = self.sys_docker.info.logging
|
||||||
|
|
||||||
is_unsupported = False
|
if storage_driver != EXPECTED_STORAGE:
|
||||||
|
|
||||||
if storage_driver not in EXPECTED_STORAGE:
|
|
||||||
is_unsupported = True
|
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Docker storage driver %s is not supported!", storage_driver
|
"Docker storage driver %s is not supported!", storage_driver
|
||||||
)
|
)
|
||||||
|
|
||||||
if logging_driver != EXPECTED_LOGGING:
|
if logging_driver != EXPECTED_LOGGING:
|
||||||
is_unsupported = True
|
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Docker logging driver %s is not supported!", logging_driver
|
"Docker logging driver %s is not supported!", logging_driver
|
||||||
)
|
)
|
||||||
|
|
||||||
return is_unsupported
|
return storage_driver != EXPECTED_STORAGE or logging_driver != EXPECTED_LOGGING
|
||||||
|
@@ -3,25 +3,22 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import errno
|
import errno
|
||||||
from http import HTTPStatus
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, PropertyMock, call, patch
|
from unittest.mock import MagicMock, PropertyMock, patch
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
from docker.errors import DockerException, NotFound
|
from docker.errors import DockerException, ImageNotFound, NotFound
|
||||||
import pytest
|
import pytest
|
||||||
from securetar import SecureTarFile
|
from securetar import SecureTarFile
|
||||||
|
|
||||||
from supervisor.addons.addon import Addon
|
from supervisor.addons.addon import Addon
|
||||||
from supervisor.addons.const import AddonBackupMode
|
from supervisor.addons.const import AddonBackupMode
|
||||||
from supervisor.addons.model import AddonModel
|
from supervisor.addons.model import AddonModel
|
||||||
from supervisor.config import CoreConfig
|
|
||||||
from supervisor.const import AddonBoot, AddonState, BusEvent
|
from supervisor.const import AddonBoot, AddonState, BusEvent
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.docker.addon import DockerAddon
|
from supervisor.docker.addon import DockerAddon
|
||||||
from supervisor.docker.const import ContainerState
|
from supervisor.docker.const import ContainerState
|
||||||
from supervisor.docker.manager import CommandReturn, DockerAPI
|
from supervisor.docker.manager import CommandReturn
|
||||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||||
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
|
from supervisor.exceptions import AddonsError, AddonsJobError, AudioUpdateError
|
||||||
from supervisor.hardware.helper import HwHelper
|
from supervisor.hardware.helper import HwHelper
|
||||||
@@ -864,14 +861,16 @@ async def test_addon_loads_wrong_image(
|
|||||||
|
|
||||||
container.remove.assert_called_with(force=True, v=True)
|
container.remove.assert_called_with(force=True, v=True)
|
||||||
# one for removing the addon, one for removing the addon builder
|
# one for removing the addon, one for removing the addon builder
|
||||||
assert coresys.docker.images.delete.call_count == 2
|
assert coresys.docker.images.remove.call_count == 2
|
||||||
|
|
||||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||||
"local/aarch64-addon-ssh:latest", force=True
|
"image": "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
|
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||||
)
|
"image": "local/aarch64-addon-ssh:9.2.1",
|
||||||
|
"force": True,
|
||||||
|
}
|
||||||
mock_run_command.assert_called_once()
|
mock_run_command.assert_called_once()
|
||||||
assert mock_run_command.call_args.args[0] == "docker.io/library/docker"
|
assert mock_run_command.call_args.args[0] == "docker.io/library/docker"
|
||||||
assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli"
|
assert mock_run_command.call_args.kwargs["version"] == "1.0.0-cli"
|
||||||
@@ -895,9 +894,7 @@ async def test_addon_loads_missing_image(
|
|||||||
mock_amd64_arch_supported,
|
mock_amd64_arch_supported,
|
||||||
):
|
):
|
||||||
"""Test addon corrects a missing image on load."""
|
"""Test addon corrects a missing image on load."""
|
||||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
|
||||||
)
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("pathlib.Path.is_file", return_value=True),
|
patch("pathlib.Path.is_file", return_value=True),
|
||||||
@@ -929,50 +926,40 @@ async def test_addon_loads_missing_image(
|
|||||||
assert install_addon_ssh.image == "local/amd64-addon-ssh"
|
assert install_addon_ssh.image == "local/amd64-addon-ssh"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"pull_image_exc",
|
|
||||||
[DockerException(), aiodocker.DockerError(400, {"message": "error"})],
|
|
||||||
)
|
|
||||||
@pytest.mark.usefixtures("container", "mock_amd64_arch_supported")
|
|
||||||
async def test_addon_load_succeeds_with_docker_errors(
|
async def test_addon_load_succeeds_with_docker_errors(
|
||||||
coresys: CoreSys,
|
coresys: CoreSys,
|
||||||
install_addon_ssh: Addon,
|
install_addon_ssh: Addon,
|
||||||
|
container: MagicMock,
|
||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
pull_image_exc: Exception,
|
mock_amd64_arch_supported,
|
||||||
):
|
):
|
||||||
"""Docker errors while building/pulling an image during load should not raise and fail setup."""
|
"""Docker errors while building/pulling an image during load should not raise and fail setup."""
|
||||||
# Build env invalid failure
|
# Build env invalid failure
|
||||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
coresys.docker.images.get.side_effect = ImageNotFound("missing")
|
||||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
|
||||||
)
|
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
await install_addon_ssh.load()
|
await install_addon_ssh.load()
|
||||||
assert "Invalid build environment" in caplog.text
|
assert "Invalid build environment" in caplog.text
|
||||||
|
|
||||||
# Image build failure
|
# Image build failure
|
||||||
|
coresys.docker.images.build.side_effect = DockerException()
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
with (
|
with (
|
||||||
patch("pathlib.Path.is_file", return_value=True),
|
patch("pathlib.Path.is_file", return_value=True),
|
||||||
patch.object(
|
patch.object(
|
||||||
CoreConfig, "local_to_extern_path", return_value="/addon/path/on/host"
|
type(coresys.config),
|
||||||
),
|
"local_to_extern_path",
|
||||||
patch.object(
|
return_value="/addon/path/on/host",
|
||||||
DockerAPI,
|
|
||||||
"run_command",
|
|
||||||
return_value=MagicMock(exit_code=1, output=b"error"),
|
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
await install_addon_ssh.load()
|
await install_addon_ssh.load()
|
||||||
assert (
|
assert "Can't build local/amd64-addon-ssh:9.2.1" in caplog.text
|
||||||
"Can't build local/amd64-addon-ssh:9.2.1: Docker build failed for local/amd64-addon-ssh:9.2.1 (exit code 1). Build output:\nerror"
|
|
||||||
in caplog.text
|
|
||||||
)
|
|
||||||
|
|
||||||
# Image pull failure
|
# Image pull failure
|
||||||
install_addon_ssh.data["image"] = "test/amd64-addon-ssh"
|
install_addon_ssh.data["image"] = "test/amd64-addon-ssh"
|
||||||
|
coresys.docker.images.build.reset_mock(side_effect=True)
|
||||||
|
coresys.docker.pull_image.side_effect = DockerException()
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
with patch.object(DockerAPI, "pull_image", side_effect=pull_image_exc):
|
await install_addon_ssh.load()
|
||||||
await install_addon_ssh.load()
|
|
||||||
assert "Unknown error with test/amd64-addon-ssh:9.2.1" in caplog.text
|
assert "Unknown error with test/amd64-addon-ssh:9.2.1" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
from collections.abc import AsyncGenerator, Generator
|
from collections.abc import AsyncGenerator, Generator
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||||
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
import pytest
|
import pytest
|
||||||
@@ -514,13 +514,19 @@ async def test_shared_image_kept_on_uninstall(
|
|||||||
latest = f"{install_addon_example.image}:latest"
|
latest = f"{install_addon_example.image}:latest"
|
||||||
|
|
||||||
await coresys.addons.uninstall("local_example2")
|
await coresys.addons.uninstall("local_example2")
|
||||||
coresys.docker.images.delete.assert_not_called()
|
coresys.docker.images.remove.assert_not_called()
|
||||||
assert not coresys.addons.get("local_example2", local_only=True)
|
assert not coresys.addons.get("local_example2", local_only=True)
|
||||||
|
|
||||||
await coresys.addons.uninstall("local_example")
|
await coresys.addons.uninstall("local_example")
|
||||||
assert coresys.docker.images.delete.call_count == 2
|
assert coresys.docker.images.remove.call_count == 2
|
||||||
assert coresys.docker.images.delete.call_args_list[0] == call(latest, force=True)
|
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||||
assert coresys.docker.images.delete.call_args_list[1] == call(image, force=True)
|
"image": latest,
|
||||||
|
"force": True,
|
||||||
|
}
|
||||||
|
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||||
|
"image": image,
|
||||||
|
"force": True,
|
||||||
|
}
|
||||||
assert not coresys.addons.get("local_example", local_only=True)
|
assert not coresys.addons.get("local_example", local_only=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -548,17 +554,19 @@ async def test_shared_image_kept_on_update(
|
|||||||
assert example_2.version == "1.2.0"
|
assert example_2.version == "1.2.0"
|
||||||
assert install_addon_example_image.version == "1.2.0"
|
assert install_addon_example_image.version == "1.2.0"
|
||||||
|
|
||||||
image_new = {"Id": "image_new", "RepoTags": ["image_new:latest"]}
|
image_new = MagicMock()
|
||||||
image_old = {"Id": "image_old", "RepoTags": ["image_old:latest"]}
|
image_new.id = "image_new"
|
||||||
docker.images.inspect.side_effect = [image_new, image_old]
|
image_old = MagicMock()
|
||||||
|
image_old.id = "image_old"
|
||||||
|
docker.images.get.side_effect = [image_new, image_old]
|
||||||
docker.images.list.return_value = [image_new, image_old]
|
docker.images.list.return_value = [image_new, image_old]
|
||||||
|
|
||||||
with patch.object(DockerAPI, "pull_image", return_value=image_new):
|
with patch.object(DockerAPI, "pull_image", return_value=image_new):
|
||||||
await coresys.addons.update("local_example2")
|
await coresys.addons.update("local_example2")
|
||||||
docker.images.delete.assert_not_called()
|
docker.images.remove.assert_not_called()
|
||||||
assert example_2.version == "1.3.0"
|
assert example_2.version == "1.3.0"
|
||||||
|
|
||||||
docker.images.inspect.side_effect = [image_new]
|
docker.images.get.side_effect = [image_new]
|
||||||
await coresys.addons.update("local_example_image")
|
await coresys.addons.update("local_example_image")
|
||||||
docker.images.delete.assert_called_once_with("image_old", force=True)
|
docker.images.remove.assert_called_once_with("image_old", force=True)
|
||||||
assert install_addon_example_image.version == "1.3.0"
|
assert install_addon_example_image.version == "1.3.0"
|
||||||
|
@@ -283,7 +283,7 @@ async def test_api_progress_updates_home_assistant_update(
|
|||||||
"""Test progress updates sent to Home Assistant for updates."""
|
"""Test progress updates sent to Home Assistant for updates."""
|
||||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
coresys.core.set_state(CoreState.RUNNING)
|
coresys.core.set_state(CoreState.RUNNING)
|
||||||
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
|
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||||
"docker_pull_image_log.json"
|
"docker_pull_image_log.json"
|
||||||
)
|
)
|
||||||
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
|
||||||
|
@@ -732,7 +732,7 @@ async def test_api_progress_updates_addon_install_update(
|
|||||||
"""Test progress updates sent to Home Assistant for installs/updates."""
|
"""Test progress updates sent to Home Assistant for installs/updates."""
|
||||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
coresys.core.set_state(CoreState.RUNNING)
|
coresys.core.set_state(CoreState.RUNNING)
|
||||||
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
|
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||||
"docker_pull_image_log.json"
|
"docker_pull_image_log.json"
|
||||||
)
|
)
|
||||||
coresys.arch._supported_arch = ["amd64"] # pylint: disable=protected-access
|
coresys.arch._supported_arch = ["amd64"] # pylint: disable=protected-access
|
||||||
|
@@ -12,9 +12,8 @@ import pytest
|
|||||||
from supervisor.const import CoreState
|
from supervisor.const import CoreState
|
||||||
from supervisor.core import Core
|
from supervisor.core import Core
|
||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.exceptions import HassioError, HostNotSupportedError, StoreGitError
|
from supervisor.exceptions import HassioError, HostNotSupportedError
|
||||||
from supervisor.homeassistant.const import WSEvent
|
from supervisor.homeassistant.const import WSEvent
|
||||||
from supervisor.store.repository import Repository
|
|
||||||
from supervisor.supervisor import Supervisor
|
from supervisor.supervisor import Supervisor
|
||||||
from supervisor.updater import Updater
|
from supervisor.updater import Updater
|
||||||
|
|
||||||
@@ -35,81 +34,6 @@ async def test_api_supervisor_options_debug(api_client: TestClient, coresys: Cor
|
|||||||
assert coresys.config.debug
|
assert coresys.config.debug
|
||||||
|
|
||||||
|
|
||||||
async def test_api_supervisor_options_add_repository(
|
|
||||||
api_client: TestClient, coresys: CoreSys, supervisor_internet: AsyncMock
|
|
||||||
):
|
|
||||||
"""Test add a repository via POST /supervisor/options REST API."""
|
|
||||||
assert REPO_URL not in coresys.store.repository_urls
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("supervisor.store.repository.RepositoryGit.load", return_value=None),
|
|
||||||
patch("supervisor.store.repository.RepositoryGit.validate", return_value=True),
|
|
||||||
):
|
|
||||||
response = await api_client.post(
|
|
||||||
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status == 200
|
|
||||||
assert REPO_URL in coresys.store.repository_urls
|
|
||||||
|
|
||||||
|
|
||||||
async def test_api_supervisor_options_remove_repository(
|
|
||||||
api_client: TestClient, coresys: CoreSys, test_repository: Repository
|
|
||||||
):
|
|
||||||
"""Test remove a repository via POST /supervisor/options REST API."""
|
|
||||||
assert test_repository.source in coresys.store.repository_urls
|
|
||||||
assert test_repository.slug in coresys.store.repositories
|
|
||||||
|
|
||||||
response = await api_client.post(
|
|
||||||
"/supervisor/options", json={"addons_repositories": []}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status == 200
|
|
||||||
assert test_repository.source not in coresys.store.repository_urls
|
|
||||||
assert test_repository.slug not in coresys.store.repositories
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("git_error", [None, StoreGitError()])
|
|
||||||
async def test_api_supervisor_options_repositories_skipped_on_error(
|
|
||||||
api_client: TestClient, coresys: CoreSys, git_error: StoreGitError
|
|
||||||
):
|
|
||||||
"""Test repositories skipped on error via POST /supervisor/options REST API."""
|
|
||||||
with (
|
|
||||||
patch("supervisor.store.repository.RepositoryGit.load", side_effect=git_error),
|
|
||||||
patch("supervisor.store.repository.RepositoryGit.validate", return_value=False),
|
|
||||||
patch("supervisor.store.repository.RepositoryCustom.remove"),
|
|
||||||
):
|
|
||||||
response = await api_client.post(
|
|
||||||
"/supervisor/options", json={"addons_repositories": [REPO_URL]}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status == 400
|
|
||||||
assert len(coresys.resolution.suggestions) == 0
|
|
||||||
assert REPO_URL not in coresys.store.repository_urls
|
|
||||||
|
|
||||||
|
|
||||||
async def test_api_supervisor_options_repo_error_with_config_change(
|
|
||||||
api_client: TestClient, coresys: CoreSys
|
|
||||||
):
|
|
||||||
"""Test config change with add repository error via POST /supervisor/options REST API."""
|
|
||||||
assert not coresys.config.debug
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"supervisor.store.repository.RepositoryGit.load", side_effect=StoreGitError()
|
|
||||||
):
|
|
||||||
response = await api_client.post(
|
|
||||||
"/supervisor/options",
|
|
||||||
json={"debug": True, "addons_repositories": [REPO_URL]},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status == 400
|
|
||||||
assert REPO_URL not in coresys.store.repository_urls
|
|
||||||
|
|
||||||
assert coresys.config.debug
|
|
||||||
coresys.updater.save_data.assert_called_once()
|
|
||||||
coresys.config.save_data.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_api_supervisor_options_auto_update(
|
async def test_api_supervisor_options_auto_update(
|
||||||
api_client: TestClient, coresys: CoreSys
|
api_client: TestClient, coresys: CoreSys
|
||||||
):
|
):
|
||||||
@@ -332,7 +256,7 @@ async def test_api_progress_updates_supervisor_update(
|
|||||||
"""Test progress updates sent to Home Assistant for updates."""
|
"""Test progress updates sent to Home Assistant for updates."""
|
||||||
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
||||||
coresys.core.set_state(CoreState.RUNNING)
|
coresys.core.set_state(CoreState.RUNNING)
|
||||||
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
|
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||||
"docker_pull_image_log.json"
|
"docker_pull_image_log.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -9,7 +9,6 @@ import subprocess
|
|||||||
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from aiodocker.docker import DockerImages
|
|
||||||
from aiohttp import ClientSession, web
|
from aiohttp import ClientSession, web
|
||||||
from aiohttp.test_utils import TestClient
|
from aiohttp.test_utils import TestClient
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
@@ -113,15 +112,13 @@ async def supervisor_name() -> None:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def docker() -> DockerAPI:
|
async def docker() -> DockerAPI:
|
||||||
"""Mock DockerAPI."""
|
"""Mock DockerAPI."""
|
||||||
image_inspect = {
|
images = [MagicMock(tags=["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"])]
|
||||||
"Os": "linux",
|
image = MagicMock()
|
||||||
"Architecture": "amd64",
|
image.attrs = {"Os": "linux", "Architecture": "amd64"}
|
||||||
"Id": "test123",
|
|
||||||
"RepoTags": ["ghcr.io/home-assistant/amd64-hassio-supervisor:latest"],
|
|
||||||
}
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
|
patch("supervisor.docker.manager.DockerClient", return_value=MagicMock()),
|
||||||
|
patch("supervisor.docker.manager.DockerAPI.images", return_value=MagicMock()),
|
||||||
patch(
|
patch(
|
||||||
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
|
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
|
||||||
),
|
),
|
||||||
@@ -129,30 +126,19 @@ async def docker() -> DockerAPI:
|
|||||||
"supervisor.docker.manager.DockerAPI.api",
|
"supervisor.docker.manager.DockerAPI.api",
|
||||||
return_value=(api_mock := MagicMock()),
|
return_value=(api_mock := MagicMock()),
|
||||||
),
|
),
|
||||||
|
patch("supervisor.docker.manager.DockerAPI.images.get", return_value=image),
|
||||||
|
patch("supervisor.docker.manager.DockerAPI.images.list", return_value=images),
|
||||||
patch(
|
patch(
|
||||||
"supervisor.docker.manager.DockerAPI.info",
|
"supervisor.docker.manager.DockerAPI.info",
|
||||||
return_value=MagicMock(),
|
return_value=MagicMock(),
|
||||||
),
|
),
|
||||||
patch("supervisor.docker.manager.DockerAPI.unload"),
|
patch("supervisor.docker.manager.DockerAPI.unload"),
|
||||||
patch("supervisor.docker.manager.aiodocker.Docker", return_value=MagicMock()),
|
|
||||||
patch(
|
|
||||||
"supervisor.docker.manager.DockerAPI.images",
|
|
||||||
new=PropertyMock(
|
|
||||||
return_value=(docker_images := MagicMock(spec=DockerImages))
|
|
||||||
),
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
docker_obj = await DockerAPI(MagicMock()).post_init()
|
docker_obj = await DockerAPI(MagicMock()).post_init()
|
||||||
docker_obj.config._data = {"registries": {}}
|
docker_obj.config._data = {"registries": {}}
|
||||||
with patch("supervisor.docker.monitor.DockerMonitor.load"):
|
with patch("supervisor.docker.monitor.DockerMonitor.load"):
|
||||||
await docker_obj.load()
|
await docker_obj.load()
|
||||||
|
|
||||||
docker_images.inspect.return_value = image_inspect
|
|
||||||
docker_images.list.return_value = [image_inspect]
|
|
||||||
docker_images.import_image.return_value = [
|
|
||||||
{"stream": "Loaded image: test:latest\n"}
|
|
||||||
]
|
|
||||||
|
|
||||||
docker_obj.info.logging = "journald"
|
docker_obj.info.logging = "journald"
|
||||||
docker_obj.info.storage = "overlay2"
|
docker_obj.info.storage = "overlay2"
|
||||||
docker_obj.info.version = AwesomeVersion("1.0.0")
|
docker_obj.info.version = AwesomeVersion("1.0.0")
|
||||||
@@ -852,9 +838,11 @@ async def container(docker: DockerAPI) -> MagicMock:
|
|||||||
"""Mock attrs and status for container on attach."""
|
"""Mock attrs and status for container on attach."""
|
||||||
docker.containers.get.return_value = addon = MagicMock()
|
docker.containers.get.return_value = addon = MagicMock()
|
||||||
docker.containers.create.return_value = addon
|
docker.containers.create.return_value = addon
|
||||||
|
docker.images.build.return_value = (addon, "")
|
||||||
addon.status = "stopped"
|
addon.status = "stopped"
|
||||||
addon.attrs = {"State": {"ExitCode": 0}}
|
addon.attrs = {"State": {"ExitCode": 0}}
|
||||||
yield addon
|
with patch.object(DockerAPI, "pull_image", return_value=addon):
|
||||||
|
yield addon
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
@@ -5,10 +5,10 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
from docker.errors import DockerException, NotFound
|
from docker.errors import DockerException, NotFound
|
||||||
from docker.models.containers import Container
|
from docker.models.containers import Container
|
||||||
|
from docker.models.images import Image
|
||||||
import pytest
|
import pytest
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
|
|
||||||
@@ -57,30 +57,35 @@ async def test_docker_image_platform(
|
|||||||
platform: str,
|
platform: str,
|
||||||
):
|
):
|
||||||
"""Test platform set correctly from arch."""
|
"""Test platform set correctly from arch."""
|
||||||
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
|
with patch.object(
|
||||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test", arch=cpu_arch)
|
coresys.docker.images, "get", return_value=Mock(id="test:1.2.3")
|
||||||
coresys.docker.dockerpy.api.pull.assert_called_once_with(
|
) as get:
|
||||||
"test", tag="1.2.3", platform=platform, stream=True, decode=True
|
await test_docker_interface.install(
|
||||||
)
|
AwesomeVersion("1.2.3"), "test", arch=cpu_arch
|
||||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
)
|
||||||
|
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||||
|
"test", tag="1.2.3", platform=platform, stream=True, decode=True
|
||||||
|
)
|
||||||
|
get.assert_called_once_with("test:1.2.3")
|
||||||
|
|
||||||
|
|
||||||
async def test_docker_image_default_platform(
|
async def test_docker_image_default_platform(
|
||||||
coresys: CoreSys, test_docker_interface: DockerInterface
|
coresys: CoreSys, test_docker_interface: DockerInterface
|
||||||
):
|
):
|
||||||
"""Test platform set using supervisor arch when omitted."""
|
"""Test platform set using supervisor arch when omitted."""
|
||||||
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
|
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
|
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
|
||||||
),
|
),
|
||||||
|
patch.object(
|
||||||
|
coresys.docker.images, "get", return_value=Mock(id="test:1.2.3")
|
||||||
|
) as get,
|
||||||
):
|
):
|
||||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||||
coresys.docker.dockerpy.api.pull.assert_called_once_with(
|
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||||
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
||||||
)
|
)
|
||||||
|
get.assert_called_once_with("test:1.2.3")
|
||||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -211,40 +216,57 @@ async def test_attach_existing_container(
|
|||||||
|
|
||||||
async def test_attach_container_failure(coresys: CoreSys):
|
async def test_attach_container_failure(coresys: CoreSys):
|
||||||
"""Test attach fails to find container but finds image."""
|
"""Test attach fails to find container but finds image."""
|
||||||
coresys.docker.containers.get.side_effect = DockerException()
|
container_collection = MagicMock()
|
||||||
coresys.docker.images.inspect.return_value.setdefault("Config", {})["Image"] = (
|
container_collection.get.side_effect = DockerException()
|
||||||
"sha256:abc123"
|
image_collection = MagicMock()
|
||||||
)
|
image_config = {"Image": "sha256:abc123"}
|
||||||
with patch.object(type(coresys.bus), "fire_event") as fire_event:
|
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,
|
||||||
|
):
|
||||||
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
||||||
assert not [
|
assert not [
|
||||||
event
|
event
|
||||||
for event in fire_event.call_args_list
|
for event in fire_event.call_args_list
|
||||||
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE
|
if event.args[0] == BusEvent.DOCKER_CONTAINER_STATE_CHANGE
|
||||||
]
|
]
|
||||||
assert (
|
assert coresys.homeassistant.core.instance.meta_config == image_config
|
||||||
coresys.homeassistant.core.instance.meta_config["Image"] == "sha256:abc123"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_attach_total_failure(coresys: CoreSys):
|
async def test_attach_total_failure(coresys: CoreSys):
|
||||||
"""Test attach fails to find container or image."""
|
"""Test attach fails to find container or image."""
|
||||||
coresys.docker.containers.get.side_effect = DockerException
|
container_collection = MagicMock()
|
||||||
coresys.docker.images.inspect.side_effect = aiodocker.DockerError(
|
container_collection.get.side_effect = DockerException()
|
||||||
400, {"message": ""}
|
image_collection = MagicMock()
|
||||||
)
|
image_collection.get.side_effect = DockerException()
|
||||||
with pytest.raises(DockerError):
|
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),
|
||||||
|
):
|
||||||
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
await coresys.homeassistant.core.instance.attach(AwesomeVersion("2022.7.3"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("err", [DockerException(), RequestException()])
|
||||||
"err", [aiodocker.DockerError(400, {"message": ""}), RequestException()]
|
|
||||||
)
|
|
||||||
async def test_image_pull_fail(
|
async def test_image_pull_fail(
|
||||||
coresys: CoreSys, capture_exception: Mock, err: Exception
|
coresys: CoreSys, capture_exception: Mock, err: Exception
|
||||||
):
|
):
|
||||||
"""Test failure to pull image."""
|
"""Test failure to pull image."""
|
||||||
coresys.docker.images.inspect.side_effect = err
|
coresys.docker.images.get.side_effect = err
|
||||||
with pytest.raises(DockerError):
|
with pytest.raises(DockerError):
|
||||||
await coresys.homeassistant.core.instance.install(
|
await coresys.homeassistant.core.instance.install(
|
||||||
AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64
|
AwesomeVersion("2022.7.3"), arch=CpuArch.AMD64
|
||||||
@@ -277,7 +299,7 @@ async def test_install_fires_progress_events(
|
|||||||
):
|
):
|
||||||
"""Test progress events are fired during an install for listeners."""
|
"""Test progress events are fired during an install for listeners."""
|
||||||
# This is from a sample pull. Filtered log to just one per unique status for test
|
# This is from a sample pull. Filtered log to just one per unique status for test
|
||||||
coresys.docker.dockerpy.api.pull.return_value = [
|
coresys.docker.docker.api.pull.return_value = [
|
||||||
{
|
{
|
||||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||||
"id": "2025.7.2",
|
"id": "2025.7.2",
|
||||||
@@ -321,10 +343,10 @@ async def test_install_fires_progress_events(
|
|||||||
),
|
),
|
||||||
):
|
):
|
||||||
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
|
||||||
coresys.docker.dockerpy.api.pull.assert_called_once_with(
|
coresys.docker.docker.api.pull.assert_called_once_with(
|
||||||
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
|
||||||
)
|
)
|
||||||
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
|
coresys.docker.images.get.assert_called_once_with("test:1.2.3")
|
||||||
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
assert events == [
|
assert events == [
|
||||||
@@ -405,7 +427,7 @@ async def test_install_progress_rounding_does_not_cause_misses(
|
|||||||
# Current numbers chosen to create a rounding issue with original code
|
# Current numbers chosen to create a rounding issue with original code
|
||||||
# Where a progress update came in with a value between the actual previous
|
# Where a progress update came in with a value between the actual previous
|
||||||
# value and what it was rounded to. It should not raise an out of order exception
|
# value and what it was rounded to. It should not raise an out of order exception
|
||||||
coresys.docker.dockerpy.api.pull.return_value = [
|
coresys.docker.docker.api.pull.return_value = [
|
||||||
{
|
{
|
||||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||||
"id": "2025.7.1",
|
"id": "2025.7.1",
|
||||||
@@ -500,7 +522,7 @@ async def test_install_raises_on_pull_error(
|
|||||||
exc_msg: str,
|
exc_msg: str,
|
||||||
):
|
):
|
||||||
"""Test exceptions raised from errors in pull log."""
|
"""Test exceptions raised from errors in pull log."""
|
||||||
coresys.docker.dockerpy.api.pull.return_value = [
|
coresys.docker.docker.api.pull.return_value = [
|
||||||
{
|
{
|
||||||
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
|
||||||
"id": "2025.7.2",
|
"id": "2025.7.2",
|
||||||
@@ -528,7 +550,7 @@ async def test_install_progress_handles_download_restart(
|
|||||||
coresys.core.set_state(CoreState.RUNNING)
|
coresys.core.set_state(CoreState.RUNNING)
|
||||||
# Fixture emulates a download restart as it docker logs it
|
# Fixture emulates a download restart as it docker logs it
|
||||||
# A log out of order exception should not be raised
|
# A log out of order exception should not be raised
|
||||||
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
|
coresys.docker.docker.api.pull.return_value = load_json_fixture(
|
||||||
"docker_pull_image_log_restart.json"
|
"docker_pull_image_log_restart.json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
"""Test Docker manager."""
|
"""Test Docker manager."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from docker.errors import APIError, DockerException, NotFound
|
from docker.errors import DockerException
|
||||||
import pytest
|
import pytest
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
|
|
||||||
@@ -21,7 +20,7 @@ async def test_run_command_success(docker: DockerAPI):
|
|||||||
mock_container.logs.return_value = b"command output"
|
mock_container.logs.return_value = b"command output"
|
||||||
|
|
||||||
# Mock docker containers.run to return our mock container
|
# Mock docker containers.run to return our mock container
|
||||||
docker.dockerpy.containers.run.return_value = mock_container
|
docker.docker.containers.run.return_value = mock_container
|
||||||
|
|
||||||
# Execute the command
|
# Execute the command
|
||||||
result = docker.run_command(
|
result = docker.run_command(
|
||||||
@@ -34,7 +33,7 @@ async def test_run_command_success(docker: DockerAPI):
|
|||||||
assert result.output == b"command output"
|
assert result.output == b"command output"
|
||||||
|
|
||||||
# Verify docker.containers.run was called correctly
|
# Verify docker.containers.run was called correctly
|
||||||
docker.dockerpy.containers.run.assert_called_once_with(
|
docker.docker.containers.run.assert_called_once_with(
|
||||||
"alpine:3.18",
|
"alpine:3.18",
|
||||||
command="echo hello",
|
command="echo hello",
|
||||||
detach=True,
|
detach=True,
|
||||||
@@ -56,7 +55,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
|||||||
mock_container.logs.return_value = b"error output"
|
mock_container.logs.return_value = b"error output"
|
||||||
|
|
||||||
# Mock docker containers.run to return our mock container
|
# Mock docker containers.run to return our mock container
|
||||||
docker.dockerpy.containers.run.return_value = mock_container
|
docker.docker.containers.run.return_value = mock_container
|
||||||
|
|
||||||
# Execute the command with minimal parameters
|
# Execute the command with minimal parameters
|
||||||
result = docker.run_command(image="ubuntu")
|
result = docker.run_command(image="ubuntu")
|
||||||
@@ -67,7 +66,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
|||||||
assert result.output == b"error output"
|
assert result.output == b"error output"
|
||||||
|
|
||||||
# Verify docker.containers.run was called with defaults
|
# Verify docker.containers.run was called with defaults
|
||||||
docker.dockerpy.containers.run.assert_called_once_with(
|
docker.docker.containers.run.assert_called_once_with(
|
||||||
"ubuntu:latest", # default tag
|
"ubuntu:latest", # default tag
|
||||||
command=None, # default command
|
command=None, # default command
|
||||||
detach=True,
|
detach=True,
|
||||||
@@ -82,7 +81,7 @@ async def test_run_command_with_defaults(docker: DockerAPI):
|
|||||||
async def test_run_command_docker_exception(docker: DockerAPI):
|
async def test_run_command_docker_exception(docker: DockerAPI):
|
||||||
"""Test command execution when Docker raises an exception."""
|
"""Test command execution when Docker raises an exception."""
|
||||||
# Mock docker containers.run to raise DockerException
|
# Mock docker containers.run to raise DockerException
|
||||||
docker.dockerpy.containers.run.side_effect = DockerException("Docker error")
|
docker.docker.containers.run.side_effect = DockerException("Docker error")
|
||||||
|
|
||||||
# Execute the command and expect DockerError
|
# Execute the command and expect DockerError
|
||||||
with pytest.raises(DockerError, match="Can't execute command: Docker error"):
|
with pytest.raises(DockerError, match="Can't execute command: Docker error"):
|
||||||
@@ -92,7 +91,7 @@ async def test_run_command_docker_exception(docker: DockerAPI):
|
|||||||
async def test_run_command_request_exception(docker: DockerAPI):
|
async def test_run_command_request_exception(docker: DockerAPI):
|
||||||
"""Test command execution when requests raises an exception."""
|
"""Test command execution when requests raises an exception."""
|
||||||
# Mock docker containers.run to raise RequestException
|
# Mock docker containers.run to raise RequestException
|
||||||
docker.dockerpy.containers.run.side_effect = RequestException("Connection error")
|
docker.docker.containers.run.side_effect = RequestException("Connection error")
|
||||||
|
|
||||||
# Execute the command and expect DockerError
|
# Execute the command and expect DockerError
|
||||||
with pytest.raises(DockerError, match="Can't execute command: Connection error"):
|
with pytest.raises(DockerError, match="Can't execute command: Connection error"):
|
||||||
@@ -105,7 +104,7 @@ async def test_run_command_cleanup_on_exception(docker: DockerAPI):
|
|||||||
mock_container = MagicMock()
|
mock_container = MagicMock()
|
||||||
|
|
||||||
# Mock docker.containers.run to return container, but container.wait to raise exception
|
# Mock docker.containers.run to return container, but container.wait to raise exception
|
||||||
docker.dockerpy.containers.run.return_value = mock_container
|
docker.docker.containers.run.return_value = mock_container
|
||||||
mock_container.wait.side_effect = DockerException("Wait failed")
|
mock_container.wait.side_effect = DockerException("Wait failed")
|
||||||
|
|
||||||
# Execute the command and expect DockerError
|
# Execute the command and expect DockerError
|
||||||
@@ -124,7 +123,7 @@ async def test_run_command_custom_stdout_stderr(docker: DockerAPI):
|
|||||||
mock_container.logs.return_value = b"output"
|
mock_container.logs.return_value = b"output"
|
||||||
|
|
||||||
# Mock docker containers.run to return our mock container
|
# Mock docker containers.run to return our mock container
|
||||||
docker.dockerpy.containers.run.return_value = mock_container
|
docker.docker.containers.run.return_value = mock_container
|
||||||
|
|
||||||
# Execute the command with custom stdout/stderr
|
# Execute the command with custom stdout/stderr
|
||||||
result = docker.run_command(
|
result = docker.run_command(
|
||||||
@@ -151,7 +150,7 @@ async def test_run_container_with_cidfile(
|
|||||||
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
cidfile_path = coresys.config.path_cid_files / f"{container_name}.cid"
|
||||||
extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid"
|
extern_cidfile_path = coresys.config.path_extern_cid_files / f"{container_name}.cid"
|
||||||
|
|
||||||
docker.dockerpy.containers.run.return_value = mock_container
|
docker.docker.containers.run.return_value = mock_container
|
||||||
|
|
||||||
# Mock container creation
|
# Mock container creation
|
||||||
with patch.object(
|
with patch.object(
|
||||||
@@ -352,101 +351,3 @@ async def test_run_container_with_leftover_cidfile_directory(
|
|||||||
assert cidfile_path.read_text() == mock_container.id
|
assert cidfile_path.read_text() == mock_container.id
|
||||||
|
|
||||||
assert result == mock_container
|
assert result == mock_container
|
||||||
|
|
||||||
|
|
||||||
async def test_repair(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
|
||||||
"""Test repair API."""
|
|
||||||
coresys.docker.dockerpy.networks.get.side_effect = [
|
|
||||||
hassio := MagicMock(
|
|
||||||
attrs={
|
|
||||||
"Containers": {
|
|
||||||
"good": {"Name": "good"},
|
|
||||||
"corrupt": {"Name": "corrupt"},
|
|
||||||
"fail": {"Name": "fail"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
),
|
|
||||||
host := MagicMock(attrs={"Containers": {}}),
|
|
||||||
]
|
|
||||||
coresys.docker.dockerpy.containers.get.side_effect = [
|
|
||||||
MagicMock(),
|
|
||||||
NotFound("corrupt"),
|
|
||||||
DockerException("fail"),
|
|
||||||
]
|
|
||||||
|
|
||||||
await coresys.run_in_executor(coresys.docker.repair)
|
|
||||||
|
|
||||||
coresys.docker.dockerpy.api.prune_containers.assert_called_once()
|
|
||||||
coresys.docker.dockerpy.api.prune_images.assert_called_once_with(
|
|
||||||
filters={"dangling": False}
|
|
||||||
)
|
|
||||||
coresys.docker.dockerpy.api.prune_builds.assert_called_once()
|
|
||||||
coresys.docker.dockerpy.api.prune_volumes.assert_called_once()
|
|
||||||
coresys.docker.dockerpy.api.prune_networks.assert_called_once()
|
|
||||||
hassio.disconnect.assert_called_once_with("corrupt", force=True)
|
|
||||||
host.disconnect.assert_not_called()
|
|
||||||
assert "Docker fatal error on container fail on hassio" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
async def test_repair_failures(coresys: CoreSys, caplog: pytest.LogCaptureFixture):
|
|
||||||
"""Test repair proceeds best it can through failures."""
|
|
||||||
coresys.docker.dockerpy.api.prune_containers.side_effect = APIError("fail")
|
|
||||||
coresys.docker.dockerpy.api.prune_images.side_effect = APIError("fail")
|
|
||||||
coresys.docker.dockerpy.api.prune_builds.side_effect = APIError("fail")
|
|
||||||
coresys.docker.dockerpy.api.prune_volumes.side_effect = APIError("fail")
|
|
||||||
coresys.docker.dockerpy.api.prune_networks.side_effect = APIError("fail")
|
|
||||||
coresys.docker.dockerpy.networks.get.side_effect = NotFound("missing")
|
|
||||||
|
|
||||||
await coresys.run_in_executor(coresys.docker.repair)
|
|
||||||
|
|
||||||
assert "Error for containers prune: fail" in caplog.text
|
|
||||||
assert "Error for images prune: fail" in caplog.text
|
|
||||||
assert "Error for builds prune: fail" in caplog.text
|
|
||||||
assert "Error for volumes prune: fail" in caplog.text
|
|
||||||
assert "Error for networks prune: fail" in caplog.text
|
|
||||||
assert "Error for networks hassio prune: missing" in caplog.text
|
|
||||||
assert "Error for networks host prune: missing" in caplog.text
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("log_starter", [("Loaded image ID"), ("Loaded image")])
|
|
||||||
async def test_import_image(coresys: CoreSys, tmp_path: Path, log_starter: str):
|
|
||||||
"""Test importing an image into docker."""
|
|
||||||
(test_tar := tmp_path / "test.tar").touch()
|
|
||||||
coresys.docker.images.import_image.return_value = [
|
|
||||||
{"stream": f"{log_starter}: imported"}
|
|
||||||
]
|
|
||||||
coresys.docker.images.inspect.return_value = {"Id": "imported"}
|
|
||||||
|
|
||||||
image = await coresys.docker.import_image(test_tar)
|
|
||||||
|
|
||||||
assert image["Id"] == "imported"
|
|
||||||
coresys.docker.images.inspect.assert_called_once_with("imported")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_import_image_error(coresys: CoreSys, tmp_path: Path):
|
|
||||||
"""Test failure importing an image into docker."""
|
|
||||||
(test_tar := tmp_path / "test.tar").touch()
|
|
||||||
coresys.docker.images.import_image.return_value = [
|
|
||||||
{"errorDetail": {"message": "fail"}}
|
|
||||||
]
|
|
||||||
|
|
||||||
with pytest.raises(DockerError, match="Can't import image from tar: fail"):
|
|
||||||
await coresys.docker.import_image(test_tar)
|
|
||||||
|
|
||||||
coresys.docker.images.inspect.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_import_multiple_images_in_tar(
|
|
||||||
coresys: CoreSys, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
||||||
):
|
|
||||||
"""Test importing an image into docker."""
|
|
||||||
(test_tar := tmp_path / "test.tar").touch()
|
|
||||||
coresys.docker.images.import_image.return_value = [
|
|
||||||
{"stream": "Loaded image: imported-1"},
|
|
||||||
{"stream": "Loaded image: imported-2"},
|
|
||||||
]
|
|
||||||
|
|
||||||
assert await coresys.docker.import_image(test_tar) is None
|
|
||||||
|
|
||||||
assert "Unexpected image count 2 while importing image from tar" in caplog.text
|
|
||||||
coresys.docker.images.inspect.assert_not_called()
|
|
||||||
|
@@ -1,11 +1,10 @@
|
|||||||
"""Test Home Assistant core."""
|
"""Test Home Assistant core."""
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
|
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch
|
||||||
|
|
||||||
import aiodocker
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
from docker.errors import APIError, DockerException, NotFound
|
from docker.errors import APIError, DockerException, ImageNotFound, NotFound
|
||||||
import pytest
|
import pytest
|
||||||
from time_machine import travel
|
from time_machine import travel
|
||||||
|
|
||||||
@@ -70,7 +69,7 @@ async def test_install_landingpage_docker_error(
|
|||||||
),
|
),
|
||||||
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
||||||
):
|
):
|
||||||
coresys.docker.dockerpy.api.pull.side_effect = [APIError("fail"), MagicMock()]
|
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
|
||||||
await coresys.homeassistant.core.install_landingpage()
|
await coresys.homeassistant.core.install_landingpage()
|
||||||
sleep.assert_awaited_once_with(30)
|
sleep.assert_awaited_once_with(30)
|
||||||
|
|
||||||
@@ -82,7 +81,7 @@ async def test_install_landingpage_other_error(
|
|||||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||||
):
|
):
|
||||||
"""Test install landing page fails due to other error."""
|
"""Test install landing page fails due to other error."""
|
||||||
coresys.docker.images.inspect.side_effect = [(err := OSError()), MagicMock()]
|
coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
|
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
|
||||||
@@ -124,7 +123,7 @@ async def test_install_docker_error(
|
|||||||
),
|
),
|
||||||
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
|
||||||
):
|
):
|
||||||
coresys.docker.dockerpy.api.pull.side_effect = [APIError("fail"), MagicMock()]
|
coresys.docker.images.get.side_effect = [APIError("fail"), MagicMock()]
|
||||||
await coresys.homeassistant.core.install()
|
await coresys.homeassistant.core.install()
|
||||||
sleep.assert_awaited_once_with(30)
|
sleep.assert_awaited_once_with(30)
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ async def test_install_other_error(
|
|||||||
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
|
||||||
):
|
):
|
||||||
"""Test install fails due to other error."""
|
"""Test install fails due to other error."""
|
||||||
coresys.docker.images.inspect.side_effect = [(err := OSError()), MagicMock()]
|
coresys.docker.images.get.side_effect = [(err := OSError()), MagicMock()]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(HomeAssistantCore, "start"),
|
patch.object(HomeAssistantCore, "start"),
|
||||||
@@ -162,29 +161,21 @@ async def test_install_other_error(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("container_exc", "image_exc", "remove_calls"),
|
"container_exists,image_exists", [(False, True), (True, False), (True, True)]
|
||||||
[
|
|
||||||
(NotFound("missing"), None, []),
|
|
||||||
(
|
|
||||||
None,
|
|
||||||
aiodocker.DockerError(404, {"message": "missing"}),
|
|
||||||
[call(force=True, v=True)],
|
|
||||||
),
|
|
||||||
(None, None, [call(force=True, v=True)]),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
@pytest.mark.usefixtures("path_extern")
|
|
||||||
async def test_start(
|
async def test_start(
|
||||||
coresys: CoreSys,
|
coresys: CoreSys, container_exists: bool, image_exists: bool, path_extern
|
||||||
container_exc: DockerException | None,
|
|
||||||
image_exc: aiodocker.DockerError | None,
|
|
||||||
remove_calls: list[call],
|
|
||||||
):
|
):
|
||||||
"""Test starting Home Assistant."""
|
"""Test starting Home Assistant."""
|
||||||
coresys.docker.images.inspect.return_value = {"Id": "123"}
|
if image_exists:
|
||||||
coresys.docker.images.inspect.side_effect = image_exc
|
coresys.docker.images.get.return_value.id = "123"
|
||||||
coresys.docker.containers.get.return_value.id = "123"
|
else:
|
||||||
coresys.docker.containers.get.side_effect = container_exc
|
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")
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(
|
||||||
@@ -207,14 +198,18 @@ async def test_start(
|
|||||||
assert run.call_args.kwargs["hostname"] == "homeassistant"
|
assert run.call_args.kwargs["hostname"] == "homeassistant"
|
||||||
|
|
||||||
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
coresys.docker.containers.get.return_value.stop.assert_not_called()
|
||||||
assert (
|
if container_exists:
|
||||||
coresys.docker.containers.get.return_value.remove.call_args_list == remove_calls
|
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()
|
||||||
|
|
||||||
|
|
||||||
async def test_start_existing_container(coresys: CoreSys, path_extern):
|
async def test_start_existing_container(coresys: CoreSys, path_extern):
|
||||||
"""Test starting Home Assistant when container exists and is viable."""
|
"""Test starting Home Assistant when container exists and is viable."""
|
||||||
coresys.docker.images.inspect.return_value = {"Id": "123"}
|
coresys.docker.images.get.return_value.id = "123"
|
||||||
coresys.docker.containers.get.return_value.image.id = "123"
|
coresys.docker.containers.get.return_value.image.id = "123"
|
||||||
coresys.docker.containers.get.return_value.status = "exited"
|
coresys.docker.containers.get.return_value.status = "exited"
|
||||||
|
|
||||||
@@ -399,32 +394,24 @@ async def test_core_loads_wrong_image_for_machine(
|
|||||||
"""Test core is loaded with wrong image for machine."""
|
"""Test core is loaded with wrong image for machine."""
|
||||||
coresys.homeassistant.set_image("ghcr.io/home-assistant/odroid-n2-homeassistant")
|
coresys.homeassistant.set_image("ghcr.io/home-assistant/odroid-n2-homeassistant")
|
||||||
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
||||||
|
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||||
|
|
||||||
with patch.object(
|
await coresys.homeassistant.core.load()
|
||||||
DockerAPI,
|
|
||||||
"pull_image",
|
|
||||||
return_value={
|
|
||||||
"Id": "abc123",
|
|
||||||
"Config": {"Labels": {"io.hass.version": "2024.4.0"}},
|
|
||||||
},
|
|
||||||
) as pull_image:
|
|
||||||
container.attrs |= pull_image.return_value
|
|
||||||
await coresys.homeassistant.core.load()
|
|
||||||
pull_image.assert_called_once_with(
|
|
||||||
ANY,
|
|
||||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
|
||||||
"2024.4.0",
|
|
||||||
platform="linux/amd64",
|
|
||||||
)
|
|
||||||
|
|
||||||
container.remove.assert_called_once_with(force=True, v=True)
|
container.remove.assert_called_once_with(force=True, v=True)
|
||||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||||
"ghcr.io/home-assistant/odroid-n2-homeassistant:latest",
|
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:latest",
|
||||||
force=True,
|
"force": True,
|
||||||
)
|
}
|
||||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||||
"ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0",
|
"image": "ghcr.io/home-assistant/odroid-n2-homeassistant:2024.4.0",
|
||||||
force=True,
|
"force": True,
|
||||||
|
}
|
||||||
|
coresys.docker.pull_image.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||||
|
"2024.4.0",
|
||||||
|
platform="linux/amd64",
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
||||||
@@ -441,8 +428,8 @@ async def test_core_load_allows_image_override(coresys: CoreSys, container: Magi
|
|||||||
await coresys.homeassistant.core.load()
|
await coresys.homeassistant.core.load()
|
||||||
|
|
||||||
container.remove.assert_not_called()
|
container.remove.assert_not_called()
|
||||||
coresys.docker.images.delete.assert_not_called()
|
coresys.docker.images.remove.assert_not_called()
|
||||||
coresys.docker.images.inspect.assert_not_called()
|
coresys.docker.images.get.assert_not_called()
|
||||||
assert (
|
assert (
|
||||||
coresys.homeassistant.image == "ghcr.io/home-assistant/odroid-n2-homeassistant"
|
coresys.homeassistant.image == "ghcr.io/home-assistant/odroid-n2-homeassistant"
|
||||||
)
|
)
|
||||||
@@ -453,36 +440,27 @@ async def test_core_loads_wrong_image_for_architecture(
|
|||||||
):
|
):
|
||||||
"""Test core is loaded with wrong image for architecture."""
|
"""Test core is loaded with wrong image for architecture."""
|
||||||
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
coresys.homeassistant.version = AwesomeVersion("2024.4.0")
|
||||||
coresys.docker.images.inspect.return_value = img_data = (
|
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||||
coresys.docker.images.inspect.return_value
|
coresys.docker.images.get("ghcr.io/home-assistant/qemux86-64-homeassistant").attrs[
|
||||||
| {
|
"Architecture"
|
||||||
"Architecture": "arm64",
|
] = "arm64"
|
||||||
"Config": {"Labels": {"io.hass.version": "2024.4.0"}},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
container.attrs |= img_data
|
|
||||||
|
|
||||||
with patch.object(
|
await coresys.homeassistant.core.load()
|
||||||
DockerAPI,
|
|
||||||
"pull_image",
|
|
||||||
return_value=img_data | {"Architecture": "amd64"},
|
|
||||||
) as pull_image:
|
|
||||||
await coresys.homeassistant.core.load()
|
|
||||||
pull_image.assert_called_once_with(
|
|
||||||
ANY,
|
|
||||||
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
|
||||||
"2024.4.0",
|
|
||||||
platform="linux/amd64",
|
|
||||||
)
|
|
||||||
|
|
||||||
container.remove.assert_called_once_with(force=True, v=True)
|
container.remove.assert_called_once_with(force=True, v=True)
|
||||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||||
"ghcr.io/home-assistant/qemux86-64-homeassistant:latest",
|
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:latest",
|
||||||
force=True,
|
"force": True,
|
||||||
)
|
}
|
||||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||||
"ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0",
|
"image": "ghcr.io/home-assistant/qemux86-64-homeassistant:2024.4.0",
|
||||||
force=True,
|
"force": True,
|
||||||
|
}
|
||||||
|
coresys.docker.pull_image.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
"ghcr.io/home-assistant/qemux86-64-homeassistant",
|
||||||
|
"2024.4.0",
|
||||||
|
platform="linux/amd64",
|
||||||
)
|
)
|
||||||
assert (
|
assert (
|
||||||
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
coresys.homeassistant.image == "ghcr.io/home-assistant/qemux86-64-homeassistant"
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
|
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch
|
||||||
|
|
||||||
from awesomeversion import AwesomeVersion
|
from awesomeversion import AwesomeVersion
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,7 +11,6 @@ from supervisor.const import BusEvent, CpuArch
|
|||||||
from supervisor.coresys import CoreSys
|
from supervisor.coresys import CoreSys
|
||||||
from supervisor.docker.const import ContainerState
|
from supervisor.docker.const import ContainerState
|
||||||
from supervisor.docker.interface import DockerInterface
|
from supervisor.docker.interface import DockerInterface
|
||||||
from supervisor.docker.manager import DockerAPI
|
|
||||||
from supervisor.docker.monitor import DockerContainerStateEvent
|
from supervisor.docker.monitor import DockerContainerStateEvent
|
||||||
from supervisor.exceptions import (
|
from supervisor.exceptions import (
|
||||||
AudioError,
|
AudioError,
|
||||||
@@ -363,26 +362,21 @@ async def test_load_with_incorrect_image(
|
|||||||
plugin.version = AwesomeVersion("2024.4.0")
|
plugin.version = AwesomeVersion("2024.4.0")
|
||||||
|
|
||||||
container.status = "running"
|
container.status = "running"
|
||||||
coresys.docker.images.inspect.return_value = img_data = (
|
container.attrs["Config"] = {"Labels": {"io.hass.version": "2024.4.0"}}
|
||||||
coresys.docker.images.inspect.return_value
|
|
||||||
| {"Config": {"Labels": {"io.hass.version": "2024.4.0"}}}
|
|
||||||
)
|
|
||||||
container.attrs |= img_data
|
|
||||||
|
|
||||||
with patch.object(DockerAPI, "pull_image", return_value=img_data) as pull_image:
|
await plugin.load()
|
||||||
await plugin.load()
|
|
||||||
pull_image.assert_called_once_with(
|
|
||||||
ANY, correct_image, "2024.4.0", platform="linux/amd64"
|
|
||||||
)
|
|
||||||
|
|
||||||
container.remove.assert_called_once_with(force=True, v=True)
|
container.remove.assert_called_once_with(force=True, v=True)
|
||||||
assert coresys.docker.images.delete.call_args_list[0] == call(
|
assert coresys.docker.images.remove.call_args_list[0].kwargs == {
|
||||||
f"{old_image}:latest",
|
"image": f"{old_image}:latest",
|
||||||
force=True,
|
"force": True,
|
||||||
)
|
}
|
||||||
assert coresys.docker.images.delete.call_args_list[1] == call(
|
assert coresys.docker.images.remove.call_args_list[1].kwargs == {
|
||||||
f"{old_image}:2024.4.0",
|
"image": f"{old_image}:2024.4.0",
|
||||||
force=True,
|
"force": True,
|
||||||
|
}
|
||||||
|
coresys.docker.pull_image.assert_called_once_with(
|
||||||
|
ANY, correct_image, "2024.4.0", platform="linux/amd64"
|
||||||
)
|
)
|
||||||
assert plugin.image == correct_image
|
assert plugin.image == correct_image
|
||||||
|
|
||||||
|
@@ -25,18 +25,13 @@ async def test_evaluation(coresys: CoreSys):
|
|||||||
assert docker_configuration.reason in coresys.resolution.unsupported
|
assert docker_configuration.reason in coresys.resolution.unsupported
|
||||||
coresys.resolution.unsupported.clear()
|
coresys.resolution.unsupported.clear()
|
||||||
|
|
||||||
coresys.docker.info.storage = EXPECTED_STORAGE[0]
|
coresys.docker.info.storage = EXPECTED_STORAGE
|
||||||
coresys.docker.info.logging = "unsupported"
|
coresys.docker.info.logging = "unsupported"
|
||||||
await docker_configuration()
|
await docker_configuration()
|
||||||
assert docker_configuration.reason in coresys.resolution.unsupported
|
assert docker_configuration.reason in coresys.resolution.unsupported
|
||||||
coresys.resolution.unsupported.clear()
|
coresys.resolution.unsupported.clear()
|
||||||
|
|
||||||
coresys.docker.info.storage = "overlay2"
|
coresys.docker.info.storage = EXPECTED_STORAGE
|
||||||
coresys.docker.info.logging = EXPECTED_LOGGING
|
|
||||||
await docker_configuration()
|
|
||||||
assert docker_configuration.reason not in coresys.resolution.unsupported
|
|
||||||
|
|
||||||
coresys.docker.info.storage = "overlayfs"
|
|
||||||
coresys.docker.info.logging = EXPECTED_LOGGING
|
coresys.docker.info.logging = EXPECTED_LOGGING
|
||||||
await docker_configuration()
|
await docker_configuration()
|
||||||
assert docker_configuration.reason not in coresys.resolution.unsupported
|
assert docker_configuration.reason not in coresys.resolution.unsupported
|
||||||
|
@@ -1,9 +1,8 @@
|
|||||||
"""Test fixup addon execute repair."""
|
"""Test fixup addon execute repair."""
|
||||||
|
|
||||||
from http import HTTPStatus
|
from unittest.mock import MagicMock, patch
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import aiodocker
|
from docker.errors import NotFound
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from supervisor.addons.addon import Addon
|
from supervisor.addons.addon import Addon
|
||||||
@@ -18,9 +17,7 @@ from supervisor.resolution.fixups.addon_execute_repair import FixupAddonExecuteR
|
|||||||
|
|
||||||
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
|
async def test_fixup(docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon):
|
||||||
"""Test fixup rebuilds addon's container."""
|
"""Test fixup rebuilds addon's container."""
|
||||||
docker.images.inspect.side_effect = aiodocker.DockerError(
|
docker.images.get.side_effect = NotFound("missing")
|
||||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
|
||||||
)
|
|
||||||
install_addon_ssh.data["image"] = "test_image"
|
install_addon_ssh.data["image"] = "test_image"
|
||||||
|
|
||||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||||
@@ -44,9 +41,7 @@ async def test_fixup_max_auto_attempts(
|
|||||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
||||||
):
|
):
|
||||||
"""Test fixup stops being auto-applied after 5 failures."""
|
"""Test fixup stops being auto-applied after 5 failures."""
|
||||||
docker.images.inspect.side_effect = aiodocker.DockerError(
|
docker.images.get.side_effect = NotFound("missing")
|
||||||
HTTPStatus.NOT_FOUND, {"message": "missing"}
|
|
||||||
)
|
|
||||||
install_addon_ssh.data["image"] = "test_image"
|
install_addon_ssh.data["image"] = "test_image"
|
||||||
|
|
||||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||||
@@ -87,6 +82,8 @@ async def test_fixup_image_exists(
|
|||||||
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
docker: DockerAPI, coresys: CoreSys, install_addon_ssh: Addon
|
||||||
):
|
):
|
||||||
"""Test fixup dismisses if image exists."""
|
"""Test fixup dismisses if image exists."""
|
||||||
|
docker.images.get.return_value = MagicMock()
|
||||||
|
|
||||||
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
addon_execute_repair = FixupAddonExecuteRepair(coresys)
|
||||||
assert addon_execute_repair.auto is True
|
assert addon_execute_repair.auto is True
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user