Skip to content

Commit 78be155

Browse files
authored
Handle download retart in pull progres log (#6131)
1 parent 9900dfc commit 78be155

File tree

4 files changed

+291
-4
lines changed

4 files changed

+291
-4
lines changed

supervisor/docker/const.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
"""Docker constants."""
22

3+
from __future__ import annotations
4+
35
from contextlib import suppress
46
from enum import Enum, StrEnum
57
from functools import total_ordering
68
from pathlib import PurePath
7-
from typing import Self, cast
9+
import re
10+
from typing import cast
811

912
from docker.types import Mount
1013

1114
from ..const import MACHINE_ID
1215

16+
RE_RETRYING_DOWNLOAD_STATUS = re.compile(r"Retrying in \d+ seconds?")
17+
1318

1419
class Capabilities(StrEnum):
1520
"""Linux Capabilities."""
@@ -79,6 +84,7 @@ class PullImageLayerStage(Enum):
7984
"""
8085

8186
PULLING_FS_LAYER = 1, "Pulling fs layer"
87+
RETRYING_DOWNLOAD = 2, "Retrying download"
8288
DOWNLOADING = 2, "Downloading"
8389
VERIFYING_CHECKSUM = 3, "Verifying Checksum"
8490
DOWNLOAD_COMPLETE = 4, "Download complete"
@@ -107,11 +113,16 @@ def __hash__(self) -> int:
107113
return hash(self.status)
108114

109115
@classmethod
110-
def from_status(cls, status: str) -> Self | None:
116+
def from_status(cls, status: str) -> PullImageLayerStage | None:
111117
"""Return stage instance from pull log status."""
112118
for i in cls:
113119
if i.status == status:
114120
return i
121+
122+
# This one includes number of seconds until download so its not constant
123+
if RE_RETRYING_DOWNLOAD_STATUS.match(status):
124+
return cls.RETRYING_DOWNLOAD
125+
115126
return None
116127

117128

supervisor/docker/interface.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,16 +291,18 @@ def _process_pull_image_log(self, job_id: str, reference: PullLogEntry) -> None:
291291
progress = 50
292292
case PullImageLayerStage.PULL_COMPLETE:
293293
progress = 100
294+
case PullImageLayerStage.RETRYING_DOWNLOAD:
295+
progress = 0
294296

295-
if progress < job.progress:
297+
if stage != PullImageLayerStage.RETRYING_DOWNLOAD and progress < job.progress:
296298
raise DockerLogOutOfOrder(
297299
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",
298300
_LOGGER.debug,
299301
)
300302

301303
# Our filters have all passed. Time to update the job
302304
# Only downloading and extracting have progress details. Use that to set extra
303-
# We'll leave it around on other stages as the total bytes may be useful after that stage
305+
# We'll leave it around on later stages as the total bytes may be useful after that stage
304306
if (
305307
stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING}
306308
and reference.progress_detail
@@ -318,6 +320,9 @@ def _process_pull_image_log(self, job_id: str, reference: PullLogEntry) -> None:
318320
progress=progress,
319321
stage=stage.status,
320322
done=stage == PullImageLayerStage.PULL_COMPLETE,
323+
extra=None
324+
if stage == PullImageLayerStage.RETRYING_DOWNLOAD
325+
else job.extra,
321326
)
322327

323328
@Job(

tests/docker/test_interface.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,3 +774,140 @@ async def test_install_raises_on_pull_error(
774774

775775
with pytest.raises(exc_type, match=exc_msg):
776776
await test_docker_interface.install(AwesomeVersion("1.2.3"), "test")
777+
778+
779+
async def test_install_progress_handles_download_restart(
780+
coresys: CoreSys, test_docker_interface: DockerInterface, ha_ws_client: AsyncMock
781+
):
782+
"""Test install handles docker progress events that include a download restart."""
783+
coresys.core.set_state(CoreState.RUNNING)
784+
coresys.docker.docker.api.pull.return_value = load_json_fixture(
785+
"docker_pull_image_log_restart.json"
786+
)
787+
788+
with (
789+
patch.object(
790+
type(coresys.supervisor), "arch", PropertyMock(return_value="i386")
791+
),
792+
):
793+
# Schedule job so we can listen for the end. Then we can assert against the WS mock
794+
event = asyncio.Event()
795+
job, install_task = coresys.jobs.schedule_job(
796+
test_docker_interface.install,
797+
JobSchedulerOptions(),
798+
AwesomeVersion("1.2.3"),
799+
"test",
800+
)
801+
802+
async def listen_for_job_end(reference: SupervisorJob):
803+
if reference.uuid != job.uuid:
804+
return
805+
event.set()
806+
807+
coresys.bus.register_event(BusEvent.SUPERVISOR_JOB_END, listen_for_job_end)
808+
await install_task
809+
await event.wait()
810+
811+
events = [
812+
evt.args[0]["data"]["data"]
813+
for evt in ha_ws_client.async_send_command.call_args_list
814+
if "data" in evt.args[0] and evt.args[0]["data"]["event"] == WSEvent.JOB
815+
]
816+
817+
def make_sub_log(layer_id: str):
818+
return [
819+
{
820+
"stage": evt["stage"],
821+
"progress": evt["progress"],
822+
"done": evt["done"],
823+
"extra": evt["extra"],
824+
}
825+
for evt in events
826+
if evt["name"] == "Pulling container image layer"
827+
and evt["reference"] == layer_id
828+
and evt["parent_id"] == job.uuid
829+
]
830+
831+
layer_1_log = make_sub_log("1e214cd6d7d0")
832+
assert len(layer_1_log) == 14
833+
assert layer_1_log == [
834+
{"stage": "Pulling fs layer", "progress": 0, "done": False, "extra": None},
835+
{
836+
"stage": "Downloading",
837+
"progress": 11.9,
838+
"done": False,
839+
"extra": {"current": 103619904, "total": 436480882},
840+
},
841+
{
842+
"stage": "Downloading",
843+
"progress": 26.1,
844+
"done": False,
845+
"extra": {"current": 227726144, "total": 436480882},
846+
},
847+
{
848+
"stage": "Downloading",
849+
"progress": 49.6,
850+
"done": False,
851+
"extra": {"current": 433170048, "total": 436480882},
852+
},
853+
{
854+
"stage": "Retrying download",
855+
"progress": 0,
856+
"done": False,
857+
"extra": None,
858+
},
859+
{
860+
"stage": "Retrying download",
861+
"progress": 0,
862+
"done": False,
863+
"extra": None,
864+
},
865+
{
866+
"stage": "Downloading",
867+
"progress": 11.9,
868+
"done": False,
869+
"extra": {"current": 103619904, "total": 436480882},
870+
},
871+
{
872+
"stage": "Downloading",
873+
"progress": 26.1,
874+
"done": False,
875+
"extra": {"current": 227726144, "total": 436480882},
876+
},
877+
{
878+
"stage": "Downloading",
879+
"progress": 49.6,
880+
"done": False,
881+
"extra": {"current": 433170048, "total": 436480882},
882+
},
883+
{
884+
"stage": "Verifying Checksum",
885+
"progress": 50,
886+
"done": False,
887+
"extra": {"current": 433170048, "total": 436480882},
888+
},
889+
{
890+
"stage": "Download complete",
891+
"progress": 50,
892+
"done": False,
893+
"extra": {"current": 433170048, "total": 436480882},
894+
},
895+
{
896+
"stage": "Extracting",
897+
"progress": 80.0,
898+
"done": False,
899+
"extra": {"current": 261816320, "total": 436480882},
900+
},
901+
{
902+
"stage": "Extracting",
903+
"progress": 100.0,
904+
"done": False,
905+
"extra": {"current": 436480882, "total": 436480882},
906+
},
907+
{
908+
"stage": "Pull complete",
909+
"progress": 100.0,
910+
"done": True,
911+
"extra": {"current": 436480882, "total": 436480882},
912+
},
913+
]
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
[
2+
{
3+
"status": "Pulling from home-assistant/odroid-n2-homeassistant",
4+
"id": "2025.7.1"
5+
},
6+
{
7+
"status": "Already exists",
8+
"progressDetail": {},
9+
"id": "6e771e15690e"
10+
},
11+
{
12+
"status": "Already exists",
13+
"progressDetail": {},
14+
"id": "58da640818f4"
15+
},
16+
{
17+
"status": "Pulling fs layer",
18+
"progressDetail": {},
19+
"id": "1e214cd6d7d0"
20+
},
21+
{
22+
"status": "Already exists",
23+
"progressDetail": {},
24+
"id": "1a38e1d5e18d"
25+
},
26+
{
27+
"status": "Waiting",
28+
"progressDetail": {},
29+
"id": "1e214cd6d7d0"
30+
},
31+
{
32+
"status": "Downloading",
33+
"progressDetail": {
34+
"current": 103619904,
35+
"total": 436480882
36+
},
37+
"progress": "[===========> ] 103.6MB/436.5MB",
38+
"id": "1e214cd6d7d0"
39+
},
40+
{
41+
"status": "Downloading",
42+
"progressDetail": {
43+
"current": 227726144,
44+
"total": 436480882
45+
},
46+
"progress": "[==========================> ] 227.7MB/436.5MB",
47+
"id": "1e214cd6d7d0"
48+
},
49+
{
50+
"status": "Downloading",
51+
"progressDetail": {
52+
"current": 433170048,
53+
"total": 436480882
54+
},
55+
"progress": "[=================================================> ] 433.2MB/436.5MB",
56+
"id": "1e214cd6d7d0"
57+
},
58+
{
59+
"status": "Retrying in 2 seconds",
60+
"progressDetail": {},
61+
"id": "1e214cd6d7d0"
62+
},
63+
{
64+
"status": "Retrying in 1 seconds",
65+
"progressDetail": {},
66+
"id": "1e214cd6d7d0"
67+
},
68+
{
69+
"status": "Downloading",
70+
"progressDetail": {
71+
"current": 103619904,
72+
"total": 436480882
73+
},
74+
"progress": "[===========> ] 103.6MB/436.5MB",
75+
"id": "1e214cd6d7d0"
76+
},
77+
{
78+
"status": "Downloading",
79+
"progressDetail": {
80+
"current": 227726144,
81+
"total": 436480882
82+
},
83+
"progress": "[==========================> ] 227.7MB/436.5MB",
84+
"id": "1e214cd6d7d0"
85+
},
86+
{
87+
"status": "Downloading",
88+
"progressDetail": {
89+
"current": 433170048,
90+
"total": 436480882
91+
},
92+
"progress": "[=================================================> ] 433.2MB/436.5MB",
93+
"id": "1e214cd6d7d0"
94+
},
95+
{
96+
"status": "Verifying Checksum",
97+
"progressDetail": {},
98+
"id": "1e214cd6d7d0"
99+
},
100+
{
101+
"status": "Download complete",
102+
"progressDetail": {},
103+
"id": "1e214cd6d7d0"
104+
},
105+
{
106+
"status": "Extracting",
107+
"progressDetail": {
108+
"current": 261816320,
109+
"total": 436480882
110+
},
111+
"progress": "[=============================> ] 261.8MB/436.5MB",
112+
"id": "1e214cd6d7d0"
113+
},
114+
{
115+
"status": "Extracting",
116+
"progressDetail": {
117+
"current": 436480882,
118+
"total": 436480882
119+
},
120+
"progress": "[==================================================>] 436.5MB/436.5MB",
121+
"id": "1e214cd6d7d0"
122+
},
123+
{
124+
"status": "Pull complete",
125+
"progressDetail": {},
126+
"id": "1e214cd6d7d0"
127+
},
128+
{
129+
"status": "Digest: sha256:7d97da645f232f82a768d0a537e452536719d56d484d419836e53dbe3e4ec736"
130+
},
131+
{
132+
"status": "Status: Downloaded newer image for ghcr.io/home-assistant/odroid-n2-homeassistant:2025.7.1"
133+
}
134+
]

0 commit comments

Comments
 (0)