mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-09-30 19:33:44 +01:00
* Add one-shot mode and docker-given totals to stats API - Add one-shot query option to container stats to skip the calculated CPU percentage window, returning docker-given totals directly. - Refactor container_stats() with translatable, structured Docker errors and timeout handling instead of generic Docker exceptions. - Translate low-level Docker errors into component-flavored errors (NotRunningError/StatsTimeoutError/UnknownError) for Home Assistant, Supervisor, and each plugin (Audio, Cli, CoreDNS, Multicast, Observer) as well as apps, mirroring the existing App.stats() pattern for consistent, informative API responses. - Add a shared api_return_stats() helper to remove duplicated stats handling logic across the 8 stats API endpoints. - DockerContainerNotFoundError now inherits from DockerNotFound. * Contain aiodocker _query access and split v1/v2 stats response model - Contain the reach into aiodocker's protected _query internals (needed for one-shot stats until aio-libs/aiodocker#1054 lands) to a single DockerAPI._query_one_shot_stats() helper. - Split the stats API response model between v1 (legacy, windowed CPU percentage still available via opt-in one_shot query string) and v2 (always one-shot, no query string, cpu_percent dropped since it would never carry a meaningful value). Both share the same api_return_stats(stats, legacy=...) helper so the response model stays in one place. - DockerAPI.container_stats() keeps its existing one_shot keyword only; the legacy/v1 vs v2 distinction is handled entirely in the API layer. * Detect stopped/restarting containers in one-shot stats response Docker returns a stub response containing only the container's id/name (no cpu_stats/memory_stats/networks data) for a stopped or restarting container instead of an error, even in one-shot mode - the not-running check in daemon.ContainerStats happens before the one-shot branch, so requesting one-shot stats doesn't change this. Detect the missing online_cpus field (only ever present while running) and raise DockerContainerNotRunningError instead of returning a payload that DockerStats would otherwise turn into a successful all-zero response. * Add tests for stats error paths introduced in one-shot/totals PR - Cover DockerAPI.container_stats/_query_one_shot_stats edge cases: empty one-shot response, empty windowed response, and the raw _query_one_shot_stats helper. - Add stats() error-translation tests (not-running, timeout, unknown) for the audio, cli, multicast and observer plugins, none of which had any coverage. - Add stats endpoint tests (v1 + v2) for the cli, audio, dns, multicast and observer REST APIs, plus a v1 success-path test for the apps stats endpoint. These close the codecov-flagged gaps for newly introduced code in this PR. * Unify one-shot and windowed container_stats() code paths Both modes now share the same error handling and not-running detection: - The windowed path no longer inspects the container first to check its running state. Docker returns the same id/name-only stub response (no online_cpus in cpu_stats) for a stopped or restarting container in windowed mode as it does in one-shot mode, so the existing online_cpus presence check now covers both, closing a race window where a container stopping between the inspect and the stats call would previously reach the DockerStats parser with an incomplete payload. - The windowed path builds its container handle via containers.container(), which constructs a DockerContainer from the name with no I/O, instead of containers.get(), which performs an unnecessary inspect call just to fetch the same information the stats call already returns. * Fix protected access pylint issue
98 lines
3.5 KiB
Python
98 lines
3.5 KiB
Python
"""Test observer plugin."""
|
|
|
|
from http import HTTPStatus
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import aiodocker
|
|
import pytest
|
|
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.observer import DockerObserver
|
|
from supervisor.exceptions import (
|
|
DockerContainerNotFoundError,
|
|
DockerContainerNotRunningError,
|
|
DockerError,
|
|
DockerStatsTimeoutError,
|
|
ObserverNotRunningError,
|
|
ObserverPortConflict,
|
|
ObserverStatsTimeoutError,
|
|
ObserverUnknownError,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"docker_message",
|
|
[
|
|
"failed to set up container networking: driver failed programming external connectivity on endpoint hassio_observer (ea4d0fdaa72cf86f2c9199a04208e3eaf0c5a0d6fd34b3c7f4fab2daadb1f3a9): failed to bind host port for 0.0.0.0:4357:172.30.33.4:80/tcp: address already in use",
|
|
"failed to set up container networking: driver failed programming external connectivity on endpoint hassio_observer (ea4d0fdaa72cf86f2c9199a04208e3eaf0c5a0d6fd34b3c7f4fab2daadb1f3a9): Bind for 0.0.0.0:4357 failed: port is already allocated",
|
|
"failed to set up container networking: driver failed programming external connectivity on endpoint hassio_observer (ea4d0fdaa72cf86f2c9199a04208e3eaf0c5a0d6fd34b3c7f4fab2daadb1f3a9): failed to bind host port 0.0.0.0:4357/tcp: address already in use",
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("container", "tmp_supervisor_data", "path_extern")
|
|
async def test_observer_start_port_conflict(
|
|
coresys: CoreSys, caplog: pytest.LogCaptureFixture, docker_message: str
|
|
):
|
|
"""Test port conflict error when trying to start observer."""
|
|
coresys.docker.containers.create.return_value.start.side_effect = (
|
|
aiodocker.DockerError(HTTPStatus.INTERNAL_SERVER_ERROR, docker_message)
|
|
)
|
|
await coresys.plugins.observer.load()
|
|
|
|
caplog.clear()
|
|
with pytest.raises(ObserverPortConflict):
|
|
await coresys.plugins.observer.start()
|
|
|
|
assert (
|
|
"Cannot start container hassio_observer because port 4357 is already in use"
|
|
in caplog.text
|
|
)
|
|
|
|
|
|
async def test_stats_not_running(coresys: CoreSys):
|
|
"""Test stats raises ObserverNotRunningError when the container isn't running."""
|
|
with (
|
|
patch.object(
|
|
DockerObserver,
|
|
"stats",
|
|
AsyncMock(
|
|
side_effect=DockerContainerNotRunningError(name="hassio_observer")
|
|
),
|
|
),
|
|
pytest.raises(ObserverNotRunningError),
|
|
):
|
|
await coresys.plugins.observer.stats()
|
|
|
|
with (
|
|
patch.object(
|
|
DockerObserver,
|
|
"stats",
|
|
AsyncMock(side_effect=DockerContainerNotFoundError(name="hassio_observer")),
|
|
),
|
|
pytest.raises(ObserverNotRunningError),
|
|
):
|
|
await coresys.plugins.observer.stats()
|
|
|
|
|
|
async def test_stats_timeout(coresys: CoreSys):
|
|
"""Test stats raises ObserverStatsTimeoutError on timeout."""
|
|
with (
|
|
patch.object(
|
|
DockerObserver,
|
|
"stats",
|
|
AsyncMock(side_effect=DockerStatsTimeoutError(name="hassio_observer")),
|
|
),
|
|
pytest.raises(ObserverStatsTimeoutError),
|
|
):
|
|
await coresys.plugins.observer.stats()
|
|
|
|
|
|
async def test_stats_unknown_error(coresys: CoreSys):
|
|
"""Test stats raises ObserverUnknownError on an unexpected Docker error."""
|
|
with (
|
|
patch.object(
|
|
DockerObserver, "stats", AsyncMock(side_effect=DockerError("boom"))
|
|
),
|
|
pytest.raises(ObserverUnknownError),
|
|
):
|
|
await coresys.plugins.observer.stats()
|