mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-05-06 19:08:39 +00:00

* Docker events based watchdog * Separate monitor from DockerAPI since it needs coresys * Move monitor into dockerAPI * Fix properties on coresys * Add watchdog tests * Added tests * pylint issue * Current state failures test * Thread-safe event processing * Use labels property
305 lines
9.9 KiB
Python
305 lines
9.9 KiB
Python
"""Test base plugin functionality."""
|
|
import asyncio
|
|
from unittest.mock import patch
|
|
|
|
from awesomeversion import AwesomeVersion
|
|
import pytest
|
|
|
|
from supervisor.const import BusEvent
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.docker.const import ContainerState
|
|
from supervisor.docker.monitor import DockerContainerStateEvent
|
|
from supervisor.exceptions import (
|
|
AudioError,
|
|
CliError,
|
|
CoreDNSError,
|
|
DockerError,
|
|
MulticastError,
|
|
ObserverError,
|
|
PluginError,
|
|
)
|
|
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
|
|
|
|
|
|
@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
|
|
|
|
|
|
async def mock_current_state(state: ContainerState) -> ContainerState:
|
|
"""Mock for current state method."""
|
|
return state
|
|
|
|
|
|
async def mock_is_running(running: bool) -> bool:
|
|
"""Mock for is running method."""
|
|
return running
|
|
|
|
|
|
async def mock_get_latest_version(version: AwesomeVersion) -> AwesomeVersion:
|
|
"""Mock for get latest version method."""
|
|
return version
|
|
|
|
|
|
@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=mock_is_running(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 = mock_current_state(ContainerState.UNHEALTHY)
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.UNHEALTHY,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0)
|
|
rebuild.assert_called_once()
|
|
start.assert_not_called()
|
|
|
|
rebuild.reset_mock()
|
|
current_state.return_value = mock_current_state(ContainerState.FAILED)
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.FAILED,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0)
|
|
rebuild.assert_called_once()
|
|
start.assert_not_called()
|
|
|
|
rebuild.reset_mock()
|
|
# Plugins are restarted anytime they stop, not just on failure
|
|
current_state.return_value = mock_current_state(ContainerState.STOPPED)
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.STOPPED,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0)
|
|
rebuild.assert_not_called()
|
|
start.assert_called_once()
|
|
|
|
start.reset_mock()
|
|
# Do not process event if container state has changed since fired
|
|
current_state.return_value = mock_current_state(ContainerState.HEALTHY)
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.FAILED,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0)
|
|
rebuild.assert_not_called()
|
|
start.assert_not_called()
|
|
|
|
# Other containers ignored
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name="addon_local_other",
|
|
state=ContainerState.UNHEALTHY,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0)
|
|
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"],
|
|
)
|
|
async def test_plugin_watchdog_rebuild_on_failure(
|
|
coresys: CoreSys, plugin: PluginBase, error: PluginError
|
|
) -> None:
|
|
"""Test plugin watchdog rebuilds if start fails."""
|
|
with patch.object(type(plugin.instance), "attach"), patch.object(
|
|
type(plugin.instance), "is_running", return_value=mock_is_running(True)
|
|
):
|
|
await plugin.load()
|
|
|
|
with patch("supervisor.plugins.base.WATCHDOG_RETRY_SECONDS", 0), patch.object(
|
|
type(plugin), "rebuild"
|
|
) as rebuild, patch.object(
|
|
type(plugin), "start", side_effect=error
|
|
) as start, patch.object(
|
|
type(plugin.instance),
|
|
"current_state",
|
|
side_effect=[
|
|
mock_current_state(ContainerState.STOPPED),
|
|
mock_current_state(ContainerState.STOPPED),
|
|
],
|
|
):
|
|
coresys.bus.fire_event(
|
|
BusEvent.DOCKER_CONTAINER_STATE_CHANGE,
|
|
DockerContainerStateEvent(
|
|
name=plugin.instance.name,
|
|
state=ContainerState.STOPPED,
|
|
id="abc123",
|
|
time=1,
|
|
),
|
|
)
|
|
await asyncio.sleep(0.1)
|
|
start.assert_called_once()
|
|
rebuild.assert_called_once()
|
|
|
|
|
|
@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=mock_get_latest_version(test_version),
|
|
), patch.object(
|
|
type(plugin.instance), "is_running", return_value=mock_is_running(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=mock_get_latest_version(test_version),
|
|
), patch.object(
|
|
type(plugin.instance), "is_running", return_value=mock_is_running(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=mock_get_latest_version(test_version),
|
|
), patch.object(
|
|
type(plugin.instance), "is_running", return_value=mock_is_running(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()
|