mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-09-30 01:08:58 +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
142 lines
4.0 KiB
Python
142 lines
4.0 KiB
Python
"""Test audio plugin."""
|
|
|
|
import errno
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
|
|
from supervisor.const import LogLevel
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.audio import DockerAudio
|
|
from supervisor.exceptions import (
|
|
AudioNotRunningError,
|
|
AudioStatsTimeoutError,
|
|
AudioUnknownError,
|
|
DockerContainerNotFoundError,
|
|
DockerContainerNotRunningError,
|
|
DockerError,
|
|
DockerStatsTimeoutError,
|
|
)
|
|
|
|
|
|
@pytest.fixture(name="docker_interface")
|
|
async def fixture_docker_interface() -> tuple[AsyncMock, AsyncMock]:
|
|
"""Mock docker interface methods."""
|
|
with (
|
|
patch.object(DockerAudio, "run") as run,
|
|
patch.object(DockerAudio, "restart") as restart,
|
|
):
|
|
yield (run, restart)
|
|
|
|
|
|
@pytest.fixture(name="write_json")
|
|
async def fixture_write_json() -> Mock:
|
|
"""Mock json file writer."""
|
|
with patch("supervisor.plugins.audio.write_json_file") as write_json_file:
|
|
yield write_json_file
|
|
|
|
|
|
async def test_config_write(
|
|
coresys: CoreSys,
|
|
docker_interface: tuple[AsyncMock, AsyncMock],
|
|
write_json: Mock,
|
|
):
|
|
"""Test config write on audio start and restart."""
|
|
await coresys.plugins.audio.start()
|
|
docker_interface[0].assert_called_once()
|
|
docker_interface[1].assert_not_called()
|
|
|
|
write_json.assert_called_once_with(
|
|
Path("/data/audio/pulse_audio.json"),
|
|
{
|
|
"debug": False,
|
|
},
|
|
)
|
|
|
|
docker_interface[0].reset_mock()
|
|
write_json.reset_mock()
|
|
coresys.config.logging = LogLevel.DEBUG
|
|
|
|
await coresys.plugins.audio.restart()
|
|
docker_interface[0].assert_not_called()
|
|
docker_interface[1].assert_called_once()
|
|
|
|
write_json.assert_called_once_with(
|
|
Path("/data/audio/pulse_audio.json"),
|
|
{
|
|
"debug": True,
|
|
},
|
|
)
|
|
|
|
|
|
async def test_load_error(
|
|
coresys: CoreSys, caplog: pytest.LogCaptureFixture, container
|
|
):
|
|
"""Test error reading config file during load."""
|
|
with (
|
|
patch(
|
|
"supervisor.plugins.audio.Path.read_text", side_effect=(err := OSError())
|
|
),
|
|
patch("supervisor.plugins.audio.shutil.copy", side_effect=err),
|
|
):
|
|
err.errno = errno.EBUSY
|
|
await coresys.plugins.audio.load()
|
|
|
|
assert "Can't read pulse-client.tmpl" in caplog.text
|
|
assert "Can't create default asound" in caplog.text
|
|
assert coresys.core.healthy is True
|
|
|
|
caplog.clear()
|
|
err.errno = errno.EBADMSG
|
|
await coresys.plugins.audio.load()
|
|
|
|
assert "Can't read pulse-client.tmpl" in caplog.text
|
|
assert "Can't create default asound" in caplog.text
|
|
assert coresys.core.healthy is False
|
|
|
|
|
|
async def test_stats_not_running(coresys: CoreSys):
|
|
"""Test stats raises AudioNotRunningError when the container isn't running."""
|
|
with (
|
|
patch.object(
|
|
DockerAudio,
|
|
"stats",
|
|
AsyncMock(side_effect=DockerContainerNotRunningError(name="hassio_audio")),
|
|
),
|
|
pytest.raises(AudioNotRunningError),
|
|
):
|
|
await coresys.plugins.audio.stats()
|
|
|
|
with (
|
|
patch.object(
|
|
DockerAudio,
|
|
"stats",
|
|
AsyncMock(side_effect=DockerContainerNotFoundError(name="hassio_audio")),
|
|
),
|
|
pytest.raises(AudioNotRunningError),
|
|
):
|
|
await coresys.plugins.audio.stats()
|
|
|
|
|
|
async def test_stats_timeout(coresys: CoreSys):
|
|
"""Test stats raises AudioStatsTimeoutError on timeout."""
|
|
with (
|
|
patch.object(
|
|
DockerAudio,
|
|
"stats",
|
|
AsyncMock(side_effect=DockerStatsTimeoutError(name="hassio_audio")),
|
|
),
|
|
pytest.raises(AudioStatsTimeoutError),
|
|
):
|
|
await coresys.plugins.audio.stats()
|
|
|
|
|
|
async def test_stats_unknown_error(coresys: CoreSys):
|
|
"""Test stats raises AudioUnknownError on an unexpected Docker error."""
|
|
with (
|
|
patch.object(DockerAudio, "stats", AsyncMock(side_effect=DockerError("boom"))),
|
|
pytest.raises(AudioUnknownError),
|
|
):
|
|
await coresys.plugins.audio.stats()
|