mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-12-02 14:08:17 +00:00
Compare commits
11 Commits
improve-pr
...
refactor-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e1e7a3ab | ||
|
|
e7c8700db9 | ||
|
|
a4f681586e | ||
|
|
fa490210cd | ||
|
|
ba82eb0620 | ||
|
|
11e3fa0bb7 | ||
|
|
9466111d56 | ||
|
|
5ec3bea0dd | ||
|
|
72159a0ae2 | ||
|
|
0a7b26187d | ||
|
|
2dc1f9224e |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -165,7 +165,7 @@ jobs:
|
||||
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build supervisor
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
with:
|
||||
args: |
|
||||
$BUILD_ARGS \
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
# home-assistant/builder doesn't support sha pinning
|
||||
- name: Build the Supervisor
|
||||
if: needs.init.outputs.publish != 'true'
|
||||
uses: home-assistant/builder@2025.09.0
|
||||
uses: home-assistant/builder@2025.11.0
|
||||
with:
|
||||
args: |
|
||||
--test \
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -8,9 +8,7 @@ ENV \
|
||||
UV_SYSTEM_PYTHON=true
|
||||
|
||||
ARG \
|
||||
COSIGN_VERSION \
|
||||
BUILD_ARCH \
|
||||
QEMU_CPU
|
||||
COSIGN_VERSION
|
||||
|
||||
# Install base
|
||||
WORKDIR /usr/src
|
||||
@@ -32,15 +30,9 @@ RUN \
|
||||
&& pip3 install uv==0.8.9
|
||||
|
||||
# Install requirements
|
||||
COPY requirements.txt .
|
||||
RUN \
|
||||
if [ "${BUILD_ARCH}" = "i386" ]; then \
|
||||
setarch="linux32"; \
|
||||
else \
|
||||
setarch=""; \
|
||||
fi \
|
||||
&& ${setarch} uv pip install --compile-bytecode --no-cache --no-build -r requirements.txt \
|
||||
&& rm -f requirements.txt
|
||||
--mount=type=bind,source=./requirements.txt,target=/usr/src/requirements.txt \
|
||||
uv pip install --compile-bytecode --no-cache --no-build -r requirements.txt
|
||||
|
||||
# Install Home Assistant Supervisor
|
||||
COPY . supervisor
|
||||
|
||||
@@ -321,8 +321,6 @@ lint.ignore = [
|
||||
"PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
|
||||
"UP006", # keep type annotation style as is
|
||||
"UP007", # keep type annotation style as is
|
||||
# Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923
|
||||
"UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)`
|
||||
|
||||
# May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
|
||||
"W191",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
astroid==4.0.2
|
||||
coverage==7.12.0
|
||||
mypy==1.18.2
|
||||
mypy==1.19.0
|
||||
pre-commit==4.5.0
|
||||
pylint==4.0.3
|
||||
pylint==4.0.4
|
||||
pytest-aiohttp==1.1.0
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==7.0.0
|
||||
pytest-timeout==2.4.0
|
||||
pytest==9.0.1
|
||||
ruff==0.14.6
|
||||
ruff==0.14.7
|
||||
time-machine==3.1.0
|
||||
types-docker==7.1.0.20251127
|
||||
types-docker==7.1.0.20251129
|
||||
types-pyyaml==6.0.12.20250915
|
||||
types-requests==2.32.4.20250913
|
||||
urllib3==2.5.0
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..const import (
|
||||
FILE_SUFFIX_CONFIGURATION,
|
||||
META_ADDON,
|
||||
SOCKET_DOCKER,
|
||||
CpuArch,
|
||||
)
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..docker.const import DOCKER_HUB
|
||||
@@ -67,7 +68,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
|
||||
raise RuntimeError()
|
||||
|
||||
@cached_property
|
||||
def arch(self) -> str:
|
||||
def arch(self) -> CpuArch:
|
||||
"""Return arch of the add-on."""
|
||||
return self.sys_arch.match([self.addon.arch])
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ from ..const import (
|
||||
AddonBootConfig,
|
||||
AddonStage,
|
||||
AddonStartup,
|
||||
CpuArch,
|
||||
)
|
||||
from ..coresys import CoreSys
|
||||
from ..docker.const import Capabilities
|
||||
@@ -548,7 +549,7 @@ class AddonModel(JobGroup, ABC):
|
||||
return self.data.get(ATTR_MACHINE, [])
|
||||
|
||||
@property
|
||||
def arch(self) -> str:
|
||||
def arch(self) -> CpuArch:
|
||||
"""Return architecture to use for the addon's image."""
|
||||
if ATTR_IMAGE in self.data:
|
||||
return self.sys_arch.match(self.data[ATTR_ARCH])
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from pathlib import Path
|
||||
import platform
|
||||
|
||||
from .const import CpuArch
|
||||
from .coresys import CoreSys, CoreSysAttributes
|
||||
from .exceptions import ConfigurationFileError, HassioArchNotFound
|
||||
from .utils.json import read_json_file
|
||||
@@ -12,38 +13,40 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
ARCH_JSON: Path = Path(__file__).parent.joinpath("data/arch.json")
|
||||
|
||||
MAP_CPU = {
|
||||
"armv7": "armv7",
|
||||
"armv6": "armhf",
|
||||
"armv8": "aarch64",
|
||||
"aarch64": "aarch64",
|
||||
"i686": "i386",
|
||||
"x86_64": "amd64",
|
||||
MAP_CPU: dict[str, CpuArch] = {
|
||||
"armv7": CpuArch.ARMV7,
|
||||
"armv6": CpuArch.ARMHF,
|
||||
"armv8": CpuArch.AARCH64,
|
||||
"aarch64": CpuArch.AARCH64,
|
||||
"i686": CpuArch.I386,
|
||||
"x86_64": CpuArch.AMD64,
|
||||
}
|
||||
|
||||
|
||||
class CpuArch(CoreSysAttributes):
|
||||
class CpuArchManager(CoreSysAttributes):
|
||||
"""Manage available architectures."""
|
||||
|
||||
def __init__(self, coresys: CoreSys) -> None:
|
||||
"""Initialize CPU Architecture handler."""
|
||||
self.coresys = coresys
|
||||
self._supported_arch: list[str] = []
|
||||
self._supported_set: set[str] = set()
|
||||
self._default_arch: str
|
||||
self._supported_arch: list[CpuArch] = []
|
||||
self._supported_set: set[CpuArch] = set()
|
||||
self._default_arch: CpuArch
|
||||
|
||||
@property
|
||||
def default(self) -> str:
|
||||
def default(self) -> CpuArch:
|
||||
"""Return system default arch."""
|
||||
return self._default_arch
|
||||
|
||||
@property
|
||||
def supervisor(self) -> str:
|
||||
def supervisor(self) -> CpuArch:
|
||||
"""Return supervisor arch."""
|
||||
return self.sys_supervisor.arch or self._default_arch
|
||||
if self.sys_supervisor.arch:
|
||||
return CpuArch(self.sys_supervisor.arch)
|
||||
return self._default_arch
|
||||
|
||||
@property
|
||||
def supported(self) -> list[str]:
|
||||
def supported(self) -> list[CpuArch]:
|
||||
"""Return support arch by CPU/Machine."""
|
||||
return self._supported_arch
|
||||
|
||||
@@ -65,7 +68,7 @@ class CpuArch(CoreSysAttributes):
|
||||
return
|
||||
|
||||
# Use configs from arch.json
|
||||
self._supported_arch.extend(arch_data[self.sys_machine])
|
||||
self._supported_arch.extend(CpuArch(a) for a in arch_data[self.sys_machine])
|
||||
self._default_arch = self.supported[0]
|
||||
|
||||
# Make sure native support is in supported list
|
||||
@@ -78,14 +81,14 @@ class CpuArch(CoreSysAttributes):
|
||||
"""Return True if there is a supported arch by this platform."""
|
||||
return not self._supported_set.isdisjoint(arch_list)
|
||||
|
||||
def match(self, arch_list: list[str]) -> str:
|
||||
def match(self, arch_list: list[str]) -> CpuArch:
|
||||
"""Return best match for this CPU/Platform."""
|
||||
for self_arch in self.supported:
|
||||
if self_arch in arch_list:
|
||||
return self_arch
|
||||
raise HassioArchNotFound()
|
||||
|
||||
def detect_cpu(self) -> str:
|
||||
def detect_cpu(self) -> CpuArch:
|
||||
"""Return the arch type of local CPU."""
|
||||
cpu = platform.machine()
|
||||
for check, value in MAP_CPU.items():
|
||||
@@ -96,9 +99,10 @@ class CpuArch(CoreSysAttributes):
|
||||
"Unknown CPU architecture %s, falling back to Supervisor architecture.",
|
||||
cpu,
|
||||
)
|
||||
return self.sys_supervisor.arch
|
||||
return CpuArch(self.sys_supervisor.arch)
|
||||
_LOGGER.warning(
|
||||
"Unknown CPU architecture %s, assuming CPU architecture equals Supervisor architecture.",
|
||||
cpu,
|
||||
)
|
||||
return cpu
|
||||
# Return the cpu string as-is, wrapped in CpuArch (may fail if invalid)
|
||||
return CpuArch(cpu)
|
||||
|
||||
@@ -13,7 +13,7 @@ from colorlog import ColoredFormatter
|
||||
|
||||
from .addons.manager import AddonManager
|
||||
from .api import RestAPI
|
||||
from .arch import CpuArch
|
||||
from .arch import CpuArchManager
|
||||
from .auth import Auth
|
||||
from .backups.manager import BackupManager
|
||||
from .bus import Bus
|
||||
@@ -71,7 +71,7 @@ async def initialize_coresys() -> CoreSys:
|
||||
coresys.jobs = await JobManager(coresys).load_config()
|
||||
coresys.core = await Core(coresys).post_init()
|
||||
coresys.plugins = await PluginManager(coresys).load_config()
|
||||
coresys.arch = CpuArch(coresys)
|
||||
coresys.arch = CpuArchManager(coresys)
|
||||
coresys.auth = await Auth(coresys).load_config()
|
||||
coresys.updater = await Updater(coresys).load_config()
|
||||
coresys.api = RestAPI(coresys)
|
||||
|
||||
@@ -29,7 +29,7 @@ from .const import (
|
||||
if TYPE_CHECKING:
|
||||
from .addons.manager import AddonManager
|
||||
from .api import RestAPI
|
||||
from .arch import CpuArch
|
||||
from .arch import CpuArchManager
|
||||
from .auth import Auth
|
||||
from .backups.manager import BackupManager
|
||||
from .bus import Bus
|
||||
@@ -78,7 +78,7 @@ class CoreSys:
|
||||
# Internal objects pointers
|
||||
self._docker: DockerAPI | None = None
|
||||
self._core: Core | None = None
|
||||
self._arch: CpuArch | None = None
|
||||
self._arch: CpuArchManager | None = None
|
||||
self._auth: Auth | None = None
|
||||
self._homeassistant: HomeAssistant | None = None
|
||||
self._supervisor: Supervisor | None = None
|
||||
@@ -266,17 +266,17 @@ class CoreSys:
|
||||
self._plugins = value
|
||||
|
||||
@property
|
||||
def arch(self) -> CpuArch:
|
||||
"""Return CpuArch object."""
|
||||
def arch(self) -> CpuArchManager:
|
||||
"""Return CpuArchManager object."""
|
||||
if self._arch is None:
|
||||
raise RuntimeError("CpuArch not set!")
|
||||
raise RuntimeError("CpuArchManager not set!")
|
||||
return self._arch
|
||||
|
||||
@arch.setter
|
||||
def arch(self, value: CpuArch) -> None:
|
||||
"""Set a CpuArch object."""
|
||||
def arch(self, value: CpuArchManager) -> None:
|
||||
"""Set a CpuArchManager object."""
|
||||
if self._arch:
|
||||
raise RuntimeError("CpuArch already set!")
|
||||
raise RuntimeError("CpuArchManager already set!")
|
||||
self._arch = value
|
||||
|
||||
@property
|
||||
@@ -733,8 +733,8 @@ class CoreSysAttributes:
|
||||
return self.coresys.plugins
|
||||
|
||||
@property
|
||||
def sys_arch(self) -> CpuArch:
|
||||
"""Return CpuArch object."""
|
||||
def sys_arch(self) -> CpuArchManager:
|
||||
"""Return CpuArchManager object."""
|
||||
return self.coresys.arch
|
||||
|
||||
@property
|
||||
|
||||
@@ -7,6 +7,7 @@ from ipaddress import IPv4Address
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from socket import SocketIO
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
@@ -834,7 +835,10 @@ class DockerAddon(DockerInterface):
|
||||
try:
|
||||
# Load needed docker objects
|
||||
container = self.sys_docker.containers.get(self.name)
|
||||
socket = container.attach_socket(params={"stdin": 1, "stream": 1})
|
||||
# attach_socket returns SocketIO for local Docker connections (Unix socket)
|
||||
socket = cast(
|
||||
SocketIO, container.attach_socket(params={"stdin": 1, "stream": 1})
|
||||
)
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
_LOGGER.error("Can't attach to %s stdin: %s", self.name, err)
|
||||
raise DockerError() from err
|
||||
|
||||
@@ -2,19 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from enum import Enum, StrEnum
|
||||
from functools import total_ordering
|
||||
from enum import StrEnum
|
||||
from pathlib import PurePath
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
from docker.types import Mount
|
||||
|
||||
from ..const import MACHINE_ID
|
||||
|
||||
RE_RETRYING_DOWNLOAD_STATUS = re.compile(r"Retrying in \d+ seconds?")
|
||||
|
||||
# Docker Hub registry identifier
|
||||
DOCKER_HUB = "hub.docker.com"
|
||||
|
||||
@@ -81,57 +76,6 @@ 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"
|
||||
RETRYING_DOWNLOAD = 2, "Retrying download"
|
||||
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) -> PullImageLayerStage | None:
|
||||
"""Return stage instance from pull log status."""
|
||||
for i in cls:
|
||||
if i.status == status:
|
||||
return i
|
||||
|
||||
# This one includes number of seconds until download so its not constant
|
||||
if RE_RETRYING_DOWNLOAD_STATUS.match(status):
|
||||
return cls.RETRYING_DOWNLOAD
|
||||
|
||||
return None
|
||||
|
||||
|
||||
ENV_TIME = "TZ"
|
||||
ENV_TOKEN = "SUPERVISOR_TOKEN"
|
||||
ENV_TOKEN_OLD = "HASSIO_TOKEN"
|
||||
|
||||
@@ -19,7 +19,6 @@ import docker
|
||||
from docker.models.containers import Container
|
||||
import requests
|
||||
|
||||
from ..bus import EventListener
|
||||
from ..const import (
|
||||
ATTR_PASSWORD,
|
||||
ATTR_REGISTRY,
|
||||
@@ -35,24 +34,23 @@ from ..exceptions import (
|
||||
DockerError,
|
||||
DockerHubRateLimitExceeded,
|
||||
DockerJobError,
|
||||
DockerLogOutOfOrder,
|
||||
DockerNotFound,
|
||||
DockerRequestError,
|
||||
)
|
||||
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 DOCKER_HUB, ContainerState, PullImageLayerStage, RestartPolicy
|
||||
from .const import DOCKER_HUB, ContainerState, RestartPolicy
|
||||
from .manager import CommandReturn, PullLogEntry
|
||||
from .monitor import DockerContainerStateEvent
|
||||
from .pull_progress import ImagePullProgress
|
||||
from .stats import DockerStats
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
MAP_ARCH: dict[CpuArch | str, str] = {
|
||||
MAP_ARCH: dict[CpuArch, str] = {
|
||||
CpuArch.ARMV7: "linux/arm/v7",
|
||||
CpuArch.ARMHF: "linux/arm/v6",
|
||||
CpuArch.AARCH64: "linux/arm64",
|
||||
@@ -195,159 +193,6 @@ class DockerInterface(JobGroup, ABC):
|
||||
|
||||
return credentials
|
||||
|
||||
def _process_pull_image_log( # noqa: C901
|
||||
self, install_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 != install_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=install_job_id,
|
||||
internal=True,
|
||||
)
|
||||
job.done = False
|
||||
return
|
||||
|
||||
# Find our sub job to update details of
|
||||
for j in self.sys_jobs.jobs:
|
||||
if j.parent_id == install_job_id and j.reference == reference.id:
|
||||
job = j
|
||||
break
|
||||
|
||||
# There should no longer be any real risk of logs out of order anymore.
|
||||
# However tests with very small images have shown that sometimes Docker
|
||||
# skips stages in log. So keeping this one as a safety check on null job
|
||||
if not job:
|
||||
raise DockerLogOutOfOrder(
|
||||
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",
|
||||
_LOGGER.debug,
|
||||
)
|
||||
|
||||
# For progress calculation we assume downloading is 70% of time, extracting is 30% 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 = (
|
||||
reference.progress_detail.current
|
||||
/ reference.progress_detail.total
|
||||
)
|
||||
if stage == PullImageLayerStage.DOWNLOADING:
|
||||
progress = 70 * progress
|
||||
else:
|
||||
progress = 70 + 30 * progress
|
||||
case (
|
||||
PullImageLayerStage.VERIFYING_CHECKSUM
|
||||
| PullImageLayerStage.DOWNLOAD_COMPLETE
|
||||
):
|
||||
progress = 70
|
||||
case PullImageLayerStage.PULL_COMPLETE:
|
||||
progress = 100
|
||||
case PullImageLayerStage.RETRYING_DOWNLOAD:
|
||||
progress = 0
|
||||
|
||||
# No real risk of getting things out of order in current implementation
|
||||
# but keeping this one in case another change to these trips us up.
|
||||
if stage != PullImageLayerStage.RETRYING_DOWNLOAD and 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 later stages as the total bytes may be useful after that stage
|
||||
# Enforce range to prevent float drift error
|
||||
progress = max(0, min(progress, 100))
|
||||
if (
|
||||
stage in {PullImageLayerStage.DOWNLOADING, PullImageLayerStage.EXTRACTING}
|
||||
and reference.progress_detail
|
||||
and reference.progress_detail.current is not None
|
||||
and reference.progress_detail.total is not None
|
||||
):
|
||||
job.update(
|
||||
progress=progress,
|
||||
stage=stage.status,
|
||||
extra={
|
||||
"current": reference.progress_detail.current,
|
||||
"total": reference.progress_detail.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
|
||||
if stage == PullImageLayerStage.DOWNLOAD_COMPLETE and not job.extra:
|
||||
extra = {"current": 1, "total": 1}
|
||||
|
||||
job.update(
|
||||
progress=progress,
|
||||
stage=stage.status,
|
||||
done=stage == PullImageLayerStage.PULL_COMPLETE,
|
||||
extra=None if stage == PullImageLayerStage.RETRYING_DOWNLOAD else extra,
|
||||
)
|
||||
|
||||
# Once we have received a progress update for every child job, start to set status of the main one
|
||||
install_job = self.sys_jobs.get_job(install_job_id)
|
||||
layer_jobs = [
|
||||
job
|
||||
for job in self.sys_jobs.jobs
|
||||
if job.parent_id == install_job.uuid
|
||||
and job.name == "Pulling container image layer"
|
||||
]
|
||||
|
||||
# First set the total bytes to be downloaded/extracted on the main job
|
||||
if not install_job.extra:
|
||||
total = 0
|
||||
for job in layer_jobs:
|
||||
if not job.extra:
|
||||
return
|
||||
total += job.extra["total"]
|
||||
install_job.extra = {"total": total}
|
||||
else:
|
||||
total = install_job.extra["total"]
|
||||
|
||||
# Then determine total progress based on progress of each sub-job, factoring in size of each compared to total
|
||||
progress = 0.0
|
||||
stage = PullImageLayerStage.PULL_COMPLETE
|
||||
for job in layer_jobs:
|
||||
if not job.extra or not job.extra.get("total"):
|
||||
return
|
||||
progress += job.progress * (job.extra["total"] / total)
|
||||
job_stage = PullImageLayerStage.from_status(cast(str, job.stage))
|
||||
|
||||
if job_stage < PullImageLayerStage.EXTRACTING:
|
||||
stage = PullImageLayerStage.DOWNLOADING
|
||||
elif (
|
||||
stage == PullImageLayerStage.PULL_COMPLETE
|
||||
and job_stage < PullImageLayerStage.PULL_COMPLETE
|
||||
):
|
||||
stage = PullImageLayerStage.EXTRACTING
|
||||
|
||||
# Ensure progress is 100 at this point to prevent float drift
|
||||
if stage == PullImageLayerStage.PULL_COMPLETE:
|
||||
progress = 100
|
||||
|
||||
# To reduce noise, limit updates to when result has changed by an entire percent or when stage changed
|
||||
if stage != install_job.stage or progress >= install_job.progress + 1:
|
||||
install_job.update(stage=stage.status, progress=max(0, min(progress, 100)))
|
||||
|
||||
@Job(
|
||||
name="docker_interface_install",
|
||||
on_condition=DockerJobError,
|
||||
@@ -366,31 +211,36 @@ class DockerInterface(JobGroup, ABC):
|
||||
if not image:
|
||||
raise ValueError("Cannot pull without an image!")
|
||||
|
||||
image_arch = str(arch) if arch else self.sys_arch.supervisor
|
||||
listener: EventListener | None = None
|
||||
image_arch = arch or self.sys_arch.supervisor
|
||||
pull_progress = ImagePullProgress()
|
||||
current_job = self.sys_jobs.current
|
||||
|
||||
async def process_pull_event(event: PullLogEntry) -> None:
|
||||
"""Process pull event and update job progress."""
|
||||
if event.job_id != current_job.uuid:
|
||||
return
|
||||
|
||||
# Process event through progress tracker
|
||||
pull_progress.process_event(event)
|
||||
|
||||
# Update job if progress changed significantly (>= 1%)
|
||||
should_update, progress = pull_progress.should_update_job()
|
||||
if should_update:
|
||||
stage = pull_progress.get_stage()
|
||||
current_job.update(progress=progress, stage=stage)
|
||||
|
||||
listener = self.sys_bus.register_event(
|
||||
BusEvent.DOCKER_IMAGE_PULL_UPDATE, process_pull_event
|
||||
)
|
||||
|
||||
_LOGGER.info("Downloading docker image %s with tag %s.", image, version)
|
||||
try:
|
||||
# Get credentials for private registries to pass to aiodocker
|
||||
credentials = self._get_credentials(image) or None
|
||||
|
||||
curr_job_id = self.sys_jobs.current.uuid
|
||||
|
||||
async def process_pull_image_log(reference: PullLogEntry) -> None:
|
||||
try:
|
||||
self._process_pull_image_log(curr_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, passing credentials to aiodocker
|
||||
docker_image = await self.sys_docker.pull_image(
|
||||
self.sys_jobs.current.uuid,
|
||||
current_job.uuid,
|
||||
image,
|
||||
str(version),
|
||||
platform=MAP_ARCH[image_arch],
|
||||
@@ -438,8 +288,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
f"Unknown error with {image}:{version!s} -> {err!s}", _LOGGER.error
|
||||
) from err
|
||||
finally:
|
||||
if listener:
|
||||
self.sys_bus.remove_listener(listener)
|
||||
self.sys_bus.remove_listener(listener)
|
||||
|
||||
self._meta = docker_image
|
||||
|
||||
@@ -603,9 +452,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
expected_cpu_arch: CpuArch | None = None,
|
||||
) -> None:
|
||||
"""Check we have expected image with correct arch."""
|
||||
expected_image_cpu_arch = (
|
||||
str(expected_cpu_arch) if expected_cpu_arch else self.sys_arch.supervisor
|
||||
)
|
||||
arch = expected_cpu_arch or self.sys_arch.supervisor
|
||||
image_name = f"{expected_image}:{version!s}"
|
||||
if self.image == expected_image:
|
||||
try:
|
||||
@@ -623,7 +470,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
# If we have an image and its the right arch, all set
|
||||
# It seems that newer Docker version return a variant for arm64 images.
|
||||
# Make sure we match linux/arm64 and linux/arm64/v8.
|
||||
expected_image_arch = MAP_ARCH[expected_image_cpu_arch]
|
||||
expected_image_arch = MAP_ARCH[arch]
|
||||
if image_arch.startswith(expected_image_arch):
|
||||
return
|
||||
_LOGGER.info(
|
||||
@@ -636,7 +483,7 @@ class DockerInterface(JobGroup, ABC):
|
||||
# We're missing the image we need. Stop and clean up what we have then pull the right one
|
||||
with suppress(DockerError):
|
||||
await self.remove()
|
||||
await self.install(version, expected_image, arch=expected_image_cpu_arch)
|
||||
await self.install(version, expected_image, arch=arch)
|
||||
|
||||
@Job(
|
||||
name="docker_interface_update",
|
||||
|
||||
@@ -708,7 +708,8 @@ class DockerAPI(CoreSysAttributes):
|
||||
raise DockerError(f"Container {name} is not running", _LOGGER.error)
|
||||
|
||||
try:
|
||||
return docker_container.stats(stream=False)
|
||||
# When stream=False, stats() returns dict, not Iterator
|
||||
return cast(dict[str, Any], docker_container.stats(stream=False))
|
||||
except (docker_errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError(
|
||||
f"Can't read stats from {name}: {err}", _LOGGER.error
|
||||
|
||||
@@ -7,6 +7,8 @@ import logging
|
||||
from typing import Self, cast
|
||||
|
||||
import docker
|
||||
from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
import requests
|
||||
|
||||
from ..const import (
|
||||
@@ -59,7 +61,7 @@ class DockerNetwork:
|
||||
def __init__(self, docker_client: docker.DockerClient):
|
||||
"""Initialize internal Supervisor network."""
|
||||
self.docker: docker.DockerClient = docker_client
|
||||
self._network: docker.models.networks.Network
|
||||
self._network: Network
|
||||
|
||||
async def post_init(
|
||||
self, enable_ipv6: bool | None = None, mtu: int | None = None
|
||||
@@ -76,7 +78,7 @@ class DockerNetwork:
|
||||
return DOCKER_NETWORK
|
||||
|
||||
@property
|
||||
def network(self) -> docker.models.networks.Network:
|
||||
def network(self) -> Network:
|
||||
"""Return docker network."""
|
||||
return self._network
|
||||
|
||||
@@ -117,7 +119,7 @@ class DockerNetwork:
|
||||
|
||||
def _get_network(
|
||||
self, enable_ipv6: bool | None = None, mtu: int | None = None
|
||||
) -> docker.models.networks.Network:
|
||||
) -> Network:
|
||||
"""Get supervisor network."""
|
||||
try:
|
||||
if network := self.docker.networks.get(DOCKER_NETWORK):
|
||||
@@ -218,7 +220,7 @@ class DockerNetwork:
|
||||
|
||||
def attach_container(
|
||||
self,
|
||||
container: docker.models.containers.Container,
|
||||
container: Container,
|
||||
alias: list[str] | None = None,
|
||||
ipv4: IPv4Address | None = None,
|
||||
) -> None:
|
||||
@@ -275,9 +277,7 @@ class DockerNetwork:
|
||||
if container.id not in self.containers:
|
||||
self.attach_container(container, alias, ipv4)
|
||||
|
||||
def detach_default_bridge(
|
||||
self, container: docker.models.containers.Container
|
||||
) -> None:
|
||||
def detach_default_bridge(self, container: Container) -> None:
|
||||
"""Detach default Docker bridge.
|
||||
|
||||
Need run inside executor.
|
||||
|
||||
316
supervisor/docker/pull_progress.py
Normal file
316
supervisor/docker/pull_progress.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Image pull progress tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manager import PullLogEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Progress weight distribution: 70% downloading, 30% extraction
|
||||
DOWNLOAD_WEIGHT = 70.0
|
||||
EXTRACT_WEIGHT = 30.0
|
||||
|
||||
|
||||
class LayerPullStatus(Enum):
|
||||
"""Status values for pulling an image layer.
|
||||
|
||||
These are a subset of the statuses in a docker pull image log.
|
||||
The order field allows comparing which stage is further along.
|
||||
"""
|
||||
|
||||
PULLING_FS_LAYER = 1, "Pulling fs layer"
|
||||
WAITING = 1, "Waiting"
|
||||
RETRYING = 2, "Retrying" # Matches "Retrying in N seconds"
|
||||
DOWNLOADING = 3, "Downloading"
|
||||
VERIFYING_CHECKSUM = 4, "Verifying Checksum"
|
||||
DOWNLOAD_COMPLETE = 5, "Download complete"
|
||||
EXTRACTING = 6, "Extracting"
|
||||
PULL_COMPLETE = 7, "Pull complete"
|
||||
ALREADY_EXISTS = 7, "Already exists"
|
||||
|
||||
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 string comparisons on status."""
|
||||
with suppress(AttributeError):
|
||||
return self.status == cast(LayerPullStatus, value).status
|
||||
return self.status == value
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Return hash based on status string."""
|
||||
return hash(self.status)
|
||||
|
||||
def __lt__(self, other: object) -> bool:
|
||||
"""Order instances by stage progression."""
|
||||
with suppress(AttributeError):
|
||||
return self.order < cast(LayerPullStatus, other).order
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_status(cls, status: str) -> LayerPullStatus | None:
|
||||
"""Get enum from status string, or None if not recognized."""
|
||||
# Handle "Retrying in N seconds" pattern
|
||||
if status.startswith("Retrying in "):
|
||||
return cls.RETRYING
|
||||
for member in cls:
|
||||
if member.status == status:
|
||||
return member
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LayerProgress:
|
||||
"""Track progress of a single layer."""
|
||||
|
||||
layer_id: str
|
||||
total_size: int = 0 # Size in bytes (from downloading, reused for extraction)
|
||||
download_current: int = 0
|
||||
extract_current: int = 0 # Extraction progress in bytes (overlay2 only)
|
||||
download_complete: bool = False
|
||||
extract_complete: bool = False
|
||||
already_exists: bool = False # Layer was already locally available
|
||||
|
||||
def calculate_progress(self) -> float:
|
||||
"""Calculate layer progress 0-100.
|
||||
|
||||
Progress is weighted: 70% download, 30% extraction.
|
||||
For overlay2, we have byte-based extraction progress.
|
||||
For containerd, extraction jumps from 70% to 100% on completion.
|
||||
"""
|
||||
if self.already_exists or self.extract_complete:
|
||||
return 100.0
|
||||
|
||||
if self.download_complete:
|
||||
# Check if we have extraction progress (overlay2)
|
||||
if self.extract_current > 0 and self.total_size > 0:
|
||||
extract_pct = min(1.0, self.extract_current / self.total_size)
|
||||
return DOWNLOAD_WEIGHT + (extract_pct * EXTRACT_WEIGHT)
|
||||
# No extraction progress yet - return 70%
|
||||
return DOWNLOAD_WEIGHT
|
||||
|
||||
if self.total_size > 0:
|
||||
download_pct = min(1.0, self.download_current / self.total_size)
|
||||
return download_pct * DOWNLOAD_WEIGHT
|
||||
|
||||
return 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImagePullProgress:
|
||||
"""Track overall progress of pulling an image.
|
||||
|
||||
Uses count-based progress where each layer contributes equally regardless of size.
|
||||
This avoids progress regression when large layers are discovered late due to
|
||||
Docker's rate-limiting of concurrent downloads.
|
||||
|
||||
Progress is only reported after the first "Downloading" event, since Docker
|
||||
sends "Already exists" and "Pulling fs layer" events before we know the full
|
||||
layer count.
|
||||
"""
|
||||
|
||||
layers: dict[str, LayerProgress] = field(default_factory=dict)
|
||||
_last_reported_progress: float = field(default=0.0, repr=False)
|
||||
_seen_downloading: bool = field(default=False, repr=False)
|
||||
|
||||
def get_or_create_layer(self, layer_id: str) -> LayerProgress:
|
||||
"""Get existing layer or create new one."""
|
||||
if layer_id not in self.layers:
|
||||
self.layers[layer_id] = LayerProgress(layer_id=layer_id)
|
||||
return self.layers[layer_id]
|
||||
|
||||
def process_event(self, entry: PullLogEntry) -> None:
|
||||
"""Process a pull log event and update layer state."""
|
||||
# Skip events without layer ID or status
|
||||
if not entry.id or not entry.status:
|
||||
return
|
||||
|
||||
# Skip metadata events that aren't layer-specific
|
||||
# "Pulling from X" has id=tag but isn't a layer
|
||||
if entry.status.startswith("Pulling from "):
|
||||
return
|
||||
|
||||
# Parse status to enum (returns None for unrecognized statuses)
|
||||
status = LayerPullStatus.from_status(entry.status)
|
||||
if status is None:
|
||||
return
|
||||
|
||||
layer = self.get_or_create_layer(entry.id)
|
||||
|
||||
# Handle "Already exists" - layer is locally available
|
||||
if status is LayerPullStatus.ALREADY_EXISTS:
|
||||
layer.already_exists = True
|
||||
layer.download_complete = True
|
||||
layer.extract_complete = True
|
||||
return
|
||||
|
||||
# Handle "Pulling fs layer" / "Waiting" - layer is being tracked
|
||||
if status in (LayerPullStatus.PULLING_FS_LAYER, LayerPullStatus.WAITING):
|
||||
return
|
||||
|
||||
# Handle "Downloading" - update download progress
|
||||
if status is LayerPullStatus.DOWNLOADING:
|
||||
# Mark that we've seen downloading - now we know layer count is complete
|
||||
self._seen_downloading = True
|
||||
if (
|
||||
entry.progress_detail
|
||||
and entry.progress_detail.current is not None
|
||||
and entry.progress_detail.total is not None
|
||||
):
|
||||
layer.download_current = entry.progress_detail.current
|
||||
# Only set total_size if not already set or if this is larger
|
||||
# (handles case where total changes during download)
|
||||
layer.total_size = max(layer.total_size, entry.progress_detail.total)
|
||||
return
|
||||
|
||||
# Handle "Verifying Checksum" - download is essentially complete
|
||||
if status is LayerPullStatus.VERIFYING_CHECKSUM:
|
||||
if layer.total_size > 0:
|
||||
layer.download_current = layer.total_size
|
||||
return
|
||||
|
||||
# Handle "Download complete" - download phase done
|
||||
if status is LayerPullStatus.DOWNLOAD_COMPLETE:
|
||||
layer.download_complete = True
|
||||
if layer.total_size > 0:
|
||||
layer.download_current = layer.total_size
|
||||
elif layer.total_size == 0:
|
||||
# Small layer that skipped downloading phase
|
||||
# Set minimal size so it doesn't distort weighted average
|
||||
layer.total_size = 1
|
||||
layer.download_current = 1
|
||||
return
|
||||
|
||||
# Handle "Extracting" - extraction in progress
|
||||
if status is LayerPullStatus.EXTRACTING:
|
||||
# For overlay2: progressDetail has {current, total} in bytes
|
||||
# For containerd: progressDetail has {current, units: "s"} (time elapsed)
|
||||
# We can only use byte-based progress (overlay2)
|
||||
layer.download_complete = True
|
||||
if layer.total_size > 0:
|
||||
layer.download_current = layer.total_size
|
||||
|
||||
# Check if this is byte-based extraction progress (overlay2)
|
||||
# Overlay2 has {current, total} in bytes, no units field
|
||||
# Containerd has {current, units: "s"} which is useless for progress
|
||||
if (
|
||||
entry.progress_detail
|
||||
and entry.progress_detail.current is not None
|
||||
and entry.progress_detail.units is None
|
||||
):
|
||||
# Use layer's total_size from downloading phase (doesn't change)
|
||||
layer.extract_current = entry.progress_detail.current
|
||||
_LOGGER.debug(
|
||||
"Layer %s extracting: %d/%d (%.1f%%)",
|
||||
layer.layer_id,
|
||||
layer.extract_current,
|
||||
layer.total_size,
|
||||
(layer.extract_current / layer.total_size * 100)
|
||||
if layer.total_size > 0
|
||||
else 0,
|
||||
)
|
||||
return
|
||||
|
||||
# Handle "Pull complete" - layer is fully done
|
||||
if status is LayerPullStatus.PULL_COMPLETE:
|
||||
layer.download_complete = True
|
||||
layer.extract_complete = True
|
||||
if layer.total_size > 0:
|
||||
layer.download_current = layer.total_size
|
||||
return
|
||||
|
||||
# Handle "Retrying in N seconds" - reset download progress
|
||||
if status is LayerPullStatus.RETRYING:
|
||||
layer.download_current = 0
|
||||
layer.download_complete = False
|
||||
return
|
||||
|
||||
def calculate_progress(self) -> float:
|
||||
"""Calculate overall progress 0-100.
|
||||
|
||||
Uses count-based progress where each layer that needs pulling contributes
|
||||
equally. Layers that already exist locally are excluded from the calculation.
|
||||
|
||||
Returns 0 until we've seen the first "Downloading" event, since Docker
|
||||
reports "Already exists" and "Pulling fs layer" events before we know
|
||||
the complete layer count.
|
||||
"""
|
||||
# Don't report progress until we've seen downloading start
|
||||
# This ensures we know the full layer count before calculating progress
|
||||
if not self._seen_downloading or not self.layers:
|
||||
return 0.0
|
||||
|
||||
# Only count layers that need pulling (exclude already_exists)
|
||||
layers_to_pull = [
|
||||
layer for layer in self.layers.values() if not layer.already_exists
|
||||
]
|
||||
|
||||
if not layers_to_pull:
|
||||
# All layers already exist, nothing to download
|
||||
return 100.0
|
||||
|
||||
# Each layer contributes equally: sum of layer progresses / total layers
|
||||
total_progress = sum(layer.calculate_progress() for layer in layers_to_pull)
|
||||
return total_progress / len(layers_to_pull)
|
||||
|
||||
def get_stage(self) -> str | None:
|
||||
"""Get current stage based on layer states."""
|
||||
if not self.layers:
|
||||
return None
|
||||
|
||||
# Check if any layer is still downloading
|
||||
for layer in self.layers.values():
|
||||
if layer.already_exists:
|
||||
continue
|
||||
if not layer.download_complete:
|
||||
return "Downloading"
|
||||
|
||||
# All downloads complete, check if extracting
|
||||
for layer in self.layers.values():
|
||||
if layer.already_exists:
|
||||
continue
|
||||
if not layer.extract_complete:
|
||||
return "Extracting"
|
||||
|
||||
# All done
|
||||
return "Pull complete"
|
||||
|
||||
def should_update_job(self, threshold: float = 1.0) -> tuple[bool, float]:
|
||||
"""Check if job should be updated based on progress change.
|
||||
|
||||
Returns (should_update, current_progress).
|
||||
Updates are triggered when progress changes by at least threshold%.
|
||||
Progress is guaranteed to only increase (monotonic).
|
||||
"""
|
||||
current_progress = self.calculate_progress()
|
||||
|
||||
# Ensure monotonic progress - never report a decrease
|
||||
# This can happen when new layers get size info and change the weighted average
|
||||
if current_progress < self._last_reported_progress:
|
||||
_LOGGER.debug(
|
||||
"Progress decreased from %.1f%% to %.1f%%, keeping last reported",
|
||||
self._last_reported_progress,
|
||||
current_progress,
|
||||
)
|
||||
return False, self._last_reported_progress
|
||||
|
||||
if current_progress >= self._last_reported_progress + threshold:
|
||||
_LOGGER.debug(
|
||||
"Progress update: %.1f%% -> %.1f%% (delta: %.1f%%)",
|
||||
self._last_reported_progress,
|
||||
current_progress,
|
||||
current_progress - self._last_reported_progress,
|
||||
)
|
||||
self._last_reported_progress = current_progress
|
||||
return True, current_progress
|
||||
|
||||
return False, self._last_reported_progress
|
||||
@@ -632,10 +632,6 @@ 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."""
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.arch import CpuArch
|
||||
from supervisor.arch import CpuArchManager
|
||||
from supervisor.config import CoreConfig
|
||||
from supervisor.const import AddonBoot, AddonStartup, AddonState, BusEvent
|
||||
from supervisor.coresys import CoreSys
|
||||
@@ -54,7 +54,9 @@ async def fixture_mock_arch_disk() -> AsyncGenerator[None]:
|
||||
"""Mock supported arch and disk space."""
|
||||
with (
|
||||
patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0**3))),
|
||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])),
|
||||
patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.addons.build import AddonBuild
|
||||
from supervisor.arch import CpuArch
|
||||
from supervisor.arch import CpuArchManager
|
||||
from supervisor.const import AddonState
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.docker.addon import DockerAddon
|
||||
@@ -236,7 +236,9 @@ async def test_api_addon_rebuild_healthcheck(
|
||||
patch.object(AddonBuild, "is_valid", return_value=True),
|
||||
patch.object(DockerAddon, "is_running", return_value=False),
|
||||
patch.object(Addon, "need_build", new=PropertyMock(return_value=True)),
|
||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])),
|
||||
patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
),
|
||||
patch.object(DockerAddon, "run", new=container_events_task),
|
||||
patch.object(
|
||||
coresys.docker,
|
||||
@@ -308,7 +310,9 @@ async def test_api_addon_rebuild_force(
|
||||
patch.object(
|
||||
Addon, "need_build", new=PropertyMock(return_value=False)
|
||||
), # Image-based
|
||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])),
|
||||
patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
),
|
||||
):
|
||||
resp = await api_client.post("/addons/local_ssh/rebuild")
|
||||
|
||||
@@ -326,7 +330,9 @@ async def test_api_addon_rebuild_force(
|
||||
patch.object(
|
||||
Addon, "need_build", new=PropertyMock(return_value=False)
|
||||
), # Image-based
|
||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])),
|
||||
patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
),
|
||||
patch.object(DockerAddon, "run", new=container_events_task),
|
||||
patch.object(
|
||||
coresys.docker,
|
||||
|
||||
@@ -305,6 +305,8 @@ async def test_api_progress_updates_home_assistant_update(
|
||||
and evt.args[0]["data"]["event"] == WSEvent.JOB
|
||||
and evt.args[0]["data"]["data"]["name"] == "home_assistant_core_update"
|
||||
]
|
||||
# Count-based progress: 2 layers need pulling (each worth 50%)
|
||||
# Layers that already exist are excluded from progress calculation
|
||||
assert events[:5] == [
|
||||
{
|
||||
"stage": None,
|
||||
@@ -318,36 +320,36 @@ async def test_api_progress_updates_home_assistant_update(
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 0.1,
|
||||
"progress": 9.2,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 1.7,
|
||||
"progress": 25.6,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 4.0,
|
||||
"progress": 35.4,
|
||||
"done": False,
|
||||
},
|
||||
]
|
||||
assert events[-5:] == [
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 95.5,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 96.9,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 98.2,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 98.3,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 99.3,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 100,
|
||||
|
||||
@@ -10,7 +10,7 @@ from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.arch import CpuArch
|
||||
from supervisor.arch import CpuArchManager
|
||||
from supervisor.backups.manager import BackupManager
|
||||
from supervisor.config import CoreConfig
|
||||
from supervisor.const import AddonState, CoreState
|
||||
@@ -191,7 +191,9 @@ async def test_api_store_update_healthcheck(
|
||||
patch.object(DockerAddon, "run", new=container_events_task),
|
||||
patch.object(DockerInterface, "install"),
|
||||
patch.object(DockerAddon, "is_running", return_value=False),
|
||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])),
|
||||
patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
),
|
||||
):
|
||||
resp = await api_client.post(f"/store/addons/{TEST_ADDON_SLUG}/update")
|
||||
|
||||
@@ -548,7 +550,9 @@ async def test_api_store_addons_addon_availability_arch_not_supported(
|
||||
coresys.addons.data.user[addon_obj.slug] = {"version": AwesomeVersion("0.0.1")}
|
||||
|
||||
# Mock the system architecture to be different
|
||||
with patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])):
|
||||
with patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
):
|
||||
resp = await api_client.request(
|
||||
api_method, f"/store/addons/{addon_obj.slug}/{api_action}"
|
||||
)
|
||||
@@ -760,6 +764,8 @@ async def test_api_progress_updates_addon_install_update(
|
||||
and evt.args[0]["data"]["data"]["name"] == job_name
|
||||
and evt.args[0]["data"]["data"]["reference"] == addon_slug
|
||||
]
|
||||
# Count-based progress: 2 layers need pulling (each worth 50%)
|
||||
# Layers that already exist are excluded from progress calculation
|
||||
assert events[:4] == [
|
||||
{
|
||||
"stage": None,
|
||||
@@ -768,36 +774,36 @@ async def test_api_progress_updates_addon_install_update(
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 0.1,
|
||||
"progress": 9.2,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 1.7,
|
||||
"progress": 25.6,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 4.0,
|
||||
"progress": 35.4,
|
||||
"done": False,
|
||||
},
|
||||
]
|
||||
assert events[-5:] == [
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 95.5,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 96.9,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 98.2,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 98.3,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 99.3,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 100,
|
||||
|
||||
@@ -358,6 +358,8 @@ async def test_api_progress_updates_supervisor_update(
|
||||
and evt.args[0]["data"]["event"] == WSEvent.JOB
|
||||
and evt.args[0]["data"]["data"]["name"] == "supervisor_update"
|
||||
]
|
||||
# Count-based progress: 2 layers need pulling (each worth 50%)
|
||||
# Layers that already exist are excluded from progress calculation
|
||||
assert events[:4] == [
|
||||
{
|
||||
"stage": None,
|
||||
@@ -366,36 +368,36 @@ async def test_api_progress_updates_supervisor_update(
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 0.1,
|
||||
"progress": 9.2,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 1.7,
|
||||
"progress": 25.6,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 4.0,
|
||||
"progress": 35.4,
|
||||
"done": False,
|
||||
},
|
||||
]
|
||||
assert events[-5:] == [
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 95.5,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 96.9,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 98.2,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 98.3,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 99.3,
|
||||
"done": False,
|
||||
},
|
||||
{
|
||||
"stage": None,
|
||||
"progress": 100,
|
||||
|
||||
@@ -709,11 +709,18 @@ async def test_install_progress_handles_layers_skipping_download(
|
||||
await install_task
|
||||
await event.wait()
|
||||
|
||||
# First update from layer download should have rather low progress ((260937/25445459) / 2 ~ 0.5%)
|
||||
assert install_job_snapshots[0]["progress"] < 1
|
||||
# With the new progress calculation approach:
|
||||
# - Progress is weighted by layer size
|
||||
# - Small layers that skip downloading get minimal size (1 byte)
|
||||
# - Progress should increase monotonically
|
||||
assert len(install_job_snapshots) > 0
|
||||
|
||||
# Total 8 events should lead to a progress update on the install job
|
||||
assert len(install_job_snapshots) == 8
|
||||
# Verify progress is monotonically increasing (or stable)
|
||||
for i in range(1, len(install_job_snapshots)):
|
||||
assert (
|
||||
install_job_snapshots[i]["progress"]
|
||||
>= install_job_snapshots[i - 1]["progress"]
|
||||
)
|
||||
|
||||
# Job should complete successfully
|
||||
assert job.done is True
|
||||
@@ -844,24 +851,24 @@ async def test_install_progress_containerd_snapshot(
|
||||
}
|
||||
|
||||
assert [c.args[0] for c in ha_ws_client.async_send_command.call_args_list] == [
|
||||
# During downloading we get continuous progress updates from download status
|
||||
# Count-based progress: 2 layers, each = 50%. Download = 0-35%, Extract = 35-50%
|
||||
job_event(0),
|
||||
job_event(1.7),
|
||||
job_event(3.4),
|
||||
job_event(8.5),
|
||||
job_event(8.4),
|
||||
job_event(10.2),
|
||||
job_event(15.3),
|
||||
job_event(18.8),
|
||||
job_event(29.0),
|
||||
job_event(35.8),
|
||||
job_event(42.6),
|
||||
job_event(49.5),
|
||||
job_event(56.0),
|
||||
job_event(62.8),
|
||||
# Downloading phase is considered 70% of total. After we only get one update
|
||||
# per image downloaded when extraction is finished. It uses the total size
|
||||
# received during downloading to determine percent complete then.
|
||||
job_event(15.2),
|
||||
job_event(18.7),
|
||||
job_event(28.8),
|
||||
job_event(35.7),
|
||||
job_event(42.4),
|
||||
job_event(49.3),
|
||||
job_event(55.8),
|
||||
job_event(62.7),
|
||||
# Downloading phase is considered 70% of layer's progress.
|
||||
# After download complete, extraction takes remaining 30% per layer.
|
||||
job_event(70.0),
|
||||
job_event(84.8),
|
||||
job_event(85.0),
|
||||
job_event(100),
|
||||
job_event(100, True),
|
||||
]
|
||||
|
||||
786
tests/docker/test_pull_progress.py
Normal file
786
tests/docker/test_pull_progress.py
Normal file
@@ -0,0 +1,786 @@
|
||||
"""Tests for image pull progress tracking."""
|
||||
|
||||
import pytest
|
||||
|
||||
from supervisor.docker.manager import PullLogEntry, PullProgressDetail
|
||||
from supervisor.docker.pull_progress import (
|
||||
DOWNLOAD_WEIGHT,
|
||||
EXTRACT_WEIGHT,
|
||||
ImagePullProgress,
|
||||
LayerProgress,
|
||||
)
|
||||
|
||||
|
||||
class TestLayerProgress:
|
||||
"""Tests for LayerProgress class."""
|
||||
|
||||
def test_already_exists_layer(self):
|
||||
"""Test that already existing layer returns 100%."""
|
||||
layer = LayerProgress(layer_id="abc123", already_exists=True)
|
||||
assert layer.calculate_progress() == 100.0
|
||||
|
||||
def test_extract_complete_layer(self):
|
||||
"""Test that extracted layer returns 100%."""
|
||||
layer = LayerProgress(
|
||||
layer_id="abc123",
|
||||
total_size=1000,
|
||||
download_current=1000,
|
||||
download_complete=True,
|
||||
extract_complete=True,
|
||||
)
|
||||
assert layer.calculate_progress() == 100.0
|
||||
|
||||
def test_download_complete_not_extracted(self):
|
||||
"""Test layer that finished downloading but not extracting."""
|
||||
layer = LayerProgress(
|
||||
layer_id="abc123",
|
||||
total_size=1000,
|
||||
download_current=1000,
|
||||
download_complete=True,
|
||||
extract_complete=False,
|
||||
)
|
||||
assert layer.calculate_progress() == DOWNLOAD_WEIGHT # 70%
|
||||
|
||||
def test_extraction_progress_overlay2(self):
|
||||
"""Test layer with byte-based extraction progress (overlay2)."""
|
||||
layer = LayerProgress(
|
||||
layer_id="abc123",
|
||||
total_size=1000,
|
||||
download_current=1000,
|
||||
extract_current=500, # 50% extracted
|
||||
download_complete=True,
|
||||
extract_complete=False,
|
||||
)
|
||||
# 70% + (50% of 30%) = 70% + 15% = 85%
|
||||
assert layer.calculate_progress() == DOWNLOAD_WEIGHT + (0.5 * EXTRACT_WEIGHT)
|
||||
|
||||
def test_downloading_progress(self):
|
||||
"""Test layer during download phase."""
|
||||
layer = LayerProgress(
|
||||
layer_id="abc123",
|
||||
total_size=1000,
|
||||
download_current=500, # 50% downloaded
|
||||
download_complete=False,
|
||||
)
|
||||
# 50% of 70% = 35%
|
||||
assert layer.calculate_progress() == 35.0
|
||||
|
||||
def test_no_size_info_yet(self):
|
||||
"""Test layer with no size information."""
|
||||
layer = LayerProgress(layer_id="abc123")
|
||||
assert layer.calculate_progress() == 0.0
|
||||
|
||||
|
||||
class TestImagePullProgress:
|
||||
"""Tests for ImagePullProgress class."""
|
||||
|
||||
def test_empty_progress(self):
|
||||
"""Test progress with no layers."""
|
||||
progress = ImagePullProgress()
|
||||
assert progress.calculate_progress() == 0.0
|
||||
|
||||
def test_all_layers_already_exist(self):
|
||||
"""Test when all layers already exist locally.
|
||||
|
||||
When an image is fully cached, there are no "Downloading" events.
|
||||
Progress stays at 0 until the job completes and sets 100%.
|
||||
"""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# Simulate "Already exists" events
|
||||
entry1 = PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Already exists",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
entry2 = PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer2",
|
||||
status="Already exists",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
progress.process_event(entry1)
|
||||
progress.process_event(entry2)
|
||||
|
||||
# No downloading events = no progress reported (job completion sets 100%)
|
||||
assert progress.calculate_progress() == 0.0
|
||||
|
||||
def test_single_layer_download(self):
|
||||
"""Test progress tracking for single layer download."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# Pull fs layer
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Start downloading
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=500, total=1000),
|
||||
)
|
||||
)
|
||||
# 50% of download phase = 35%
|
||||
assert progress.calculate_progress() == pytest.approx(35.0)
|
||||
|
||||
# Download complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Download complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
assert progress.calculate_progress() == 70.0
|
||||
|
||||
# Pull complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
assert progress.calculate_progress() == 100.0
|
||||
|
||||
def test_multiple_layers_equal_weight_progress(self):
|
||||
"""Test count-based progress where each layer contributes equally."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# Two layers: sizes don't matter for weight, each layer = 50%
|
||||
|
||||
# Pulling fs layer for both
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="large",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="small",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Large layer: 50% downloaded = 35% layer progress (50% of 70%)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="large",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=500, total=1000),
|
||||
)
|
||||
)
|
||||
|
||||
# Small layer: 100% downloaded, waiting for extraction = 70% layer progress
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="small",
|
||||
status="Download complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="small",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=100, total=100),
|
||||
)
|
||||
)
|
||||
|
||||
# Progress calculation (count-based, equal weight per layer):
|
||||
# Large layer: 35% (50% of 70% download weight)
|
||||
# Small layer: 70% (download complete)
|
||||
# Each layer = 50% weight
|
||||
# Total: (35 + 70) / 2 = 52.5%
|
||||
assert progress.calculate_progress() == pytest.approx(52.5)
|
||||
|
||||
def test_download_retry(self):
|
||||
"""Test that download retry resets progress."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Download 50%
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=500, total=1000),
|
||||
)
|
||||
)
|
||||
assert progress.calculate_progress() == pytest.approx(35.0)
|
||||
|
||||
# Retry
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Retrying in 5 seconds",
|
||||
)
|
||||
)
|
||||
assert progress.calculate_progress() == 0.0
|
||||
|
||||
def test_layer_skips_download(self):
|
||||
"""Test small layer that goes straight to Download complete."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="small",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Goes directly to Download complete (skipping Downloading events)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="small",
|
||||
status="Download complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Should still work - sets minimal size
|
||||
layer = progress.layers["small"]
|
||||
assert layer.total_size == 1
|
||||
assert layer.download_complete is True
|
||||
|
||||
def test_containerd_extract_progress(self):
|
||||
"""Test extraction progress with containerd snapshotter (time-based)."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Download complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=1000, total=1000),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Download complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Containerd extraction progress (time-based, not byte-based)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Extracting",
|
||||
progress_detail=PullProgressDetail(current=5, units="s"),
|
||||
)
|
||||
)
|
||||
|
||||
# Should be at 70% (download complete, time-based extraction not tracked)
|
||||
assert progress.calculate_progress() == 70.0
|
||||
|
||||
# Pull complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
assert progress.calculate_progress() == 100.0
|
||||
|
||||
def test_overlay2_extract_progress(self):
|
||||
"""Test extraction progress with overlay2 (byte-based)."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Download complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=1000, total=1000),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Download complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# At download complete, progress should be 70%
|
||||
assert progress.calculate_progress() == 70.0
|
||||
|
||||
# Overlay2 extraction progress (byte-based, 50% extracted)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Extracting",
|
||||
progress_detail=PullProgressDetail(current=500, total=1000),
|
||||
)
|
||||
)
|
||||
|
||||
# Should be at 70% + (50% of 30%) = 85%
|
||||
assert progress.calculate_progress() == pytest.approx(85.0)
|
||||
|
||||
# Extraction continues to 80%
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Extracting",
|
||||
progress_detail=PullProgressDetail(current=800, total=1000),
|
||||
)
|
||||
)
|
||||
|
||||
# Should be at 70% + (80% of 30%) = 94%
|
||||
assert progress.calculate_progress() == pytest.approx(94.0)
|
||||
|
||||
# Pull complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
assert progress.calculate_progress() == 100.0
|
||||
|
||||
def test_get_stage(self):
|
||||
"""Test stage detection."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
assert progress.get_stage() is None
|
||||
|
||||
# Add a layer that needs downloading
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=500, total=1000),
|
||||
)
|
||||
)
|
||||
assert progress.get_stage() == "Downloading"
|
||||
|
||||
# Download complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Download complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
assert progress.get_stage() == "Extracting"
|
||||
|
||||
# Pull complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
assert progress.get_stage() == "Pull complete"
|
||||
|
||||
def test_should_update_job(self):
|
||||
"""Test update threshold logic."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# Initial state - no updates
|
||||
should_update, _ = progress.should_update_job()
|
||||
assert not should_update
|
||||
|
||||
# Add a layer and start downloading
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Small progress - 1%
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=20, total=1000),
|
||||
)
|
||||
)
|
||||
# 2% of download = 1.4% total
|
||||
should_update, current = progress.should_update_job()
|
||||
assert should_update
|
||||
assert current == pytest.approx(1.4)
|
||||
|
||||
# Tiny increment - shouldn't trigger update
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=25, total=1000),
|
||||
)
|
||||
)
|
||||
should_update, _ = progress.should_update_job()
|
||||
assert not should_update
|
||||
|
||||
# Larger increment - should trigger
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=100, total=1000),
|
||||
)
|
||||
)
|
||||
should_update, _ = progress.should_update_job()
|
||||
assert should_update
|
||||
|
||||
def test_verifying_checksum(self):
|
||||
"""Test that Verifying Checksum marks download as nearly complete."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=800, total=1000),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Verifying Checksum",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
layer = progress.layers["layer1"]
|
||||
assert layer.download_current == 1000 # Should be set to total
|
||||
|
||||
def test_events_without_status_ignored(self):
|
||||
"""Test that events without status are ignored."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# Event without status (just id field)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="abc123",
|
||||
)
|
||||
)
|
||||
|
||||
# Event without id
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
status="Digest: sha256:abc123",
|
||||
)
|
||||
)
|
||||
|
||||
# They shouldn't create layers or cause errors
|
||||
assert len(progress.layers) == 0
|
||||
|
||||
def test_mixed_already_exists_and_pull(self):
|
||||
"""Test combination of cached and pulled layers."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# Layer 1 already exists
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="cached",
|
||||
status="Already exists",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Layer 2 needs to be pulled
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="pulled",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="pulled",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=500, total=1000),
|
||||
)
|
||||
)
|
||||
|
||||
# Only 1 layer needs pulling (cached layer excluded)
|
||||
# pulled: 35% (50% of 70% download weight)
|
||||
assert progress.calculate_progress() == pytest.approx(35.0)
|
||||
|
||||
# Complete the pulled layer
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="pulled",
|
||||
status="Download complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="pulled",
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
assert progress.calculate_progress() == 100.0
|
||||
|
||||
def test_pending_layers_prevent_premature_100(self):
|
||||
"""Test that layers without size info scale down progress."""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# First batch of layers - they complete
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer2",
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Layer1 downloads and completes
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=1000, total=1000),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer1",
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Layer2 is still pending (no size info yet) - simulating Docker rate limiting
|
||||
# Progress should NOT be 100% because layer2 hasn't started
|
||||
|
||||
# Layer1 is 100% complete, layer2 is 0%
|
||||
# With scaling: 1 known layer at 100%, 1 pending layer
|
||||
# Scale factor = 1/(1+1) = 0.5, so progress = 100 * 0.5 = 50%
|
||||
assert progress.calculate_progress() == pytest.approx(50.0)
|
||||
|
||||
# Now layer2 starts downloading
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer2",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=500, total=1000),
|
||||
)
|
||||
)
|
||||
|
||||
# Now both layers have size info, no scaling needed
|
||||
# Layer1: 100%, Layer2: 35% (50% of 70%)
|
||||
# Weighted by equal size: (100 + 35) / 2 = 67.5%
|
||||
assert progress.calculate_progress() == pytest.approx(67.5)
|
||||
|
||||
# Complete layer2
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="layer2",
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
assert progress.calculate_progress() == 100.0
|
||||
|
||||
def test_large_layers_appearing_late_dont_cause_regression(self):
|
||||
"""Test that large layers discovered late don't cause progress to drop.
|
||||
|
||||
This simulates Docker's rate-limiting behavior where small layers complete
|
||||
first, then large layers start downloading later.
|
||||
"""
|
||||
progress = ImagePullProgress()
|
||||
|
||||
# All layers announced upfront (Docker does this)
|
||||
for layer_id in ["small1", "small2", "big1", "big2"]:
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id=layer_id,
|
||||
status="Pulling fs layer",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Big layers are "Waiting" (rate limited)
|
||||
for layer_id in ["big1", "big2"]:
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id=layer_id,
|
||||
status="Waiting",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# Small layers download quickly (1KB each)
|
||||
for layer_id in ["small1", "small2"]:
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id=layer_id,
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=1000, total=1000),
|
||||
)
|
||||
)
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id=layer_id,
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
# At this point, 2 small layers are complete, 2 big layers are unknown size
|
||||
progress_before_big = progress.calculate_progress()
|
||||
|
||||
# Now big layers start downloading - they're 100MB each!
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="big1",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=1000000, total=100000000),
|
||||
)
|
||||
)
|
||||
|
||||
progress_after_big1 = progress.calculate_progress()
|
||||
|
||||
# Progress should NOT drop significantly when big layer appears
|
||||
# The monotonic tracking in should_update_job will help, but the
|
||||
# raw calculation should also not regress too badly
|
||||
assert progress_after_big1 >= progress_before_big * 0.5, (
|
||||
f"Progress dropped too much: {progress_before_big} -> {progress_after_big1}"
|
||||
)
|
||||
|
||||
# Second big layer appears
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id="big2",
|
||||
status="Downloading",
|
||||
progress_detail=PullProgressDetail(current=1000000, total=100000000),
|
||||
)
|
||||
)
|
||||
|
||||
# Should still make forward progress overall
|
||||
# Complete all layers
|
||||
for layer_id in ["big1", "big2"]:
|
||||
progress.process_event(
|
||||
PullLogEntry(
|
||||
job_id="test",
|
||||
id=layer_id,
|
||||
status="Pull complete",
|
||||
progress_detail=PullProgressDetail(),
|
||||
)
|
||||
)
|
||||
|
||||
assert progress.calculate_progress() == 100.0
|
||||
@@ -9,7 +9,7 @@ from awesomeversion import AwesomeVersion
|
||||
import pytest
|
||||
|
||||
from supervisor.addons.addon import Addon
|
||||
from supervisor.arch import CpuArch
|
||||
from supervisor.arch import CpuArchManager
|
||||
from supervisor.backups.manager import BackupManager
|
||||
from supervisor.coresys import CoreSys
|
||||
from supervisor.exceptions import AddonNotSupportedError, StoreJobError
|
||||
@@ -163,7 +163,9 @@ async def test_update_unavailable_addon(
|
||||
with (
|
||||
patch.object(BackupManager, "do_backup_partial") as backup,
|
||||
patch.object(AddonStore, "data", new=PropertyMock(return_value=addon_config)),
|
||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])),
|
||||
patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
),
|
||||
patch.object(CoreSys, "machine", new=PropertyMock(return_value="qemux86-64")),
|
||||
patch.object(
|
||||
HomeAssistant,
|
||||
@@ -219,7 +221,9 @@ async def test_install_unavailable_addon(
|
||||
|
||||
with (
|
||||
patch.object(AddonStore, "data", new=PropertyMock(return_value=addon_config)),
|
||||
patch.object(CpuArch, "supported", new=PropertyMock(return_value=["amd64"])),
|
||||
patch.object(
|
||||
CpuArchManager, "supported", new=PropertyMock(return_value=["amd64"])
|
||||
),
|
||||
patch.object(CoreSys, "machine", new=PropertyMock(return_value="qemux86-64")),
|
||||
patch.object(
|
||||
HomeAssistant,
|
||||
|
||||
Reference in New Issue
Block a user