Bind libraries to different files and refactor images.pull

This commit is contained in:
Mike Degatano
2025-10-27 16:39:52 +00:00
parent 33c09a7a96
commit 94d1f520ba
11 changed files with 174 additions and 70 deletions

View File

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

View File

@@ -452,11 +452,23 @@ class DockerInterface(JobGroup, ABC):
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 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 (
aiodocker.DockerError,
docker.errors.DockerException,
requests.RequestException,
) as err:

View File

@@ -17,6 +17,7 @@ from typing import Any, Final, Self, cast
import aiodocker
from aiodocker.images import DockerImages
from aiohttp import ClientSession, ClientTimeout, UnixConnector
import attr
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
from docker import errors as docker_errors
@@ -211,7 +212,10 @@ class DockerAPI(CoreSysAttributes):
# We keep both until we can fully refactor to aiodocker
self._dockerpy: DockerClient | None = None
self.docker: aiodocker.Docker = aiodocker.Docker(
url=f"unix:/{str(SOCKET_DOCKER)}", api_version="auto"
url="unix://localhost", # dummy hostname for URL composition
connector=(connector := UnixConnector(SOCKET_DOCKER.as_posix())),
session=ClientSession(connector=connector, timeout=ClientTimeout(900)),
api_version="auto",
)
self._network: DockerNetwork | None = None
@@ -221,11 +225,13 @@ class DockerAPI(CoreSysAttributes):
async def post_init(self) -> Self:
"""Post init actions that must be done in event loop."""
# Use /var/run/docker.sock for this one so aiodocker and dockerpy don't
# share the same handle. Temporary fix while refactoring this client out
self._dockerpy = await asyncio.get_running_loop().run_in_executor(
None,
partial(
DockerClient,
base_url=f"unix:/{str(SOCKET_DOCKER)}",
base_url=f"unix://var{SOCKET_DOCKER.as_posix()}",
version="auto",
timeout=900,
),
@@ -433,20 +439,16 @@ class DockerAPI(CoreSysAttributes):
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.
"""
def api_pull():
pull_log = self.dockerpy.api.pull(
repository, tag=tag, platform=platform, stream=True, decode=True
)
for e in pull_log:
async for e in self.images.pull(
repository, tag=tag, platform=platform, stream=True
):
entry = PullLogEntry.from_pull_log_dict(job_id, e)
if entry.error:
raise entry.exception
self.sys_loop.call_soon_threadsafe(
self.sys_bus.fire_event, BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry
await asyncio.gather(
*self.sys_bus.fire_event(BusEvent.DOCKER_IMAGE_PULL_UPDATE, entry)
)
await self.sys_run_in_executor(api_pull)
sep = "@" if tag.startswith("sha256:") else ":"
return await self.images.inspect(f"{repository}{sep}{tag}")

View File

@@ -9,7 +9,7 @@ from unittest.mock import MagicMock, PropertyMock, call, patch
import aiodocker
from awesomeversion import AwesomeVersion
from docker.errors import DockerException, NotFound
from docker.errors import APIError, DockerException, NotFound
import pytest
from securetar import SecureTarFile
@@ -931,7 +931,7 @@ async def test_addon_loads_missing_image(
@pytest.mark.parametrize(
"pull_image_exc",
[DockerException(), aiodocker.DockerError(400, {"message": "error"})],
[APIError("error"), aiodocker.DockerError(400, {"message": "error"})],
)
@pytest.mark.usefixtures("container", "mock_amd64_arch_supported")
async def test_addon_load_succeeds_with_docker_errors(
@@ -973,7 +973,7 @@ async def test_addon_load_succeeds_with_docker_errors(
caplog.clear()
with patch.object(DockerAPI, "pull_image", side_effect=pull_image_exc):
await install_addon_ssh.load()
assert "Unknown error with test/amd64-addon-ssh:9.2.1" in caplog.text
assert "Can't install test/amd64-addon-ssh:9.2.1:" in caplog.text
async def test_addon_manual_only_boot(coresys: CoreSys, install_addon_example: Addon):

View File

@@ -19,7 +19,7 @@ from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.homeassistant.module import HomeAssistant
from tests.api import common_test_api_advanced_logs
from tests.common import load_json_fixture
from tests.common import AsyncIterator, load_json_fixture
@pytest.mark.parametrize("legacy_route", [True, False])
@@ -283,9 +283,9 @@ async def test_api_progress_updates_home_assistant_update(
"""Test progress updates sent to Home Assistant for updates."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.core.set_state(CoreState.RUNNING)
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
"docker_pull_image_log.json"
)
logs = load_json_fixture("docker_pull_image_log.json")
coresys.docker.images.pull.return_value = AsyncIterator(logs)
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
with (

View File

@@ -24,7 +24,7 @@ from supervisor.homeassistant.module import HomeAssistant
from supervisor.store.addon import AddonStore
from supervisor.store.repository import Repository
from tests.common import load_json_fixture
from tests.common import AsyncIterator, load_json_fixture
from tests.const import TEST_ADDON_SLUG
REPO_URL = "https://github.com/awesome-developer/awesome-repo"
@@ -732,9 +732,10 @@ async def test_api_progress_updates_addon_install_update(
"""Test progress updates sent to Home Assistant for installs/updates."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.core.set_state(CoreState.RUNNING)
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
"docker_pull_image_log.json"
)
logs = load_json_fixture("docker_pull_image_log.json")
coresys.docker.images.pull.return_value = AsyncIterator(logs)
coresys.arch._supported_arch = ["amd64"] # pylint: disable=protected-access
install_addon_example.data_store["version"] = AwesomeVersion("2.0.0")

View File

@@ -19,7 +19,7 @@ from supervisor.supervisor import Supervisor
from supervisor.updater import Updater
from tests.api import common_test_api_advanced_logs
from tests.common import load_json_fixture
from tests.common import AsyncIterator, load_json_fixture
from tests.dbus_service_mocks.base import DBusServiceMock
from tests.dbus_service_mocks.os_agent import OSAgent as OSAgentService
@@ -332,9 +332,9 @@ async def test_api_progress_updates_supervisor_update(
"""Test progress updates sent to Home Assistant for updates."""
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
coresys.core.set_state(CoreState.RUNNING)
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
"docker_pull_image_log.json"
)
logs = load_json_fixture("docker_pull_image_log.json")
coresys.docker.images.pull.return_value = AsyncIterator(logs)
with (
patch.object(

View File

@@ -1,13 +1,14 @@
"""Common test functions."""
import asyncio
from collections.abc import Sequence
from datetime import datetime
from functools import partial
from importlib import import_module
from inspect import getclosurevars
import json
from pathlib import Path
from typing import Any
from typing import Any, Self
from dbus_fast.aio.message_bus import MessageBus
@@ -145,3 +146,22 @@ class MockResponse:
async def __aexit__(self, exc_type, exc, tb):
"""Exit the context manager."""
class AsyncIterator:
"""Make list/fixture into async iterator for test mocks."""
def __init__(self, seq: Sequence[Any]) -> None:
"""Initialize with sequence."""
self.iter = iter(seq)
def __aiter__(self) -> Self:
"""Implement aiter."""
return self
async def __anext__(self) -> Any:
"""Return next in sequence."""
try:
return next(self.iter)
except StopIteration:
raise StopAsyncIteration() from None

View File

@@ -56,6 +56,7 @@ from supervisor.store.repository import Repository
from supervisor.utils.dt import utcnow
from .common import (
AsyncIterator,
MockResponse,
load_binary_fixture,
load_fixture,
@@ -125,14 +126,8 @@ async def docker() -> DockerAPI:
patch(
"supervisor.docker.manager.DockerAPI.containers", return_value=MagicMock()
),
patch(
"supervisor.docker.manager.DockerAPI.api",
return_value=(api_mock := MagicMock()),
),
patch(
"supervisor.docker.manager.DockerAPI.info",
return_value=MagicMock(),
),
patch("supervisor.docker.manager.DockerAPI.api", return_value=MagicMock()),
patch("supervisor.docker.manager.DockerAPI.info", return_value=MagicMock()),
patch("supervisor.docker.manager.DockerAPI.unload"),
patch("supervisor.docker.manager.aiodocker.Docker", return_value=MagicMock()),
patch(
@@ -153,13 +148,12 @@ async def docker() -> DockerAPI:
{"stream": "Loaded image: test:latest\n"}
]
docker_images.pull.return_value = AsyncIterator([{}])
docker_obj.info.logging = "journald"
docker_obj.info.storage = "overlay2"
docker_obj.info.version = AwesomeVersion("1.0.0")
# Need an iterable for logs
api_mock.pull.return_value = []
yield docker_obj

View File

@@ -28,7 +28,7 @@ from supervisor.exceptions import (
)
from supervisor.jobs import JobSchedulerOptions, SupervisorJob
from tests.common import load_json_fixture
from tests.common import AsyncIterator, load_json_fixture
@pytest.fixture(autouse=True)
@@ -59,8 +59,8 @@ async def test_docker_image_platform(
"""Test platform set correctly from arch."""
coresys.docker.images.inspect.return_value = {"Id": "test:1.2.3"}
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test", arch=cpu_arch)
coresys.docker.dockerpy.api.pull.assert_called_once_with(
"test", tag="1.2.3", platform=platform, stream=True, decode=True
coresys.docker.images.pull.assert_called_once_with(
"test", tag="1.2.3", platform=platform, stream=True
)
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
@@ -76,8 +76,8 @@ async def test_docker_image_default_platform(
),
):
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
coresys.docker.dockerpy.api.pull.assert_called_once_with(
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
coresys.docker.images.pull.assert_called_once_with(
"test", tag="1.2.3", platform="linux/386", stream=True
)
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
@@ -276,8 +276,9 @@ async def test_install_fires_progress_events(
coresys: CoreSys, test_docker_interface: DockerInterface
):
"""Test progress events are fired during an install for listeners."""
# This is from a sample pull. Filtered log to just one per unique status for test
coresys.docker.dockerpy.api.pull.return_value = [
logs = [
{
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
"id": "2025.7.2",
@@ -299,7 +300,11 @@ async def test_install_fires_progress_events(
"id": "1578b14a573c",
},
{"status": "Pull complete", "progressDetail": {}, "id": "1578b14a573c"},
{"status": "Verifying Checksum", "progressDetail": {}, "id": "6a1e931d8f88"},
{
"status": "Verifying Checksum",
"progressDetail": {},
"id": "6a1e931d8f88",
},
{
"status": "Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d"
},
@@ -307,6 +312,7 @@ async def test_install_fires_progress_events(
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.2"
},
]
coresys.docker.images.pull.return_value = AsyncIterator(logs)
events: list[PullLogEntry] = []
@@ -321,8 +327,8 @@ async def test_install_fires_progress_events(
),
):
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
coresys.docker.dockerpy.api.pull.assert_called_once_with(
"test", tag="1.2.3", platform="linux/386", stream=True, decode=True
coresys.docker.images.pull.assert_called_once_with(
"test", tag="1.2.3", platform="linux/386", stream=True
)
coresys.docker.images.inspect.assert_called_once_with("test:1.2.3")
@@ -402,10 +408,11 @@ async def test_install_progress_rounding_does_not_cause_misses(
):
"""Test extremely close progress events do not create rounding issues."""
coresys.core.set_state(CoreState.RUNNING)
# Current numbers chosen to create a rounding issue with original code
# Where a progress update came in with a value between the actual previous
# value and what it was rounded to. It should not raise an out of order exception
coresys.docker.dockerpy.api.pull.return_value = [
logs = [
{
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
"id": "2025.7.1",
@@ -445,6 +452,7 @@ async def test_install_progress_rounding_does_not_cause_misses(
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1"
},
]
coresys.docker.images.pull.return_value = AsyncIterator(logs)
with (
patch.object(
@@ -500,7 +508,8 @@ async def test_install_raises_on_pull_error(
exc_msg: str,
):
"""Test exceptions raised from errors in pull log."""
coresys.docker.dockerpy.api.pull.return_value = [
logs = [
{
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
"id": "2025.7.2",
@@ -513,6 +522,7 @@ async def test_install_raises_on_pull_error(
},
error_log,
]
coresys.docker.images.pull.return_value = AsyncIterator(logs)
with pytest.raises(exc_type, match=exc_msg):
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
@@ -526,11 +536,11 @@ async def test_install_progress_handles_download_restart(
):
"""Test install handles docker progress events that include a download restart."""
coresys.core.set_state(CoreState.RUNNING)
# Fixture emulates a download restart as it docker logs it
# A log out of order exception should not be raised
coresys.docker.dockerpy.api.pull.return_value = load_json_fixture(
"docker_pull_image_log_restart.json"
)
logs = load_json_fixture("docker_pull_image_log_restart.json")
coresys.docker.images.pull.return_value = AsyncIterator(logs)
with (
patch.object(

View File

@@ -1,12 +1,14 @@
"""Test Home Assistant core."""
from datetime import datetime, timedelta
from http import HTTPStatus
from unittest.mock import ANY, MagicMock, Mock, PropertyMock, call, patch
import aiodocker
from awesomeversion import AwesomeVersion
from docker.errors import APIError, DockerException, NotFound
import pytest
from requests import RequestException
from time_machine import travel
from supervisor.const import CpuArch
@@ -24,8 +26,12 @@ from supervisor.exceptions import (
from supervisor.homeassistant.api import APIState
from supervisor.homeassistant.core import HomeAssistantCore
from supervisor.homeassistant.module import HomeAssistant
from supervisor.resolution.const import ContextType, IssueType
from supervisor.resolution.data import Issue
from supervisor.updater import Updater
from tests.common import AsyncIterator
async def test_update_fails_if_out_of_date(coresys: CoreSys):
"""Test update of Home Assistant fails when supervisor or plugin is out of date."""
@@ -53,11 +59,23 @@ async def test_update_fails_if_out_of_date(coresys: CoreSys):
await coresys.homeassistant.core.update()
async def test_install_landingpage_docker_error(
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
@pytest.mark.parametrize(
"err",
[
aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}),
APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)),
],
)
async def test_install_landingpage_docker_ratelimit_error(
coresys: CoreSys,
capture_exception: Mock,
caplog: pytest.LogCaptureFixture,
err: Exception,
):
"""Test install landing page fails due to docker error."""
"""Test install landing page fails due to docker ratelimit error."""
coresys.security.force = True
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
with (
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
patch.object(
@@ -70,19 +88,35 @@ async def test_install_landingpage_docker_error(
),
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
):
coresys.docker.dockerpy.api.pull.side_effect = [APIError("fail"), MagicMock()]
await coresys.homeassistant.core.install_landingpage()
sleep.assert_awaited_once_with(30)
assert "Failed to install landingpage, retrying after 30sec" in caplog.text
capture_exception.assert_not_called()
assert (
Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM)
in coresys.resolution.issues
)
@pytest.mark.parametrize(
"err",
[
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}),
APIError("fail"),
DockerException(),
RequestException(),
OSError(),
],
)
async def test_install_landingpage_other_error(
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
coresys: CoreSys,
capture_exception: Mock,
caplog: pytest.LogCaptureFixture,
err: Exception,
):
"""Test install landing page fails due to other error."""
coresys.docker.images.inspect.side_effect = [(err := OSError()), MagicMock()]
coresys.docker.images.inspect.side_effect = [err, MagicMock()]
with (
patch.object(DockerHomeAssistant, "attach", side_effect=DockerError),
@@ -103,11 +137,23 @@ async def test_install_landingpage_other_error(
capture_exception.assert_called_once_with(err)
async def test_install_docker_error(
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
@pytest.mark.parametrize(
"err",
[
aiodocker.DockerError(HTTPStatus.TOO_MANY_REQUESTS, {"message": "ratelimit"}),
APIError("ratelimit", MagicMock(status_code=HTTPStatus.TOO_MANY_REQUESTS)),
],
)
async def test_install_docker_ratelimit_error(
coresys: CoreSys,
capture_exception: Mock,
caplog: pytest.LogCaptureFixture,
err: Exception,
):
"""Test install fails due to docker error."""
"""Test install fails due to docker ratelimit error."""
coresys.security.force = True
coresys.docker.images.pull.side_effect = [err, AsyncIterator([{}])]
with (
patch.object(HomeAssistantCore, "start"),
patch.object(DockerHomeAssistant, "cleanup"),
@@ -124,19 +170,35 @@ async def test_install_docker_error(
),
patch("supervisor.homeassistant.core.asyncio.sleep") as sleep,
):
coresys.docker.dockerpy.api.pull.side_effect = [APIError("fail"), MagicMock()]
await coresys.homeassistant.core.install()
sleep.assert_awaited_once_with(30)
assert "Error on Home Assistant installation. Retrying in 30sec" in caplog.text
capture_exception.assert_not_called()
assert (
Issue(IssueType.DOCKER_RATELIMIT, ContextType.SYSTEM)
in coresys.resolution.issues
)
@pytest.mark.parametrize(
"err",
[
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, {"message": "fail"}),
APIError("fail"),
DockerException(),
RequestException(),
OSError(),
],
)
async def test_install_other_error(
coresys: CoreSys, capture_exception: Mock, caplog: pytest.LogCaptureFixture
coresys: CoreSys,
capture_exception: Mock,
caplog: pytest.LogCaptureFixture,
err: Exception,
):
"""Test install fails due to other error."""
coresys.docker.images.inspect.side_effect = [(err := OSError()), MagicMock()]
coresys.docker.images.inspect.side_effect = [err, MagicMock()]
with (
patch.object(HomeAssistantCore, "start"),