From ef63083c08e9c9145e1a2e456c1f96cf08e19643 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:42:10 +0000 Subject: [PATCH] Support Docker containerd snapshotter for image extraction progress Co-authored-by: agners <34061+agners@users.noreply.github.com> --- supervisor/docker/interface.py | 28 +++- tests/docker/test_interface.py | 47 +++++++ .../docker_pull_image_log_containerd.json | 122 ++++++++++++++++++ 3 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/docker_pull_image_log_containerd.json diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index ffcf519cd..99fe30ff9 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -309,18 +309,30 @@ class DockerInterface(JobGroup, ABC): stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING} and reference.progress_detail ): + # For containerd snapshotter, extracting phase has total=None + # In that case, use the download_total from the downloading phase + current_extra: dict[str, Any] = job.extra if job.extra else {} + if ( + stage == PullImageLayerStage.DOWNLOADING + and reference.progress_detail.total + ): + # Store download total for use in extraction phase with containerd snapshotter + current_extra["download_total"] = reference.progress_detail.total + job.update( progress=progress, stage=stage.status, extra={ "current": reference.progress_detail.current, - "total": reference.progress_detail.total, + "total": reference.progress_detail.total + or current_extra.get("download_total"), + "download_total": current_extra.get("download_total"), }, ) else: # If we reach DOWNLOAD_COMPLETE without ever having set extra (small layers that skip # the downloading phase), set a minimal extra so aggregate progress calculation can proceed - extra = job.extra + extra: dict[str, Any] | None = job.extra if stage == PullImageLayerStage.DOWNLOAD_COMPLETE and not job.extra: extra = {"current": 1, "total": 1} @@ -346,7 +358,11 @@ class DockerInterface(JobGroup, ABC): for job in layer_jobs: if not job.extra: return - total += job.extra["total"] + # Use download_total if available (for containerd snapshotter), otherwise use total + layer_total = job.extra.get("download_total") or job.extra.get("total") + if layer_total is None: + return + total += layer_total install_job.extra = {"total": total} else: total = install_job.extra["total"] @@ -357,7 +373,11 @@ class DockerInterface(JobGroup, ABC): for job in layer_jobs: if not job.extra: return - progress += job.progress * (job.extra["total"] / total) + # Use download_total if available (for containerd snapshotter), otherwise use total + layer_total = job.extra.get("download_total") or job.extra.get("total") + if layer_total is None: + return + progress += job.progress * (layer_total / total) job_stage = PullImageLayerStage.from_status(cast(str, job.stage)) if job_stage < PullImageLayerStage.EXTRACTING: diff --git a/tests/docker/test_interface.py b/tests/docker/test_interface.py index ade64b7c4..51d27875b 100644 --- a/tests/docker/test_interface.py +++ b/tests/docker/test_interface.py @@ -675,3 +675,50 @@ async def test_install_progress_handles_layers_skipping_download( assert job.done is True assert job.progress == 100 capture_exception.assert_not_called() + + +async def test_install_progress_handles_containerd_snapshotter( + coresys: CoreSys, + test_docker_interface: DockerInterface, + capture_exception: Mock, +): + """Test install handles containerd snapshotter format where extraction has no total bytes. + + With containerd snapshotter, the extraction phase reports time elapsed in seconds + rather than bytes extracted. The progress_detail has format: + {"current": , "units": "s"} with total=None + + This test ensures we handle this gracefully by using the download size for + aggregate progress calculation. + """ + coresys.core.set_state(CoreState.RUNNING) + + # Fixture emulates containerd snapshotter pull log format + coresys.docker.docker.api.pull.return_value = load_json_fixture( + "docker_pull_image_log_containerd.json" + ) + + with patch.object( + type(coresys.supervisor), "arch", PropertyMock(return_value="i386") + ): + 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() + + # Job should complete successfully without exceptions + assert job.done is True + assert job.progress == 100 + capture_exception.assert_not_called() diff --git a/tests/fixtures/docker_pull_image_log_containerd.json b/tests/fixtures/docker_pull_image_log_containerd.json new file mode 100644 index 000000000..47d7ffd04 --- /dev/null +++ b/tests/fixtures/docker_pull_image_log_containerd.json @@ -0,0 +1,122 @@ +[ + { + "status": "Pulling from home-assistant/test-image", + "id": "2025.7.1" + }, + { + "status": "Pulling fs layer", + "progressDetail": {}, + "id": "layer1" + }, + { + "status": "Pulling fs layer", + "progressDetail": {}, + "id": "layer2" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 1048576, + "total": 5178461 + }, + "progress": "[===========> ] 1.049MB/5.178MB", + "id": "layer1" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 5178461, + "total": 5178461 + }, + "progress": "[==================================================>] 5.178MB/5.178MB", + "id": "layer1" + }, + { + "status": "Download complete", + "progressDetail": { + "hidecounts": true + }, + "id": "layer1" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 1048576, + "total": 10485760 + }, + "progress": "[=====> ] 1.049MB/10.49MB", + "id": "layer2" + }, + { + "status": "Downloading", + "progressDetail": { + "current": 10485760, + "total": 10485760 + }, + "progress": "[==================================================>] 10.49MB/10.49MB", + "id": "layer2" + }, + { + "status": "Download complete", + "progressDetail": { + "hidecounts": true + }, + "id": "layer2" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 1, + "units": "s" + }, + "progress": "1 s", + "id": "layer1" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 5, + "units": "s" + }, + "progress": "5 s", + "id": "layer1" + }, + { + "status": "Pull complete", + "progressDetail": { + "hidecounts": true + }, + "id": "layer1" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 1, + "units": "s" + }, + "progress": "1 s", + "id": "layer2" + }, + { + "status": "Extracting", + "progressDetail": { + "current": 3, + "units": "s" + }, + "progress": "3 s", + "id": "layer2" + }, + { + "status": "Pull complete", + "progressDetail": { + "hidecounts": true + }, + "id": "layer2" + }, + { + "status": "Digest: sha256:abc123" + }, + { + "status": "Status: Downloaded newer image for test/image:2025.7.1" + } +]