From 207b665e1d608cf70d33e6360b5583647231cce0 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Fri, 22 Aug 2025 04:41:10 -0400 Subject: [PATCH] Send progress updates during image pull for install/update (#6102) * Send progress updates during image pull for install/update * Add extra to tests about job APIs * Sent out of date progress to sentry and combine done event * Pulling container image layer --- supervisor/docker/const.py | 50 ++- supervisor/docker/interface.py | 126 +++++++- supervisor/docker/manager.py | 12 +- supervisor/exceptions.py | 4 + supervisor/jobs/__init__.py | 37 ++- tests/api/test_jobs.py | 11 + tests/backups/test_manager.py | 1 + tests/docker/test_interface.py | 212 ++++++++++++- tests/fixtures/docker_pull_image_log.json | 351 ++++++++++++++++++++++ tests/homeassistant/test_core.py | 4 +- tests/jobs/test_job_decorator.py | 1 + tests/jobs/test_job_manager.py | 6 + tests/plugins/test_plugin_base.py | 4 +- 13 files changed, 799 insertions(+), 20 deletions(-) create mode 100644 tests/fixtures/docker_pull_image_log.json diff --git a/supervisor/docker/const.py b/supervisor/docker/const.py index bde3752e4..f8658d021 100644 --- a/supervisor/docker/const.py +++ b/supervisor/docker/const.py @@ -1,7 +1,10 @@ """Docker constants.""" -from enum import StrEnum +from contextlib import suppress +from enum import Enum, StrEnum +from functools import total_ordering from pathlib import PurePath +from typing import Self, cast from docker.types import Mount @@ -67,6 +70,51 @@ class PropagationMode(StrEnum): RSLAVE = "rslave" +@total_ordering +class PullImageLayerStage(Enum): + """Job stages for pulling an image layer. + + These are a subset of the statuses in a docker pull image log. They + are the standardized ones that are the most useful to us. + """ + + PULLING_FS_LAYER = 1, "Pulling fs layer" + DOWNLOADING = 2, "Downloading" + VERIFYING_CHECKSUM = 3, "Verifying Checksum" + DOWNLOAD_COMPLETE = 4, "Download complete" + EXTRACTING = 5, "Extracting" + PULL_COMPLETE = 6, "Pull complete" + + def __init__(self, order: int, status: str) -> None: + """Set fields from values.""" + self.order = order + self.status = status + + def __eq__(self, value: object, /) -> bool: + """Check equality, allow StrEnum style comparisons on status.""" + with suppress(AttributeError): + return self.status == cast(PullImageLayerStage, value).status + return self.status == value + + def __lt__(self, other: object) -> bool: + """Order instances.""" + with suppress(AttributeError): + return self.order < cast(PullImageLayerStage, other).order + return False + + def __hash__(self) -> int: + """Hash instance.""" + return hash(self.status) + + @classmethod + def from_status(cls, status: str) -> Self | None: + """Return stage instance from pull log status.""" + for i in cls: + if i.status == status: + return i + return None + + ENV_TIME = "TZ" ENV_TOKEN = "SUPERVISOR_TOKEN" ENV_TOKEN_OLD = "HASSIO_TOKEN" diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 574bdef1d..5faf90492 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -19,6 +19,7 @@ from docker.models.containers import Container from docker.models.images import Image import requests +from ..bus import EventListener from ..const import ( ATTR_PASSWORD, ATTR_REGISTRY, @@ -35,17 +36,19 @@ from ..exceptions import ( DockerAPIError, DockerError, DockerJobError, + DockerLogOutOfOrder, DockerNotFound, DockerRequestError, DockerTrustError, ) +from ..jobs import SupervisorJob from ..jobs.const import JOB_GROUP_DOCKER_INTERFACE, JobConcurrency from ..jobs.decorator import Job from ..jobs.job_group import JobGroup from ..resolution.const import ContextType, IssueType, SuggestionType from ..utils.sentry import async_capture_exception -from .const import ContainerState, RestartPolicy -from .manager import CommandReturn +from .const import ContainerState, PullImageLayerStage, RestartPolicy +from .manager import CommandReturn, PullLogEntry from .monitor import DockerContainerStateEvent from .stats import DockerStats @@ -217,6 +220,106 @@ class DockerInterface(JobGroup, ABC): await self.sys_run_in_executor(self.sys_docker.docker.login, **credentials) + def _process_pull_image_log(self, job_id: str, reference: PullLogEntry) -> None: + """Process events fired from a docker while pulling an image, filtered to a given job id.""" + if ( + reference.job_id != job_id + or not reference.id + or not reference.status + or not (stage := PullImageLayerStage.from_status(reference.status)) + ): + return + + # Pulling FS Layer is our marker for a layer that needs to be downloaded and extracted. Otherwise it already exists and we can ignore + job: SupervisorJob | None = None + if stage == PullImageLayerStage.PULLING_FS_LAYER: + job = self.sys_jobs.new_job( + name="Pulling container image layer", + initial_stage=stage.status, + reference=reference.id, + parent_id=job_id, + ) + job.done = False + return + + # Find our sub job to update details of + for j in self.sys_jobs.jobs: + if j.parent_id == job_id and j.reference == reference.id: + job = j + break + + # This likely only occurs if the logs came in out of sync and we got progress before the Pulling FS Layer one + if not job: + raise DockerLogOutOfOrder( + f"Received pull image log with status {reference.status} for image id {reference.id} and parent job {job_id} but could not find a matching job, skipping", + _LOGGER.debug, + ) + + # Hopefully these come in order but if they sometimes get out of sync, avoid accidentally going backwards + # If it happens a lot though we may need to reconsider the value of this feature + if job.done: + raise DockerLogOutOfOrder( + f"Received pull image log with status {reference.status} for job {job.uuid} but job was done, skipping", + _LOGGER.debug, + ) + + if job.stage and stage < PullImageLayerStage.from_status(job.stage): + raise DockerLogOutOfOrder( + f"Received pull image log with status {reference.status} for job {job.uuid} but job was already on stage {job.stage}, skipping", + _LOGGER.debug, + ) + + # For progress calcuation we assume downloading and extracting are each 50% of the time and others stages negligible + progress = job.progress + match stage: + case PullImageLayerStage.DOWNLOADING | PullImageLayerStage.EXTRACTING: + if ( + reference.progress_detail + and reference.progress_detail.current + and reference.progress_detail.total + ): + progress = 50 * ( + reference.progress_detail.current + / reference.progress_detail.total + ) + if stage == PullImageLayerStage.EXTRACTING: + progress += 50 + case ( + PullImageLayerStage.VERIFYING_CHECKSUM + | PullImageLayerStage.DOWNLOAD_COMPLETE + ): + progress = 50 + case PullImageLayerStage.PULL_COMPLETE: + progress = 100 + + if progress < job.progress: + raise DockerLogOutOfOrder( + f"Received pull image log with status {reference.status} for job {job.uuid} that implied progress was {progress} but current progress is {job.progress}, skipping", + _LOGGER.debug, + ) + + # Our filters have all passed. Time to update the job + # Only downloading and extracting have progress details. Use that to set extra + # We'll leave it around on other stages as the total bytes may be useful after that stage + if ( + stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING} + and reference.progress_detail + ): + job.update( + progress=progress, + stage=stage.status, + extra={ + "current": reference.progress_detail.current, + "total": reference.progress_detail.total, + }, + ) + else: + job.update( + progress=progress, + stage=stage.status, + done=stage == PullImageLayerStage.PULL_COMPLETE, + ) + @Job( name="docker_interface_install", on_condition=DockerJobError, @@ -235,6 +338,7 @@ class DockerInterface(JobGroup, ABC): raise ValueError("Cannot pull without an image!") image_arch = str(arch) if arch else self.sys_arch.supervisor + listener: EventListener | None = None _LOGGER.info("Downloading docker image %s with tag %s.", image, version) try: @@ -242,9 +346,24 @@ class DockerInterface(JobGroup, ABC): # Try login if we have defined credentials await self._docker_login(image) + job_id = self.sys_jobs.current.uuid + + async def process_pull_image_log(reference: PullLogEntry) -> None: + try: + self._process_pull_image_log(job_id, reference) + except DockerLogOutOfOrder as err: + # Send all these to sentry. Missing a few progress updates + # shouldn't matter to users but matters to us + await async_capture_exception(err) + + listener = self.sys_bus.register_event( + BusEvent.DOCKER_IMAGE_PULL_UPDATE, process_pull_image_log + ) + # Pull new image docker_image = await self.sys_run_in_executor( self.sys_docker.pull_image, + self.sys_jobs.current.uuid, image, str(version), platform=MAP_ARCH[image_arch], @@ -297,6 +416,9 @@ class DockerInterface(JobGroup, ABC): f"Error happened on Content-Trust check for {image}:{version!s}: {err!s}", _LOGGER.error, ) from err + finally: + if listener: + self.sys_bus.remove_listener(listener) self._meta = docker_image.attrs diff --git a/supervisor/docker/manager.py b/supervisor/docker/manager.py index 1c58f8d19..e67bbc934 100644 --- a/supervisor/docker/manager.py +++ b/supervisor/docker/manager.py @@ -129,6 +129,7 @@ class PullLogEntry: exactly matches "error". As that is redundant, skipping for now. """ + job_id: str # Not part of the docker object. Used to link log entries to supervisor jobs id: str | None = None status: str | None = None progress: str | None = None @@ -136,9 +137,10 @@ class PullLogEntry: error: str | None = None @classmethod - def from_pull_log_dict(cls, value: dict[str, Any]) -> PullLogEntry: + def from_pull_log_dict(cls, job_id: str, value: dict[str, Any]) -> PullLogEntry: """Convert pull progress log dictionary into instance.""" return cls( + job_id=job_id, id=value.get("id"), status=value.get("status"), progress=value.get("progress"), @@ -376,7 +378,11 @@ class DockerAPI(CoreSysAttributes): return container def pull_image( - self, repository: str, tag: str = "latest", platform: str | None = None + self, + job_id: str, + repository: str, + tag: str = "latest", + platform: str | None = None, ) -> Image: """Pull the specified image and return it. @@ -391,7 +397,7 @@ class DockerAPI(CoreSysAttributes): repository, tag=tag, platform=platform, stream=True, decode=True ) for e in pull_log: - entry = PullLogEntry.from_pull_log_dict(e) + entry = PullLogEntry.from_pull_log_dict(job_id, e) if entry.error: raise entry.exception self.sys_loop.call_soon_threadsafe( diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index 2b68fcf58..a527ee249 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -556,6 +556,10 @@ class DockerNotFound(DockerError): """Docker object don't Exists.""" +class DockerLogOutOfOrder(DockerError): + """Raise when log from docker action was out of order.""" + + class DockerNoSpaceOnDevice(DockerError): """Raise if a docker pull fails due to available space.""" diff --git a/supervisor/jobs/__init__.py b/supervisor/jobs/__init__.py index 5ce52de1d..d47f27002 100644 --- a/supervisor/jobs/__init__.py +++ b/supervisor/jobs/__init__.py @@ -20,6 +20,7 @@ from ..exceptions import HassioError, JobNotFound, JobStartException from ..homeassistant.const import WSEvent from ..utils.common import FileConfiguration from ..utils.dt import utcnow +from ..utils.sentinel import DEFAULT from .const import ATTR_IGNORE_CONDITIONS, FILE_CONFIG_JOBS, JobCondition from .validate import SCHEMA_JOBS_CONFIG @@ -103,14 +104,13 @@ class SupervisorJob: ) parent_id: str | None = field(factory=_CURRENT_JOB.get, on_setattr=frozen) done: bool | None = field(init=False, default=None, on_setattr=_on_change) - on_change: Callable[["SupervisorJob", Attribute, Any], None] | None = field( - default=None, on_setattr=frozen - ) + on_change: Callable[["SupervisorJob", Attribute, Any], None] | None = None internal: bool = field(default=False) errors: list[SupervisorJobError] = field( init=False, factory=list, on_setattr=_on_change ) release_event: asyncio.Event | None = None + extra: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: """Return dictionary representation.""" @@ -124,6 +124,7 @@ class SupervisorJob: "parent_id": self.parent_id, "errors": [err.as_dict() for err in self.errors], "created": self.created.isoformat(), + "extra": self.extra, } def capture_error(self, err: HassioError | None = None) -> None: @@ -157,6 +158,30 @@ class SupervisorJob: if token: _CURRENT_JOB.reset(token) + def update( + self, + progress: float | None = None, + stage: str | None = None, + extra: dict[str, Any] | None = DEFAULT, # type: ignore + done: bool | None = None, + ) -> None: + """Update multiple fields with one on change event.""" + on_change = self.on_change + self.on_change = None + + if progress is not None: + self.progress = progress + if stage is not None: + self.stage = stage + if extra != DEFAULT: + self.extra = extra + if done is not None: + self.done = done + + self.on_change = on_change + # Just triggers the normal on change + self.reference = self.reference + class JobManager(FileConfiguration, CoreSysAttributes): """Job Manager class.""" @@ -224,7 +249,7 @@ class JobManager(FileConfiguration, CoreSysAttributes): reference: str | None = None, initial_stage: str | None = None, internal: bool = False, - no_parent: bool = False, + parent_id: str | None = DEFAULT, # type: ignore ) -> SupervisorJob: """Create a new job.""" job = SupervisorJob( @@ -233,7 +258,7 @@ class JobManager(FileConfiguration, CoreSysAttributes): stage=initial_stage, on_change=None if internal else self._notify_on_job_change, internal=internal, - **({"parent_id": None} if no_parent else {}), + **({} if parent_id == DEFAULT else {"parent_id": parent_id}), # type: ignore ) self._jobs[job.uuid] = job return job @@ -267,7 +292,7 @@ class JobManager(FileConfiguration, CoreSysAttributes): **kwargs, ) -> tuple[SupervisorJob, asyncio.Task | asyncio.TimerHandle]: """Schedule a job to run later and return job and task or timer handle.""" - job = self.new_job(no_parent=True) + job = self.new_job(parent_id=None) def _wrap_task() -> asyncio.Task: return self.sys_create_task( diff --git a/tests/api/test_jobs.py b/tests/api/test_jobs.py index 40219b7d6..e7d1af8f3 100644 --- a/tests/api/test_jobs.py +++ b/tests/api/test_jobs.py @@ -112,6 +112,7 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys "done": False, "child_jobs": [], "errors": [], + "extra": None, }, { "created": ANY, @@ -122,6 +123,7 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys "stage": None, "done": False, "errors": [], + "extra": None, "child_jobs": [ { "created": ANY, @@ -133,6 +135,7 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys "done": False, "child_jobs": [], "errors": [], + "extra": None, }, ], }, @@ -154,6 +157,7 @@ async def test_jobs_tree_representation(api_client: TestClient, coresys: CoreSys "done": True, "child_jobs": [], "errors": [], + "extra": None, }, ] await outer_task @@ -196,6 +200,7 @@ async def test_job_manual_cleanup(api_client: TestClient, coresys: CoreSys): "done": False, "child_jobs": [], "errors": [], + "extra": None, } # Only done jobs can be deleted via API @@ -282,6 +287,7 @@ async def test_jobs_sorted(api_client: TestClient, coresys: CoreSys): "done": True, "errors": [], "child_jobs": [], + "extra": None, }, { "created": ANY, @@ -292,6 +298,7 @@ async def test_jobs_sorted(api_client: TestClient, coresys: CoreSys): "stage": None, "done": True, "errors": [], + "extra": None, "child_jobs": [ { "created": ANY, @@ -303,6 +310,7 @@ async def test_jobs_sorted(api_client: TestClient, coresys: CoreSys): "done": True, "errors": [], "child_jobs": [], + "extra": None, }, { "created": ANY, @@ -314,6 +322,7 @@ async def test_jobs_sorted(api_client: TestClient, coresys: CoreSys): "done": True, "errors": [], "child_jobs": [], + "extra": None, }, ], }, @@ -359,6 +368,7 @@ async def test_job_with_error( "progress": 0, "stage": "test", "done": True, + "extra": None, "errors": [ { "type": "SupervisorError", @@ -375,6 +385,7 @@ async def test_job_with_error( "progress": 0, "stage": None, "done": True, + "extra": None, "errors": [ { "type": "SupervisorError", diff --git a/tests/backups/test_manager.py b/tests/backups/test_manager.py index 38e032778..bc17a3d6a 100644 --- a/tests/backups/test_manager.py +++ b/tests/backups/test_manager.py @@ -1126,6 +1126,7 @@ def _make_backup_message_for_assert( "parent_id": None, "errors": [], "created": ANY, + "extra": None, }, }, } diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index d028b14fd..f588d3afe 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -2,7 +2,7 @@ import asyncio from typing import Any -from unittest.mock import MagicMock, Mock, PropertyMock, call, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, PropertyMock, call, patch from awesomeversion import AwesomeVersion from docker.errors import DockerException, NotFound @@ -12,7 +12,7 @@ import pytest from requests import RequestException from supervisor.addons.manager import Addon -from supervisor.const import BusEvent, CpuArch +from supervisor.const import BusEvent, CoreState, CpuArch from supervisor.coresys import CoreSys from supervisor.docker.const import ContainerState from supervisor.docker.interface import DockerInterface @@ -25,6 +25,10 @@ from supervisor.exceptions import ( DockerNotFound, DockerRequestError, ) +from supervisor.homeassistant.const import WSEvent +from supervisor.jobs import JobSchedulerOptions, SupervisorJob + +from tests.common import load_json_fixture @pytest.fixture(autouse=True) @@ -346,58 +350,256 @@ async def test_install_fires_progress_events( await asyncio.sleep(1) assert events == [ PullLogEntry( + job_id=ANY, status="Pulling from home-assistant/odroid-n2-homeassistant", id="2025.7.2", ), PullLogEntry( + job_id=ANY, status="Already exists", progress_detail=PullProgressDetail(), id="6e771e15690e", ), PullLogEntry( + job_id=ANY, status="Pulling fs layer", progress_detail=PullProgressDetail(), id="1578b14a573c", ), PullLogEntry( - status="Waiting", progress_detail=PullProgressDetail(), id="2488d0e401e1" + job_id=ANY, + status="Waiting", + progress_detail=PullProgressDetail(), + id="2488d0e401e1", ), PullLogEntry( + job_id=ANY, status="Downloading", progress_detail=PullProgressDetail(current=1378, total=1486), progress="[==============================================> ] 1.378kB/1.486kB", id="1578b14a573c", ), PullLogEntry( + job_id=ANY, status="Download complete", progress_detail=PullProgressDetail(), id="1578b14a573c", ), PullLogEntry( + job_id=ANY, status="Extracting", progress_detail=PullProgressDetail(current=1486, total=1486), progress="[==================================================>] 1.486kB/1.486kB", id="1578b14a573c", ), PullLogEntry( + job_id=ANY, status="Pull complete", progress_detail=PullProgressDetail(), id="1578b14a573c", ), PullLogEntry( + job_id=ANY, status="Verifying Checksum", progress_detail=PullProgressDetail(), id="6a1e931d8f88", ), PullLogEntry( - status="Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d" + job_id=ANY, + status="Digest: sha256:490080d7da0f385928022927990e04f604615f7b8c622ef3e58253d0f089881d", ), PullLogEntry( - status="Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.2" + job_id=ANY, + status="Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.2", ), ] +async def test_install_sends_progress_to_home_assistant( + coresys: CoreSys, test_docker_interface: DockerInterface, ha_ws_client: AsyncMock +): + """Test progress events are sent as job updates to Home Assistant.""" + coresys.core.set_state(CoreState.RUNNING) + coresys.docker.docker.api.pull.return_value = load_json_fixture( + "docker_pull_image_log.json" + ) + + with ( + patch.object( + type(coresys.supervisor), "arch", PropertyMock(return_value="i386") + ), + ): + # Schedule job so we can listen for the end. Then we can assert against the WS mock + event = asyncio.Event() + job, install_task = coresys.jobs.schedule_job( + test_docker_interface.install, + JobSchedulerOptions(), + AwesomeVersion("1.2.3"), + "test", + ) + + async def listen_for_job_end(reference: SupervisorJob): + if reference.uuid != job.uuid: + return + event.set() + + coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end) + await install_task + await event.wait() + + events = [ + evt.args[0]["data"]["data"] + for evt in ha_ws_client.async_send_command.call_args_list + if "data" in evt.args[0] and evt.args[0]["data"]["event"] == WSEvent.JOB + ] + assert events[0]["name"] == "docker_interface_install" + assert events[0]["uuid"] == job.uuid + assert events[0]["done"] is None + assert events[1]["name"] == "docker_interface_install" + assert events[1]["uuid"] == job.uuid + assert events[1]["done"] is False + assert events[-1]["name"] == "docker_interface_install" + assert events[-1]["uuid"] == job.uuid + assert events[-1]["done"] is True + + def make_sub_log(layer_id: str): + return [ + { + "stage": evt["stage"], + "progress": evt["progress"], + "done": evt["done"], + "extra": evt["extra"], + } + for evt in events + if evt["name"] == "Pulling container image layer" + and evt["reference"] == layer_id + and evt["parent_id"] == job.uuid + ] + + layer_1_log = make_sub_log("1e214cd6d7d0") + layer_2_log = make_sub_log("1a38e1d5e18d") + assert len(layer_1_log) == 20 + assert len(layer_2_log) == 19 + assert len(events) == 42 + assert layer_1_log == [ + {"stage": "Pulling fs layer", "progress": 0, "done": False, "extra": None}, + { + "stage": "Downloading", + "progress": 0.1, + "done": False, + "extra": {"current": 539462, "total": 436480882}, + }, + { + "stage": "Downloading", + "progress": 0.6, + "done": False, + "extra": {"current": 4864838, "total": 436480882}, + }, + { + "stage": "Downloading", + "progress": 0.9, + "done": False, + "extra": {"current": 7552896, "total": 436480882}, + }, + { + "stage": "Downloading", + "progress": 1.2, + "done": False, + "extra": {"current": 10252544, "total": 436480882}, + }, + { + "stage": "Downloading", + "progress": 2.9, + "done": False, + "extra": {"current": 25369792, "total": 436480882}, + }, + { + "stage": "Downloading", + "progress": 11.9, + "done": False, + "extra": {"current": 103619904, "total": 436480882}, + }, + { + "stage": "Downloading", + "progress": 26.1, + "done": False, + "extra": {"current": 227726144, "total": 436480882}, + }, + { + "stage": "Downloading", + "progress": 49.6, + "done": False, + "extra": {"current": 433170048, "total": 436480882}, + }, + { + "stage": "Verifying Checksum", + "progress": 50, + "done": False, + "extra": {"current": 433170048, "total": 436480882}, + }, + { + "stage": "Download complete", + "progress": 50, + "done": False, + "extra": {"current": 433170048, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 50.1, + "done": False, + "extra": {"current": 557056, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 60.3, + "done": False, + "extra": {"current": 89686016, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 70.0, + "done": False, + "extra": {"current": 174358528, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 80.0, + "done": False, + "extra": {"current": 261816320, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 88.4, + "done": False, + "extra": {"current": 334790656, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 94.0, + "done": False, + "extra": {"current": 383811584, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 99.9, + "done": False, + "extra": {"current": 435617792, "total": 436480882}, + }, + { + "stage": "Extracting", + "progress": 100.0, + "done": False, + "extra": {"current": 436480882, "total": 436480882}, + }, + { + "stage": "Pull complete", + "progress": 100.0, + "done": True, + "extra": {"current": 436480882, "total": 436480882}, + }, + ] + + @pytest.mark.parametrize( ("error_log", "exc_type", "exc_msg"), [ diff --git a/tests/fixtures/docker_pull_image_log.json b/tests/fixtures/docker_pull_image_log.json new file mode 100644 index 000000000..8f0672664 --- /dev/null +++ b/tests/fixtures/docker_pull_image_log.json @@ -0,0 +1,351 @@ +[ + { + "status": "Pulling from home-assistant/odroid-n2-homeassistant", + "id": "2025.7.1" + }, + { + "status": "Already exists", + "progressDetail": {}, + "id": "6e771e15690e" + }, + { + "status": "Already exists", + "progressDetail": {}, + "id": "58da640818f4" + }, + { + "status": "Pulling fs layer", + "progressDetail": {}, + "id": "1e214cd6d7d0" + }, + { + "status": "Pulling fs layer", + "progressDetail": {}, + "id": "1a38e1d5e18d" + }, + { + "status": "Waiting", + "progressDetail": {}, + "id": "1e214cd6d7d0" + }, + { + "status": "Waiting", + "progressDetail": {}, + "id": "1a38e1d5e18d" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 539462, + "total": 436480882 + }, + "progress": "[> ] 539.5kB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 262144, + "total": 26132657 + }, + "progress": "[> ] 262.1kB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 4864838, + "total": 436480882 + }, + "progress": "[> ] 4.865MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 6580032, + "total": 26132657 + }, + "progress": "[============> ] 6.58MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 7552896, + "total": 436480882 + }, + "progress": "[> ] 7.553MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 18688896, + "total": 26132657 + }, + "progress": "[===================================> ] 18.69MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 10252544, + "total": 436480882 + }, + "progress": "[=> ] 10.25MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 25815872, + "total": 26132657 + }, + "progress": "[=================================================> ] 25.82MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Verifying Checksum", + "progressDetail": {}, + "id": "1a38e1d5e18d" + }, + { + "status": "Download complete", + "progressDetail": {}, + "id": "1a38e1d5e18d" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 25369792, + "total": 436480882 + }, + "progress": "[==> ] 25.37MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 103619904, + "total": 436480882 + }, + "progress": "[===========> ] 103.6MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 227726144, + "total": 436480882 + }, + "progress": "[==========================> ] 227.7MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 433170048, + "total": 436480882 + }, + "progress": "[=================================================> ] 433.2MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Verifying Checksum", + "progressDetail": {}, + "id": "1e214cd6d7d0" + }, + { + "status": "Download complete", + "progressDetail": {}, + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 557056, + "total": 436480882 + }, + "progress": "[> ] 557.1kB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 89686016, + "total": 436480882 + }, + "progress": "[==========> ] 89.69MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 174358528, + "total": 436480882 + }, + "progress": "[===================> ] 174.4MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 261816320, + "total": 436480882 + }, + "progress": "[=============================> ] 261.8MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 334790656, + "total": 436480882 + }, + "progress": "[======================================> ] 334.8MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 383811584, + "total": 436480882 + }, + "progress": "[===========================================> ] 383.8MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 435617792, + "total": 436480882 + }, + "progress": "[=================================================> ] 435.6MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 436480882, + "total": 436480882 + }, + "progress": "[==================================================>] 436.5MB/436.5MB", + "id": "1e214cd6d7d0" + }, + { + "status": "Pull complete", + "progressDetail": {}, + "id": "1e214cd6d7d0" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 262144, + "total": 26132657 + }, + "progress": "[> ] 262.1kB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 2621440, + "total": 26132657 + }, + "progress": "[=====> ] 2.621MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 6029312, + "total": 26132657 + }, + "progress": "[===========> ] 6.029MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 8650752, + "total": 26132657 + }, + "progress": "[================> ] 8.651MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 11010048, + "total": 26132657 + }, + "progress": "[=====================> ] 11.01MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 13369344, + "total": 26132657 + }, + "progress": "[=========================> ] 13.37MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 15990784, + "total": 26132657 + }, + "progress": "[==============================> ] 15.99MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 18350080, + "total": 26132657 + }, + "progress": "[===================================> ] 18.35MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 20709376, + "total": 26132657 + }, + "progress": "[=======================================> ] 20.71MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 23068672, + "total": 26132657 + }, + "progress": "[============================================> ] 23.07MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 26132657, + "total": 26132657 + }, + "progress": "[==================================================>] 26.13MB/26.13MB", + "id": "1a38e1d5e18d" + }, + { + "status": "Pull complete", + "progressDetail": {}, + "id": "1a38e1d5e18d" + }, + { + "status": "Digest: sha256:7d97da645f232f82a768d0a537e452536719d56d484d419836e53dbe3e4ec736" + }, + { + "status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1" + } +] \ No newline at end of file diff --git a/tests/homeassistant/test_core.py b/tests/homeassistant/test_core.py index 54e2804f2..029258cc5 100644 --- a/tests/homeassistant/test_core.py +++ b/tests/homeassistant/test_core.py @@ -1,7 +1,7 @@ """Test Home Assistant core.""" from datetime import datetime, timedelta -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch from awesomeversion import AwesomeVersion from docker.errors import APIError, DockerException, ImageNotFound, NotFound @@ -408,6 +408,7 @@ async def test_core_loads_wrong_image_for_machine( "force": True, } coresys.docker.pull_image.assert_called_once_with( + ANY, "ghcr.io/home-assistant/qemux86-64-homeassistant", "2024.4.0", platform="linux/amd64", @@ -456,6 +457,7 @@ async def test_core_loads_wrong_image_for_architecture( "force": True, } coresys.docker.pull_image.assert_called_once_with( + ANY, "ghcr.io/home-assistant/qemux86-64-homeassistant", "2024.4.0", platform="linux/amd64", diff --git a/tests/jobs/test_job_decorator.py b/tests/jobs/test_job_decorator.py index de44bf03f..ad49203e6 100644 --- a/tests/jobs/test_job_decorator.py +++ b/tests/jobs/test_job_decorator.py @@ -993,6 +993,7 @@ async def test_internal_jobs_no_notify(coresys: CoreSys, ha_ws_client: AsyncMock "parent_id": None, "errors": [], "created": ANY, + "extra": None, }, }, } diff --git a/tests/jobs/test_job_manager.py b/tests/jobs/test_job_manager.py index b868deb01..b5730611a 100644 --- a/tests/jobs/test_job_manager.py +++ b/tests/jobs/test_job_manager.py @@ -105,6 +105,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock): "parent_id": None, "errors": [], "created": ANY, + "extra": None, }, }, } @@ -127,6 +128,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock): "parent_id": None, "errors": [], "created": ANY, + "extra": None, }, }, } @@ -149,6 +151,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock): "parent_id": None, "errors": [], "created": ANY, + "extra": None, }, }, } @@ -171,6 +174,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock): "parent_id": None, "errors": [], "created": ANY, + "extra": None, }, }, } @@ -199,6 +203,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock): } ], "created": ANY, + "extra": None, }, }, } @@ -226,6 +231,7 @@ async def test_notify_on_change(coresys: CoreSys, ha_ws_client: AsyncMock): } ], "created": ANY, + "extra": None, }, }, } diff --git a/tests/plugins/test_plugin_base.py b/tests/plugins/test_plugin_base.py index 68f0fff94..ae0df9265 100644 --- a/tests/plugins/test_plugin_base.py +++ b/tests/plugins/test_plugin_base.py @@ -1,7 +1,7 @@ """Test base plugin functionality.""" import asyncio -from unittest.mock import MagicMock, Mock, PropertyMock, patch +from unittest.mock import ANY, MagicMock, Mock, PropertyMock, patch from awesomeversion import AwesomeVersion import pytest @@ -373,7 +373,7 @@ async def test_load_with_incorrect_image( "force": True, } coresys.docker.pull_image.assert_called_once_with( - correct_image, "2024.4.0", platform="linux/amd64" + ANY, correct_image, "2024.4.0", platform="linux/amd64" ) assert plugin.image == correct_image