Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
aiodns==3.5.0
aiodocker==0.24.0
aiohttp==3.13.1
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
Expand Down
7 changes: 5 additions & 2 deletions supervisor/bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from asyncio import Task
from collections.abc import Callable, Coroutine
import logging
from typing import Any
Expand Down Expand Up @@ -38,11 +39,13 @@ def register_event(
self._listeners.setdefault(event, []).append(listener)
return listener

def fire_event(self, event: BusEvent, reference: Any) -> None:
def fire_event(self, event: BusEvent, reference: Any) -> list[Task]:
"""Fire an event to the bus."""
_LOGGER.debug("Fire event '%s' with '%s'", event, reference)
tasks: list[Task] = []
for listener in self._listeners.get(event, []):
self.sys_create_task(listener.callback(reference))
tasks.append(self.sys_create_task(listener.callback(reference)))
return tasks

def remove_listener(self, listener: EventListener) -> None:
"""Unregister an listener."""
Expand Down
36 changes: 20 additions & 16 deletions supervisor/docker/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, cast

import aiodocker
from attr import evolve
from awesomeversion import AwesomeVersion
import docker
Expand Down Expand Up @@ -717,19 +718,21 @@ def build_image():
error_message = f"Docker build failed for {addon_image_tag} (exit code {result.exit_code}). Build output:\n{logs}"
raise docker.errors.DockerException(error_message)

addon_image = self.sys_docker.images.get(addon_image_tag)

return addon_image, logs
return addon_image_tag, logs

try:
docker_image, log = await self.sys_run_in_executor(build_image)
addon_image_tag, log = await self.sys_run_in_executor(build_image)

_LOGGER.debug("Build %s:%s done: %s", self.image, version, log)

# Update meta data
self._meta = docker_image.attrs
self._meta = await self.sys_docker.images.inspect(addon_image_tag)

except (docker.errors.DockerException, requests.RequestException) as err:
except (
docker.errors.DockerException,
requests.RequestException,
aiodocker.DockerError,
) as err:
_LOGGER.error("Can't build %s:%s: %s", self.image, version, err)
raise DockerError() from err

Expand All @@ -751,11 +754,8 @@ def export_image(self, tar_file: Path) -> None:
)
async def import_image(self, tar_file: Path) -> None:
"""Import a tar file as image."""
docker_image = await self.sys_run_in_executor(
self.sys_docker.import_image, tar_file
)
if docker_image:
self._meta = docker_image.attrs
if docker_image := await self.sys_docker.import_image(tar_file):
self._meta = docker_image
_LOGGER.info("Importing image %s and version %s", tar_file, self.version)

with suppress(DockerError):
Expand All @@ -769,17 +769,21 @@ async def cleanup(
version: AwesomeVersion | None = None,
) -> None:
"""Check if old version exists and cleanup other versions of image not in use."""
await self.sys_run_in_executor(
self.sys_docker.cleanup_old_images,
(image := image or self.image),
version or self.version,
if not (use_image := image or self.image):
raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
if not (use_version := version or self.version):
raise DockerError("Cannot determine version from metadata!", _LOGGER.error)

await self.sys_docker.cleanup_old_images(
use_image,
use_version,
{old_image} if old_image else None,
keep_images={
f"{addon.image}:{addon.version}"
for addon in self.sys_addons.installed
if addon.slug != self.addon.slug
and addon.image
and addon.image in {old_image, image}
and addon.image in {old_image, use_image}
},
)

Expand Down
12 changes: 5 additions & 7 deletions supervisor/docker/homeassistant.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Init file for Supervisor Docker object."""

from collections.abc import Awaitable
from ipaddress import IPv4Address
import logging
import re
Expand Down Expand Up @@ -236,13 +235,12 @@ async def execute_command(self, command: str) -> CommandReturn:
environment={ENV_TIME: self.sys_timezone},
)

def is_initialize(self) -> Awaitable[bool]:
async def is_initialize(self) -> bool:
"""Return True if Docker container exists."""
return self.sys_run_in_executor(
self.sys_docker.container_is_initialized,
self.name,
self.image,
self.sys_homeassistant.version,
if not self.sys_homeassistant.version:
return False
return await self.sys_docker.container_is_initialized(
self.name, self.image, self.sys_homeassistant.version
)

async def _validate_trust(self, image_id: str) -> None:
Expand Down
109 changes: 62 additions & 47 deletions supervisor/docker/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@
from collections import defaultdict
from collections.abc import Awaitable
from contextlib import suppress
from http import HTTPStatus
import logging
import re
from time import time
from typing import Any, cast
from uuid import uuid4

import aiodocker
from awesomeversion import AwesomeVersion
from awesomeversion.strategy import AwesomeVersionStrategy
import docker
from docker.models.containers import Container
from docker.models.images import Image
import requests

from ..bus import EventListener
Expand All @@ -35,6 +36,7 @@
CodeNotaryUntrusted,
DockerAPIError,
DockerError,
DockerHubRateLimitExceeded,
DockerJobError,
DockerLogOutOfOrder,
DockerNotFound,
Expand Down Expand Up @@ -218,7 +220,7 @@ async def _docker_login(self, image: str) -> None:
if not credentials:
return

await self.sys_run_in_executor(self.sys_docker.docker.login, **credentials)
await self.sys_run_in_executor(self.sys_docker.dockerpy.login, **credentials)

def _process_pull_image_log(
self, install_job_id: str, reference: PullLogEntry
Expand Down Expand Up @@ -417,8 +419,7 @@ async def process_pull_image_log(reference: PullLogEntry) -> None:
)

# Pull new image
docker_image = await self.sys_run_in_executor(
self.sys_docker.pull_image,
docker_image = await self.sys_docker.pull_image(
self.sys_jobs.current.uuid,
image,
str(version),
Expand All @@ -427,13 +428,11 @@ async def process_pull_image_log(reference: PullLogEntry) -> None:

# Validate content
try:
await self._validate_trust(cast(str, docker_image.id))
await self._validate_trust(cast(str, docker_image["Id"]))
except CodeNotaryError:
with suppress(docker.errors.DockerException):
await self.sys_run_in_executor(
self.sys_docker.images.remove,
image=f"{image}:{version!s}",
force=True,
with suppress(aiodocker.DockerError, requests.RequestException):
await self.sys_docker.images.delete(
f"{image}:{version!s}", force=True
)
raise

Expand All @@ -442,22 +441,37 @@ async def process_pull_image_log(reference: PullLogEntry) -> None:
_LOGGER.info(
"Tagging image %s with version %s as latest", image, version
)
await self.sys_run_in_executor(docker_image.tag, image, tag="latest")
await self.sys_docker.images.tag(
docker_image["Id"], image, tag="latest"
)
except docker.errors.APIError as err:
if err.status_code == 429:
if err.status_code == HTTPStatus.TOO_MANY_REQUESTS:
self.sys_resolution.create_issue(
IssueType.DOCKER_RATELIMIT,
ContextType.SYSTEM,
suggestions=[SuggestionType.REGISTRY_LOGIN],
)
_LOGGER.info(
"Your IP address has made too many requests to Docker Hub which activated a rate limit. "
"For more details see https://www.home-assistant.io/more-info/dockerhub-rate-limit"
raise DockerHubRateLimitExceeded(_LOGGER.error) from err
await async_capture_exception(err)
raise DockerError(
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
) from err
except aiodocker.DockerError as err:
if err.status == HTTPStatus.TOO_MANY_REQUESTS:
self.sys_resolution.create_issue(
IssueType.DOCKER_RATELIMIT,
ContextType.SYSTEM,
suggestions=[SuggestionType.REGISTRY_LOGIN],
)
raise DockerHubRateLimitExceeded(_LOGGER.error) from err
await async_capture_exception(err)
raise DockerError(
f"Can't install {image}:{version!s}: {err}", _LOGGER.error
) from err
except (docker.errors.DockerException, requests.RequestException) as err:
except (
docker.errors.DockerException,
requests.RequestException,
) as err:
await async_capture_exception(err)
raise DockerError(
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
Expand All @@ -476,14 +490,12 @@ async def process_pull_image_log(reference: PullLogEntry) -> None:
if listener:
self.sys_bus.remove_listener(listener)

self._meta = docker_image.attrs
self._meta = docker_image

async def exists(self) -> bool:
"""Return True if Docker image exists in local repository."""
with suppress(docker.errors.DockerException, requests.RequestException):
await self.sys_run_in_executor(
self.sys_docker.images.get, f"{self.image}:{self.version!s}"
)
with suppress(aiodocker.DockerError, requests.RequestException):
await self.sys_docker.images.inspect(f"{self.image}:{self.version!s}")
return True
return False

Expand Down Expand Up @@ -542,11 +554,11 @@ async def attach(
),
)

with suppress(docker.errors.DockerException, requests.RequestException):
with suppress(aiodocker.DockerError, requests.RequestException):
if not self._meta and self.image:
self._meta = self.sys_docker.images.get(
self._meta = await self.sys_docker.images.inspect(
f"{self.image}:{version!s}"
).attrs
)

# Successful?
if not self._meta:
Expand Down Expand Up @@ -614,14 +626,17 @@ def start(self) -> Awaitable[None]:
)
async def remove(self, *, remove_image: bool = True) -> None:
"""Remove Docker images."""
if not self.image or not self.version:
raise DockerError(
"Cannot determine image and/or version from metadata!", _LOGGER.error
)

# Cleanup container
with suppress(DockerError):
await self.stop()

if remove_image:
await self.sys_run_in_executor(
self.sys_docker.remove_image, self.image, self.version
)
await self.sys_docker.remove_image(self.image, self.version)

self._meta = None

Expand All @@ -643,18 +658,16 @@ async def check_image(
image_name = f"{expected_image}:{version!s}"
if self.image == expected_image:
try:
image: Image = await self.sys_run_in_executor(
self.sys_docker.images.get, image_name
)
except (docker.errors.DockerException, requests.RequestException) as err:
image = await self.sys_docker.images.inspect(image_name)
except (aiodocker.DockerError, requests.RequestException) as err:
raise DockerError(
f"Could not get {image_name} for check due to: {err!s}",
_LOGGER.error,
) from err

image_arch = f"{image.attrs['Os']}/{image.attrs['Architecture']}"
if "Variant" in image.attrs:
image_arch = f"{image_arch}/{image.attrs['Variant']}"
image_arch = f"{image['Os']}/{image['Architecture']}"
if "Variant" in image:
image_arch = f"{image_arch}/{image['Variant']}"

# If we have an image and its the right arch, all set
# It seems that newer Docker version return a variant for arm64 images.
Expand Down Expand Up @@ -716,11 +729,13 @@ async def cleanup(
version: AwesomeVersion | None = None,
) -> None:
"""Check if old version exists and cleanup."""
await self.sys_run_in_executor(
self.sys_docker.cleanup_old_images,
image or self.image,
version or self.version,
{old_image} if old_image else None,
if not (use_image := image or self.image):
raise DockerError("Cannot determine image from metadata!", _LOGGER.error)
if not (use_version := version or self.version):
raise DockerError("Cannot determine version from metadata!", _LOGGER.error)

await self.sys_docker.cleanup_old_images(
use_image, use_version, {old_image} if old_image else None
)

@Job(
Expand Down Expand Up @@ -772,10 +787,10 @@ async def get_latest_version(self) -> AwesomeVersion:
"""Return latest version of local image."""
available_version: list[AwesomeVersion] = []
try:
for image in await self.sys_run_in_executor(
self.sys_docker.images.list, self.image
for image in await self.sys_docker.images.list(
filters=f'{{"reference": ["{self.image}"]}}'
):
for tag in image.tags:
for tag in image["RepoTags"]:
version = AwesomeVersion(tag.partition(":")[2])
if version.strategy == AwesomeVersionStrategy.UNKNOWN:
continue
Expand All @@ -784,7 +799,7 @@ async def get_latest_version(self) -> AwesomeVersion:
if not available_version:
raise ValueError()

except (docker.errors.DockerException, ValueError) as err:
except (aiodocker.DockerError, ValueError) as err:
raise DockerNotFound(
f"No version found for {self.image}", _LOGGER.info
) from err
Expand Down Expand Up @@ -823,10 +838,10 @@ async def _validate_trust(self, image_id: str) -> None:
async def check_trust(self) -> None:
"""Check trust of exists Docker image."""
try:
image = await self.sys_run_in_executor(
self.sys_docker.images.get, f"{self.image}:{self.version!s}"
image = await self.sys_docker.images.inspect(
f"{self.image}:{self.version!s}"
)
except (docker.errors.DockerException, requests.RequestException):
except (aiodocker.DockerError, requests.RequestException):
return

await self._validate_trust(cast(str, image.id))
await self._validate_trust(cast(str, image["Id"]))
Loading
Loading