mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-05-21 07:08:53 +01:00
c772a9bbb0
* Replace fixed-duration sleeps after bus events with gather Several tests use ``await asyncio.sleep(...)`` to "wait for the listener to run" after firing a bus event. The fixed duration is real wall-clock time and the wait can be indeterministic — if the handler chain happens to need slightly more time on a busy CI runner, the assertion races the handler. ``Bus.fire_event`` returns the listener tasks since #6252; capture and ``await asyncio.gather(*tasks)`` instead of sleeping. Touches test_bus.py (the bus tests were poking scheduling instead of verifying their assertions), test_home_assistant_watchdog.py, test_plugin_base.py, addons/test_manager.py, docker/test_addon.py, and test_store_execute_reload.py. Other cleanups in the same spirit: - ``_fire_test_event`` in addons/test_addon.py becomes ``async def`` and gathers the listener tasks itself, so its 17 call sites collapse to a single ``await _fire_test_event(...)``. - The two test_store_execute_reload.py sites that used the private ``_update_connectivity()`` helper are reworked to set the cached connectivity flag directly and fire the event themselves so they can gather the listener tasks the same way. - The two ``sleep(1)`` post-pull drains in docker/test_interface.py collapse to ``sleep(0)`` (handler tasks are already gathered inside pull_image), saving ~2s. - The ``sleep(0.01)`` waits inside ``container_events()`` task bodies (api/test_addons.py, api/test_store.py, backups/test_manager.py) are just one-yield-to-the-parent and become ``sleep(0)``. Switching to ``gather`` exposes a few latent test mocks that were silently swallowing TypeErrors as background-task failures before: - ``CGroup.add_devices_allowed`` is ``async def`` but was patched as a plain MagicMock in docker/test_addon.py — now patched via ``new_callable=AsyncMock``. - The watchdog does ``await (await self.start())`` / ``await (await self.restart())`` because ``App.start`` / ``App.restart`` return ``asyncio.Task``. The mocks in addons/test_addon.py (test_app_watchdog, test_watchdog_on_stop, test_watchdog_during_attach) needed ``AsyncMock(return_value=<settled future>)`` to mirror that shape rather than a plain MagicMock. * Factor bus.fire_event + gather pattern into a helper Per review feedback, the ``await asyncio.gather(*coresys.bus.fire_event(...))`` incantation was scattered across many call sites. Add ``tests.common.fire_bus_event`` that takes the coresys, event and data, fires the event and awaits the spawned listener tasks. Convert all matching sites to use it, including the ``_fire_test_event`` wrapper in addons/test_addon.py which now just builds the ``DockerContainerStateEvent`` and delegates.
396 lines
13 KiB
Python
396 lines
13 KiB
Python
"""Test base plugin functionality."""
|
|
|
|
from unittest.mock import ANY, Mock, PropertyMock, call, patch
|
|
|
|
from aiodocker.containers import DockerContainer
|
|
from awesomeversion import AwesomeVersion
|
|
import pytest
|
|
|
|
from supervisor.const import BusEvent, CpuArch
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.const import ContainerState
|
|
from supervisor.docker.interface import DockerInterface
|
|
from supervisor.docker.manager import DockerAPI
|
|
from supervisor.docker.monitor import DockerContainerStateEvent
|
|
from supervisor.exceptions import (
|
|
AudioError,
|
|
AudioJobError,
|
|
CliError,
|
|
CliJobError,
|
|
CoreDNSError,
|
|
CoreDNSJobError,
|
|
DockerError,
|
|
MulticastError,
|
|
MulticastJobError,
|
|
ObserverError,
|
|
ObserverJobError,
|
|
PluginError,
|
|
PluginJobError,
|
|
)
|
|
from supervisor.plugins.audio import PluginAudio
|
|
from supervisor.plugins.base import PluginBase
|
|
from supervisor.plugins.cli import PluginCli
|
|
from supervisor.plugins.dns import PluginDns
|
|
from supervisor.plugins.multicast import PluginMulticast
|
|
from supervisor.plugins.observer import PluginObserver
|
|
from supervisor.utils import check_exception_chain
|
|
|
|
from tests.common import fire_bus_event
|
|
|
|
|
|
@pytest.fixture(name="plugin")
|
|
async def fixture_plugin(
|
|
coresys: CoreSys, request: pytest.FixtureRequest
|
|
) -> PluginBase:
|
|
"""Get plugin from param."""
|
|
if request.param == PluginAudio:
|
|
yield coresys.plugins.audio
|
|
elif request.param == PluginCli:
|
|
yield coresys.plugins.cli
|
|
elif request.param == PluginDns:
|
|
with patch.object(PluginDns, "loop_detection"):
|
|
yield coresys.plugins.dns
|
|
elif request.param == PluginMulticast:
|
|
yield coresys.plugins.multicast
|
|
elif request.param == PluginObserver:
|
|
yield coresys.plugins.observer
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin",
|
|
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
|
indirect=True,
|
|
)
|
|
async def test_plugin_watchdog(coresys: CoreSys, plugin: PluginBase) -> None:
|
|
"""Test plugin watchdog works correctly."""
|
|
with (
|
|
patch.object(type(plugin.instance), "attach"),
|
|
patch.object(type(plugin.instance), "is_running", return_value=True),
|
|
):
|
|
await plugin.load()
|
|
|
|
with (
|
|
patch.object(type(plugin), "rebuild") as rebuild,
|
|
patch.object(type(plugin), "start") as start,
|
|
patch.object(type(plugin.instance), "current_state") as current_state,
|
|
):
|
|
current_state.return_value = ContainerState.UNHEALTHY
|
|
await fire_bus_event(
|
|
coresys,
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.UNHEALTHY,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
rebuild.assert_called_once()
|
|
start.assert_not_called()
|
|
|
|
rebuild.reset_mock()
|
|
current_state.return_value = ContainerState.FAILED
|
|
await fire_bus_event(
|
|
coresys,
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.FAILED,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
rebuild.assert_called_once()
|
|
start.assert_not_called()
|
|
|
|
rebuild.reset_mock()
|
|
# Stop should be ignored as it means an update or system shutdown, plugins don't stop otherwise
|
|
current_state.return_value = ContainerState.STOPPED
|
|
await fire_bus_event(
|
|
coresys,
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.STOPPED,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
rebuild.assert_not_called()
|
|
start.assert_not_called()
|
|
|
|
# Do not process event if container state has changed since fired
|
|
current_state.return_value = ContainerState.HEALTHY
|
|
await fire_bus_event(
|
|
coresys,
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.FAILED,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
rebuild.assert_not_called()
|
|
start.assert_not_called()
|
|
|
|
# Other containers ignored
|
|
await fire_bus_event(
|
|
coresys,
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name="addon_local_other",
|
|
state=ContainerState.UNHEALTHY,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
rebuild.assert_not_called()
|
|
start.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin,error",
|
|
[
|
|
(PluginAudio, AudioError()),
|
|
(PluginCli, CliError()),
|
|
(PluginDns, CoreDNSError()),
|
|
(PluginMulticast, MulticastError()),
|
|
(PluginObserver, ObserverError()),
|
|
],
|
|
indirect=["plugin"],
|
|
)
|
|
@pytest.mark.usefixtures("coresys", "tmp_supervisor_data", "path_extern")
|
|
async def test_plugin_watchdog_max_failed_attempts(
|
|
capture_exception: Mock,
|
|
plugin: PluginBase,
|
|
error: PluginError,
|
|
container: DockerContainer,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test plugin watchdog gives up after max failed attempts."""
|
|
with patch.object(type(plugin.instance), "attach"):
|
|
await plugin.load()
|
|
|
|
container.show.return_value["State"]["Status"] = "stopped"
|
|
container.show.return_value["State"]["Running"] = False
|
|
container.show.return_value["State"]["ExitCode"] = 1
|
|
with (
|
|
patch("supervisor.plugins.base.WATCHDOG_RETRY_SECONDS", 0),
|
|
patch.object(type(plugin), "start", side_effect=error) as start,
|
|
):
|
|
await plugin.watchdog_container(
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.FAILED,
|
|
id="abc123",
|
|
time=1,
|
|
)
|
|
)
|
|
assert start.call_count == 5
|
|
|
|
capture_exception.assert_called_with(error)
|
|
assert (
|
|
f"Watchdog cannot restart {plugin.slug} plugin, failed all 5 attempts"
|
|
in caplog.text
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin",
|
|
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
|
indirect=True,
|
|
)
|
|
async def test_plugin_load_running_container(
|
|
coresys: CoreSys, plugin: PluginBase
|
|
) -> None:
|
|
"""Test plugins load and attach to a running container."""
|
|
test_version = AwesomeVersion("2022.7.3")
|
|
with (
|
|
patch.object(type(coresys.bus), "register_event") as register_event,
|
|
patch.object(type(plugin.instance), "attach") as attach,
|
|
patch.object(type(plugin), "install") as install,
|
|
patch.object(type(plugin), "start") as start,
|
|
patch.object(
|
|
type(plugin.instance),
|
|
"get_latest_version",
|
|
return_value=test_version,
|
|
),
|
|
patch.object(type(plugin.instance), "is_running", return_value=True),
|
|
):
|
|
await plugin.load()
|
|
register_event.assert_any_call(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, plugin.watchdog_container
|
|
)
|
|
attach.assert_called_once_with(
|
|
version=test_version, skip_state_event_if_down=True
|
|
)
|
|
install.assert_not_called()
|
|
start.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin",
|
|
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
|
indirect=True,
|
|
)
|
|
async def test_plugin_load_stopped_container(
|
|
coresys: CoreSys, plugin: PluginBase
|
|
) -> None:
|
|
"""Test plugins load and start existing container."""
|
|
test_version = AwesomeVersion("2022.7.3")
|
|
with (
|
|
patch.object(type(coresys.bus), "register_event") as register_event,
|
|
patch.object(type(plugin.instance), "attach") as attach,
|
|
patch.object(type(plugin), "install") as install,
|
|
patch.object(type(plugin), "start") as start,
|
|
patch.object(
|
|
type(plugin.instance),
|
|
"get_latest_version",
|
|
return_value=test_version,
|
|
),
|
|
patch.object(type(plugin.instance), "is_running", return_value=False),
|
|
):
|
|
await plugin.load()
|
|
register_event.assert_any_call(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, plugin.watchdog_container
|
|
)
|
|
attach.assert_called_once_with(
|
|
version=test_version, skip_state_event_if_down=True
|
|
)
|
|
install.assert_not_called()
|
|
start.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin",
|
|
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
|
indirect=True,
|
|
)
|
|
async def test_plugin_load_missing_container(
|
|
coresys: CoreSys, plugin: PluginBase
|
|
) -> None:
|
|
"""Test plugins load and create and start container."""
|
|
test_version = AwesomeVersion("2022.7.3")
|
|
with (
|
|
patch.object(type(coresys.bus), "register_event") as register_event,
|
|
patch.object(
|
|
type(plugin.instance), "attach", side_effect=DockerError()
|
|
) as attach,
|
|
patch.object(type(plugin), "install") as install,
|
|
patch.object(type(plugin), "start") as start,
|
|
patch.object(
|
|
type(plugin.instance),
|
|
"get_latest_version",
|
|
return_value=test_version,
|
|
),
|
|
patch.object(type(plugin.instance), "is_running", return_value=False),
|
|
):
|
|
await plugin.load()
|
|
register_event.assert_any_call(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE, plugin.watchdog_container
|
|
)
|
|
attach.assert_called_once_with(
|
|
version=test_version, skip_state_event_if_down=True
|
|
)
|
|
install.assert_called_once()
|
|
start.assert_called_once()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin,error",
|
|
[
|
|
(PluginAudio, AudioJobError),
|
|
(PluginCli, CliJobError),
|
|
(PluginDns, CoreDNSJobError),
|
|
(PluginMulticast, MulticastJobError),
|
|
(PluginObserver, ObserverJobError),
|
|
],
|
|
indirect=["plugin"],
|
|
)
|
|
async def test_update_fails_if_out_of_date(
|
|
coresys: CoreSys, plugin: PluginBase, error: PluginJobError
|
|
):
|
|
"""Test update of plugins fail when supervisor is out of date."""
|
|
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
|
|
|
|
with (
|
|
patch.object(
|
|
type(coresys.supervisor), "need_update", new=PropertyMock(return_value=True)
|
|
),
|
|
pytest.raises(error),
|
|
):
|
|
await plugin.update()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin",
|
|
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.usefixtures("coresys")
|
|
async def test_repair_failed(capture_exception: Mock, plugin: PluginBase):
|
|
"""Test repair failed."""
|
|
with (
|
|
patch.object(DockerInterface, "exists", return_value=False),
|
|
patch.object(
|
|
DockerInterface, "arch", new=PropertyMock(return_value=CpuArch.AMD64)
|
|
),
|
|
patch.object(DockerInterface, "install", side_effect=DockerError),
|
|
):
|
|
await plugin.repair()
|
|
|
|
capture_exception.assert_called_once()
|
|
assert check_exception_chain(capture_exception.call_args[0][0], DockerError)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin",
|
|
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
|
indirect=True,
|
|
)
|
|
async def test_load_with_incorrect_image(
|
|
coresys: CoreSys, container: DockerContainer, plugin: PluginBase
|
|
):
|
|
"""Test plugin loads with the incorrect image."""
|
|
plugin.image = old_image = f"ghcr.io/home-assistant/aarch64-hassio-{plugin.slug}"
|
|
correct_image = f"ghcr.io/home-assistant/amd64-hassio-{plugin.slug}"
|
|
coresys.updater._data["image"][plugin.slug] = correct_image # pylint: disable=protected-access
|
|
plugin.version = AwesomeVersion("2024.4.0")
|
|
|
|
container.show.return_value["State"]["Status"] = "running"
|
|
container.show.return_value["State"]["Running"] = True
|
|
coresys.docker.images.inspect.return_value = img_data = (
|
|
coresys.docker.images.inspect.return_value
|
|
| {"Config": {"Labels": {"io.hass.version": "2024.4.0"}}}
|
|
)
|
|
container.show.return_value |= img_data
|
|
|
|
with patch.object(DockerAPI, "pull_image", return_value=img_data) as pull_image:
|
|
await plugin.load()
|
|
pull_image.assert_called_once_with(
|
|
ANY, correct_image, "2024.4.0", platform="linux/amd64", auth=None
|
|
)
|
|
|
|
container.delete.assert_called_once_with(force=True, v=True)
|
|
assert coresys.docker.images.delete.call_args_list[0] == call(
|
|
f"{old_image}:latest",
|
|
force=True,
|
|
)
|
|
assert coresys.docker.images.delete.call_args_list[1] == call(
|
|
f"{old_image}:2024.4.0",
|
|
force=True,
|
|
)
|
|
assert plugin.image == correct_image
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"plugin",
|
|
[PluginAudio, PluginCli, PluginDns, PluginMulticast, PluginObserver],
|
|
indirect=True,
|
|
)
|
|
async def test_default_image_fallback(coresys: CoreSys, plugin: PluginBase):
|
|
"""Test default image falls back to hard-coded constant if we fail to fetch version file."""
|
|
assert getattr(coresys.updater, f"image_{plugin.slug}") is None
|
|
assert plugin.default_image == f"ghcr.io/home-assistant/amd64-hassio-{plugin.slug}"
|