Skip to content

Commit 190b734

Browse files
authored
Add progress reporting to addon, HA and Supervisor updates (#6195)
* Add progress reporting to addon, HA and Supervisor updates * Fix assert in test * Add progress to addon, core, supervisor updates/installs * Fix double install bug in addons install * Remove initial_install and re-arrange order of load
1 parent 559b698 commit 190b734

File tree

10 files changed

+419
-368
lines changed

10 files changed

+419
-368
lines changed

supervisor/addons/addon.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ async def load(self) -> None:
226226
)
227227

228228
await self._check_ingress_port()
229+
229230
default_image = self._image(self.data)
230231
try:
231232
await self.instance.attach(version=self.version)
@@ -774,7 +775,6 @@ async def install(self) -> None:
774775
raise AddonsError("Missing from store, cannot install!")
775776

776777
await self.sys_addons.data.install(self.addon_store)
777-
await self.load()
778778

779779
def setup_data():
780780
if not self.path_data.is_dir():
@@ -797,6 +797,9 @@ def setup_data():
797797
await self.sys_addons.data.uninstall(self)
798798
raise AddonsError() from err
799799

800+
# Finish initialization and set up listeners
801+
await self.load()
802+
800803
# Add to addon manager
801804
self.sys_addons.local[self.slug] = self
802805

supervisor/addons/manager.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99

1010
from attr import evolve
1111

12-
from supervisor.jobs.const import JobConcurrency
13-
1412
from ..const import AddonBoot, AddonStartup, AddonState
1513
from ..coresys import CoreSys, CoreSysAttributes
1614
from ..exceptions import (
@@ -21,6 +19,8 @@
2119
DockerError,
2220
HassioError,
2321
)
22+
from ..jobs import ChildJobSyncFilter
23+
from ..jobs.const import JobConcurrency
2424
from ..jobs.decorator import Job, JobCondition
2525
from ..resolution.const import ContextType, IssueType, SuggestionType
2626
from ..store.addon import AddonStore
@@ -182,6 +182,9 @@ async def shutdown(self, stage: AddonStartup) -> None:
182182
conditions=ADDON_UPDATE_CONDITIONS,
183183
on_condition=AddonsJobError,
184184
concurrency=JobConcurrency.QUEUE,
185+
child_job_syncs=[
186+
ChildJobSyncFilter("docker_interface_install", progress_allocation=1.0)
187+
],
185188
)
186189
async def install(
187190
self, slug: str, *, validation_complete: asyncio.Event | None = None
@@ -229,6 +232,13 @@ async def uninstall(self, slug: str, *, remove_config: bool = False) -> None:
229232
name="addon_manager_update",
230233
conditions=ADDON_UPDATE_CONDITIONS,
231234
on_condition=AddonsJobError,
235+
# We assume for now the docker image pull is 100% of this task for progress
236+
# allocation. But from a user perspective that isn't true. Other steps
237+
# that take time which is not accounted for in progress include:
238+
# partial backup, image cleanup, apparmor update, and addon restart
239+
child_job_syncs=[
240+
ChildJobSyncFilter("docker_interface_install", progress_allocation=1.0)
241+
],
232242
)
233243
async def update(
234244
self,
@@ -271,7 +281,10 @@ async def update(
271281
addons=[addon.slug],
272282
)
273283

274-
return await addon.update()
284+
task = await addon.update()
285+
286+
_LOGGER.info("Add-on '%s' successfully updated", slug)
287+
return task
275288

276289
@Job(
277290
name="addon_manager_rebuild",

supervisor/docker/interface.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,12 @@ async def _docker_login(self, image: str) -> None:
220220

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

223-
def _process_pull_image_log(self, job_id: str, reference: PullLogEntry) -> None:
223+
def _process_pull_image_log(
224+
self, install_job_id: str, reference: PullLogEntry
225+
) -> None:
224226
"""Process events fired from a docker while pulling an image, filtered to a given job id."""
225227
if (
226-
reference.job_id != job_id
228+
reference.job_id != install_job_id
227229
or not reference.id
228230
or not reference.status
229231
or not (stage := PullImageLayerStage.from_status(reference.status))
@@ -237,21 +239,22 @@ def _process_pull_image_log(self, job_id: str, reference: PullLogEntry) -> None:
237239
name="Pulling container image layer",
238240
initial_stage=stage.status,
239241
reference=reference.id,
240-
parent_id=job_id,
242+
parent_id=install_job_id,
243+
internal=True,
241244
)
242245
job.done = False
243246
return
244247

245248
# Find our sub job to update details of
246249
for j in self.sys_jobs.jobs:
247-
if j.parent_id == job_id and j.reference == reference.id:
250+
if j.parent_id == install_job_id and j.reference == reference.id:
248251
job = j
249252
break
250253

251254
# This likely only occurs if the logs came in out of sync and we got progress before the Pulling FS Layer one
252255
if not job:
253256
raise DockerLogOutOfOrder(
254-
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",
257+
f"Received pull image log with status {reference.status} for image id {reference.id} and parent job {install_job_id} but could not find a matching job, skipping",
255258
_LOGGER.debug,
256259
)
257260

@@ -325,10 +328,56 @@ def _process_pull_image_log(self, job_id: str, reference: PullLogEntry) -> None:
325328
else job.extra,
326329
)
327330

331+
# Once we have received a progress update for every child job, start to set status of the main one
332+
install_job = self.sys_jobs.get_job(install_job_id)
333+
layer_jobs = [
334+
job
335+
for job in self.sys_jobs.jobs
336+
if job.parent_id == install_job.uuid
337+
and job.name == "Pulling container image layer"
338+
]
339+
340+
# First set the total bytes to be downloaded/extracted on the main job
341+
if not install_job.extra:
342+
total = 0
343+
for job in layer_jobs:
344+
if not job.extra:
345+
return
346+
total += job.extra["total"]
347+
install_job.extra = {"total": total}
348+
else:
349+
total = install_job.extra["total"]
350+
351+
# Then determine total progress based on progress of each sub-job, factoring in size of each compared to total
352+
progress = 0.0
353+
stage = PullImageLayerStage.PULL_COMPLETE
354+
for job in layer_jobs:
355+
if not job.extra:
356+
return
357+
progress += job.progress * (job.extra["total"] / total)
358+
job_stage = PullImageLayerStage.from_status(cast(str, job.stage))
359+
360+
if job_stage < PullImageLayerStage.EXTRACTING:
361+
stage = PullImageLayerStage.DOWNLOADING
362+
elif (
363+
stage == PullImageLayerStage.PULL_COMPLETE
364+
and job_stage < PullImageLayerStage.PULL_COMPLETE
365+
):
366+
stage = PullImageLayerStage.EXTRACTING
367+
368+
# Ensure progress is 100 at this point to prevent float drift
369+
if stage == PullImageLayerStage.PULL_COMPLETE:
370+
progress = 100
371+
372+
# To reduce noise, limit updates to when result has changed by an entire percent or when stage changed
373+
if stage != install_job.stage or progress >= install_job.progress + 1:
374+
install_job.update(stage=stage.status, progress=progress)
375+
328376
@Job(
329377
name="docker_interface_install",
330378
on_condition=DockerJobError,
331379
concurrency=JobConcurrency.GROUP_REJECT,
380+
internal=True,
332381
)
333382
async def install(
334383
self,
@@ -351,11 +400,11 @@ async def install(
351400
# Try login if we have defined credentials
352401
await self._docker_login(image)
353402

354-
job_id = self.sys_jobs.current.uuid
403+
curr_job_id = self.sys_jobs.current.uuid
355404

356405
async def process_pull_image_log(reference: PullLogEntry) -> None:
357406
try:
358-
self._process_pull_image_log(job_id, reference)
407+
self._process_pull_image_log(curr_job_id, reference)
359408
except DockerLogOutOfOrder as err:
360409
# Send all these to sentry. Missing a few progress updates
361410
# shouldn't matter to users but matters to us
@@ -629,7 +678,10 @@ async def check_image(
629678
concurrency=JobConcurrency.GROUP_REJECT,
630679
)
631680
async def update(
632-
self, version: AwesomeVersion, image: str | None = None, latest: bool = False
681+
self,
682+
version: AwesomeVersion,
683+
image: str | None = None,
684+
latest: bool = False,
633685
) -> None:
634686
"""Update a Docker image."""
635687
image = image or self.image

supervisor/homeassistant/core.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
HomeAssistantUpdateError,
2929
JobException,
3030
)
31+
from ..jobs import ChildJobSyncFilter
3132
from ..jobs.const import JOB_GROUP_HOME_ASSISTANT_CORE, JobConcurrency, JobThrottle
3233
from ..jobs.decorator import Job, JobCondition
3334
from ..jobs.job_group import JobGroup
@@ -224,6 +225,13 @@ async def install(self) -> None:
224225
],
225226
on_condition=HomeAssistantJobError,
226227
concurrency=JobConcurrency.GROUP_REJECT,
228+
# We assume for now the docker image pull is 100% of this task. But from
229+
# a user perspective that isn't true. Other steps that take time which
230+
# is not accounted for in progress include: partial backup, image
231+
# cleanup, and Home Assistant restart
232+
child_job_syncs=[
233+
ChildJobSyncFilter("docker_interface_install", progress_allocation=1.0)
234+
],
227235
)
228236
async def update(
229237
self,

supervisor/jobs/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,10 @@ def _on_job_change(
282282
# reporting shouldn't raise and break the active job
283283
continue
284284

285-
progress = sync.starting_progress + (
286-
sync.progress_allocation * job_data["progress"]
285+
progress = min(
286+
100,
287+
sync.starting_progress
288+
+ (sync.progress_allocation * job_data["progress"]),
287289
)
288290
# Using max would always trigger on change even if progress was unchanged
289291
# pylint: disable-next=R1731

supervisor/supervisor.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from aiohttp.client_exceptions import ClientError
1414
from awesomeversion import AwesomeVersion, AwesomeVersionException
1515

16+
from supervisor.jobs import ChildJobSyncFilter
17+
1618
from .const import (
1719
ATTR_SUPERVISOR_INTERNET,
1820
SUPERVISOR_VERSION,
@@ -195,6 +197,15 @@ def write_profile() -> Path:
195197
if temp_dir:
196198
await self.sys_run_in_executor(temp_dir.cleanup)
197199

200+
@Job(
201+
name="supervisor_update",
202+
# We assume for now the docker image pull is 100% of this task. But from
203+
# a user perspective that isn't true. Other steps that take time which
204+
# is not accounted for in progress include: app armor update and restart
205+
child_job_syncs=[
206+
ChildJobSyncFilter("docker_interface_install", progress_allocation=1.0)
207+
],
208+
)
198209
async def update(self, version: AwesomeVersion | None = None) -> None:
199210
"""Update Supervisor version."""
200211
version = version or self.latest_version or self.version
@@ -221,6 +232,7 @@ async def update(self, version: AwesomeVersion | None = None) -> None:
221232

222233
# Update container
223234
_LOGGER.info("Update Supervisor to version %s", version)
235+
224236
try:
225237
await self.instance.install(version, image=image)
226238
await self.instance.update_start_tag(image, version)

tests/api/test_homeassistant.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
import asyncio
44
from pathlib import Path
5-
from unittest.mock import MagicMock, PropertyMock, patch
5+
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
66

77
from aiohttp.test_utils import TestClient
88
from awesomeversion import AwesomeVersion
99
import pytest
1010

1111
from supervisor.backups.manager import BackupManager
12+
from supervisor.const import CoreState
1213
from supervisor.coresys import CoreSys
14+
from supervisor.docker.homeassistant import DockerHomeAssistant
1315
from supervisor.docker.interface import DockerInterface
14-
from supervisor.homeassistant.api import APIState
16+
from supervisor.homeassistant.api import APIState, HomeAssistantAPI
17+
from supervisor.homeassistant.const import WSEvent
1518
from supervisor.homeassistant.core import HomeAssistantCore
1619
from supervisor.homeassistant.module import HomeAssistant
1720

@@ -271,3 +274,96 @@ async def test_background_home_assistant_update_fails_fast(
271274
assert resp.status == 400
272275
body = await resp.json()
273276
assert body["message"] == "Version 2025.8.3 is already installed"
277+
278+
279+
@pytest.mark.usefixtures("tmp_supervisor_data")
280+
async def test_api_progress_updates_home_assistant_update(
281+
api_client: TestClient, coresys: CoreSys, ha_ws_client: AsyncMock
282+
):
283+
"""Test progress updates sent to Home Assistant for updates."""
284+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
285+
coresys.core.set_state(CoreState.RUNNING)
286+
coresys.docker.docker.api.pull.return_value = load_json_fixture(
287+
"docker_pull_image_log.json"
288+
)
289+
coresys.homeassistant.version = AwesomeVersion("2025.8.0")
290+
291+
with (
292+
patch.object(
293+
DockerHomeAssistant,
294+
"version",
295+
new=PropertyMock(return_value=AwesomeVersion("2025.8.0")),
296+
),
297+
patch.object(
298+
HomeAssistantAPI, "get_config", return_value={"components": ["frontend"]}
299+
),
300+
):
301+
resp = await api_client.post("/core/update", json={"version": "2025.8.3"})
302+
303+
assert resp.status == 200
304+
305+
events = [
306+
{
307+
"stage": evt.args[0]["data"]["data"]["stage"],
308+
"progress": evt.args[0]["data"]["data"]["progress"],
309+
"done": evt.args[0]["data"]["data"]["done"],
310+
}
311+
for evt in ha_ws_client.async_send_command.call_args_list
312+
if "data" in evt.args[0]
313+
and evt.args[0]["data"]["event"] == WSEvent.JOB
314+
and evt.args[0]["data"]["data"]["name"] == "home_assistant_core_update"
315+
]
316+
assert events[:5] == [
317+
{
318+
"stage": None,
319+
"progress": 0,
320+
"done": None,
321+
},
322+
{
323+
"stage": None,
324+
"progress": 0,
325+
"done": False,
326+
},
327+
{
328+
"stage": None,
329+
"progress": 0.1,
330+
"done": False,
331+
},
332+
{
333+
"stage": None,
334+
"progress": 1.2,
335+
"done": False,
336+
},
337+
{
338+
"stage": None,
339+
"progress": 2.8,
340+
"done": False,
341+
},
342+
]
343+
assert events[-5:] == [
344+
{
345+
"stage": None,
346+
"progress": 97.2,
347+
"done": False,
348+
},
349+
{
350+
"stage": None,
351+
"progress": 98.4,
352+
"done": False,
353+
},
354+
{
355+
"stage": None,
356+
"progress": 99.4,
357+
"done": False,
358+
},
359+
{
360+
"stage": None,
361+
"progress": 100,
362+
"done": False,
363+
},
364+
{
365+
"stage": None,
366+
"progress": 100,
367+
"done": True,
368+
},
369+
]

0 commit comments

Comments
 (0)