Add events to Portainer (#179242)

This commit is contained in:
Erwin Douna
2026-08-24 21:59:13 +02:00
committed by GitHub
parent b0f9769593
commit 834505ff62
10 changed files with 918 additions and 4 deletions
@@ -33,6 +33,7 @@ from .services import async_setup_services
_PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.EVENT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
@@ -122,6 +123,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) ->
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_watcher)
)
@callback
def _start_event_listeners(_hass: HomeAssistant) -> None:
"""Start the Docker event listeners in the event loop."""
coordinator.async_start_event_listeners()
@callback
def _stop_event_listeners(_event: Event) -> None:
"""Stop the Docker event listeners in the event loop."""
coordinator.async_stop_event_listeners()
# Defer the event listener, to avoid a thunderherd of connections during startup
entry.async_on_unload(async_at_started(hass, _start_event_listeners))
entry.async_on_unload(coordinator.async_stop_event_listeners)
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_event_listeners)
)
return True
@@ -4,3 +4,21 @@ DOMAIN = "portainer"
DEFAULT_NAME = "Portainer"
API_MAX_RETRIES = 3
CONTAINER_STATE_ACTIONS = {
"start",
"stop",
"die",
"kill",
"pause",
"unpause",
"restart",
"oom",
"update",
}
HEALTH_STATUS_VALUES = ("healthy", "unhealthy", "starting")
CONTAINER_STATE_EVENT_TYPES: tuple[str, ...] = tuple(
sorted(CONTAINER_STATE_ACTIONS)
) + tuple(f"health_status_{value}" for value in HEALTH_STATUS_VALUES)
@@ -3,8 +3,9 @@
from abc import abstractmethod
import asyncio
from collections.abc import Callable
import dataclasses
from dataclasses import dataclass
from datetime import timedelta
from datetime import datetime, timedelta
import logging
import time
from typing import override
@@ -15,6 +16,8 @@ from pyportainer import (
Portainer,
PortainerAuthenticationError,
PortainerConnectionError,
PortainerEventListener,
PortainerEventListenerResult,
PortainerTimeoutError,
)
from pyportainer.models.docker import (
@@ -34,13 +37,14 @@ from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import DEFAULT_NAME, DOMAIN
from .const import CONTAINER_STATE_ACTIONS, DEFAULT_NAME, DOMAIN
from .util import sanitize_container_name
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
@@ -65,6 +69,14 @@ class PortainerCoordinatorData:
volumes: dict[str, PortainerVolumeData]
@dataclass(slots=True, frozen=True)
class ContainerDockerEvent:
"""A classified Docker event applied to a single container."""
action: str
occurred_at: datetime
@dataclass(slots=True)
class PortainerContainerData:
"""Container data held by the Portainer coordinator."""
@@ -76,6 +88,7 @@ class PortainerContainerData:
stats: DockerContainerStats | None
stats_pre: DockerContainerStats | None
image_status: PortainerImageUpdateStatus | None = None
last_docker_event: ContainerDockerEvent | None = None
@dataclass(slots=True)
@@ -203,6 +216,9 @@ class PortainerCoordinator(
self._image_cache: dict[
tuple[int, str], tuple[float, LocalImageInformation]
] = {}
self._event_listeners: dict[int, PortainerEventListener] = {}
self._event_listeners_enabled = False
self._container_ids_by_endpoint: dict[int, dict[str, str]] = {}
@override
async def update_data(self) -> dict[int, PortainerCoordinatorData]:
@@ -399,6 +415,10 @@ class PortainerCoordinator(
for container_name, stats in container_stats.items():
container_map[container_name].stats = stats
self._container_ids_by_endpoint[endpoint.id] = {
data.container.id: name for name, data in container_map.items()
}
mapped_endpoints[endpoint.id] = PortainerCoordinatorData(
id=endpoint.id,
name=endpoint.name,
@@ -411,6 +431,7 @@ class PortainerCoordinator(
)
self._async_add_remove_endpoints(mapped_endpoints)
self._async_sync_event_listeners(mapped_endpoints)
return mapped_endpoints
@@ -584,6 +605,91 @@ class PortainerCoordinator(
)
return local_image
def _async_sync_event_listeners(
self, mapped_endpoints: dict[int, PortainerCoordinatorData]
) -> None:
"""Start/stop per-endpoint Docker event listeners to match up endpoints."""
current_endpoints = set(mapped_endpoints)
for endpoint_id in self._event_listeners.keys() - current_endpoints:
self._event_listeners.pop(endpoint_id).stop()
self._container_ids_by_endpoint.pop(endpoint_id, None)
for endpoint_id in current_endpoints - self._event_listeners.keys():
listener = PortainerEventListener(
self.portainer,
endpoint_id=endpoint_id,
event_types=["container"],
)
listener.register_callback(self._async_handle_docker_event)
self._event_listeners[endpoint_id] = listener
if self._event_listeners_enabled:
listener.start()
@callback
def async_start_event_listeners(self) -> None:
"""Start all tracked event listeners."""
self._event_listeners_enabled = True
for listener in self._event_listeners.values():
listener.start()
@callback
def async_stop_event_listeners(self) -> None:
"""Stop all tracked event listeners."""
for listener in self._event_listeners.values():
listener.stop()
async def _async_handle_docker_event(
self, result: PortainerEventListenerResult
) -> None:
"""React to a single Docker event pushed by a per-endpoint listener."""
event, endpoint_id = result.event, result.endpoint_id
if event.action is None or (
event.action not in CONTAINER_STATE_ACTIONS
and not event.action.startswith("health_status")
):
return
_LOGGER.debug(
"Received Docker event for endpoint %d: %s",
endpoint_id,
event,
)
actor_id = event.actor.id if event.actor else None
container_name = (
self._container_ids_by_endpoint.get(endpoint_id, {}).get(actor_id)
if actor_id is not None
else None
)
if (
container_name is None
or (endpoint_data := self.data.get(endpoint_id)) is None
or (container_data := endpoint_data.containers.get(container_name)) is None
):
return
updated_containers = dict(endpoint_data.containers)
updated_containers[container_name] = dataclasses.replace(
container_data,
last_docker_event=ContainerDockerEvent(
action=(
f"health_status_{event.action.rsplit(': ', 1)[-1]}"
if event.action.startswith("health_status")
else event.action
),
occurred_at=dt_util.utcnow(),
),
)
merged = dict(self.data)
merged[endpoint_id] = dataclasses.replace(
endpoint_data, containers=updated_containers
)
self.data = merged
self.async_update_listeners()
class PortainerDockerDiskSpaceCoordinator(
PortainerBaseCoordinator[dict[int, DockerSystemDF]]
@@ -0,0 +1,74 @@
"""Event platform for Portainer."""
from datetime import datetime
from typing import override
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .const import CONTAINER_STATE_EVENT_TYPES
from .coordinator import PortainerContainerData, PortainerCoordinator
from .entity import PortainerContainerEntity, PortainerCoordinatorData
PARALLEL_UPDATES = 0
CONTAINER_EVENT_DESCRIPTION = EventEntityDescription(
key="docker_event",
translation_key="docker_event",
entity_category=EntityCategory.DIAGNOSTIC,
event_types=list(CONTAINER_STATE_EVENT_TYPES),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PortainerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Portainer event entities."""
coordinator = entry.runtime_data
def _async_add_new_containers(
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
) -> None:
"""Add new container event entities."""
async_add_entities(
PortainerContainerEventEntity(
coordinator, CONTAINER_EVENT_DESCRIPTION, container, endpoint
)
for (endpoint, container) in containers
)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
_async_add_new_containers(
[
(endpoint, container)
for endpoint in coordinator.data.values()
for container in endpoint.containers.values()
]
)
class PortainerContainerEventEntity(PortainerContainerEntity, EventEntity):
"""Representation of Docker lifecycle events for a Portainer container."""
entity_description: EventEntityDescription
coordinator: PortainerCoordinator
@override
def _handle_coordinator_update(self) -> None:
"""Fire an event if the container reported a new Docker event."""
if (
self.available
and (last_event := self.container_data.last_docker_event) is not None
):
last_triggered = self.state
if (
last_triggered is None
or datetime.fromisoformat(last_triggered) < last_event.occurred_at
):
self._trigger_event(last_event.action)
super()._handle_coordinator_update()
@@ -17,6 +17,11 @@
"default": "mdi:delete-sweep"
}
},
"event": {
"docker_event": {
"default": "mdi:docker"
}
},
"sensor": {
"api_version": {
"default": "mdi:api"
@@ -85,6 +85,29 @@
"name": "Prune unused volumes"
}
},
"event": {
"docker_event": {
"name": "Docker event",
"state_attributes": {
"event_type": {
"state": {
"die": "Died",
"health_status_healthy": "Health check passed",
"health_status_starting": "Health check starting",
"health_status_unhealthy": "Health check failed",
"kill": "Killed",
"oom": "Out of memory",
"pause": "Paused",
"restart": "Restarted",
"start": "Started",
"stop": "Stopped",
"unpause": "Unpaused",
"update": "Updated"
}
}
}
}
},
"sensor": {
"api_version": {
"name": "API version"
+20
View File
@@ -3,6 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from pyportainer import PortainerEventListener
from pyportainer.models.docker import (
DockerContainer,
DockerContainerStats,
@@ -138,6 +139,25 @@ def mock_portainer_client(mock_portainer_watcher: MagicMock) -> Generator[AsyncM
yield client
@pytest.fixture
def mock_portainer_event_listeners() -> Generator[dict[int, MagicMock]]:
"""Mock PortainerEventListener; one MagicMock instance per endpoint_id."""
instances: dict[int, MagicMock] = {}
def _factory(
portainer: MagicMock, endpoint_id: int | None = None, **kwargs
) -> MagicMock:
instance = MagicMock(spec=PortainerEventListener)
instances[endpoint_id] = instance
return instance
with patch(
"homeassistant.components.portainer.coordinator.PortainerEventListener",
side_effect=_factory,
):
yield instances
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
@@ -0,0 +1,481 @@
# serializer version: 1
# name: test_all_entities[event.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_docker_event-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'event.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_docker_event',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Docker event',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Docker event',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'docker_event',
'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_docker_event',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[event.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_docker_event-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Docker event',
}),
'context': <ANY>,
'entity_id': 'event.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_docker_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[event.focused_einstein_docker_event-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'event.focused_einstein_docker_event',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Docker event',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Docker event',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'docker_event',
'unique_id': 'portainer_test_entry_123_focused_einstein_docker_event',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[event.focused_einstein_docker_event-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'focused_einstein Docker event',
}),
'context': <ANY>,
'entity_id': 'event.focused_einstein_docker_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[event.funny_chatelet_docker_event-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'event.funny_chatelet_docker_event',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Docker event',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Docker event',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'docker_event',
'unique_id': 'portainer_test_entry_123_funny_chatelet_docker_event',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[event.funny_chatelet_docker_event-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'funny_chatelet Docker event',
}),
'context': <ANY>,
'entity_id': 'event.funny_chatelet_docker_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[event.practical_morse_docker_event-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'event.practical_morse_docker_event',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Docker event',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Docker event',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'docker_event',
'unique_id': 'portainer_test_entry_123_practical_morse_docker_event',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[event.practical_morse_docker_event-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'practical_morse Docker event',
}),
'context': <ANY>,
'entity_id': 'event.practical_morse_docker_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[event.serene_banach_docker_event-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'event.serene_banach_docker_event',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Docker event',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Docker event',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'docker_event',
'unique_id': 'portainer_test_entry_123_serene_banach_docker_event',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[event.serene_banach_docker_event-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'serene_banach Docker event',
}),
'context': <ANY>,
'entity_id': 'event.serene_banach_docker_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_all_entities[event.stoic_turing_docker_event-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'event.stoic_turing_docker_event',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Docker event',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Docker event',
'platform': 'portainer',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'docker_event',
'unique_id': 'portainer_test_entry_123_stoic_turing_docker_event',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[event.stoic_turing_docker_event-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
'die',
'kill',
'oom',
'pause',
'restart',
'start',
'stop',
'unpause',
'update',
'health_status_healthy',
'health_status_unhealthy',
'health_status_starting',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'stoic_turing Docker event',
}),
'context': <ANY>,
'entity_id': 'event.stoic_turing_docker_event',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+150
View File
@@ -0,0 +1,150 @@
"""Tests for the Portainer event platform."""
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from pyportainer import PortainerEventListenerResult
from pyportainer.models.docker_inspect import DockerInspect
from pyportainer.models.event import DockerEvent, DockerEventActor
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.portainer.const import CONTAINER_STATE_EVENT_TYPES, DOMAIN
from homeassistant.const import STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from . import setup_integration
from .conftest import TEST_CONTAINER_ID, TEST_CONTAINER_NAME
from tests.common import MockConfigEntry, load_json_value_fixture, snapshot_platform
async def _fire_event(
hass: HomeAssistant,
mock_portainer_event_listeners: dict[int, MagicMock],
endpoint_id: int,
action: str,
actor_id: str | None = None,
event_type: str = "container",
) -> None:
"""Invoke the coordinator's registered event callback with a synthetic event."""
event = DockerEvent(
type=event_type,
action=action,
actor=DockerEventActor(id=actor_id) if actor_id else None,
)
listener = mock_portainer_event_listeners[endpoint_id]
callback = listener.register_callback.call_args[0][0]
await callback(PortainerEventListenerResult(endpoint_id=endpoint_id, event=event))
await hass.async_block_till_done()
TEST_EVENT_ENTITY_ID = f"event.{TEST_CONTAINER_NAME}_docker_event"
@pytest.fixture(autouse=True)
def enable_all_entities(entity_registry_enabled_by_default: None) -> None:
"""Make sure all entities are enabled."""
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all event entities."""
with patch(
"homeassistant.components.portainer._PLATFORMS",
[Platform.EVENT],
):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry.entry_id
)
@pytest.mark.freeze_time("2023-10-21")
@pytest.mark.parametrize("expected_event_type", CONTAINER_STATE_EVENT_TYPES)
async def test_state_event_triggers_entity_event(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,
mock_portainer_event_listeners: dict[int, MagicMock],
mock_config_entry: MockConfigEntry,
expected_event_type: str,
) -> None:
"""Test a state Docker event fires the container's event entity."""
await setup_integration(hass, mock_config_entry)
assert (state := hass.states.get(TEST_EVENT_ENTITY_ID))
assert state.state == STATE_UNKNOWN
inspect = cast(
dict[str, Any], load_json_value_fixture("container_inspect.json", DOMAIN)
)
mock_portainer_client.inspect_container.return_value = DockerInspect.from_dict(
inspect
)
action = (
f"health_status: {expected_event_type.removeprefix('health_status_')}"
if expected_event_type.startswith("health_status_")
else expected_event_type
)
await _fire_event(
hass,
mock_portainer_event_listeners,
1,
action,
TEST_CONTAINER_ID,
)
assert (state := hass.states.get(TEST_EVENT_ENTITY_ID))
assert state.attributes["event_type"] == expected_event_type
assert state.state == dt_util.utcnow().isoformat(timespec="milliseconds")
@pytest.mark.usefixtures("mock_portainer_client")
async def test_ignored_action_does_not_change_data(
hass: HomeAssistant,
mock_portainer_event_listeners: dict[int, MagicMock],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a noisy/irrelevant Docker event doesn't change coordinator data."""
await setup_integration(hass, mock_config_entry)
coordinator = mock_config_entry.runtime_data
data_before = coordinator.data
await _fire_event(
hass,
mock_portainer_event_listeners,
1,
"exec_start",
TEST_CONTAINER_ID,
)
assert coordinator.data is data_before
@pytest.mark.usefixtures("mock_portainer_client")
async def test_unknown_container_id_does_not_change_data(
hass: HomeAssistant,
mock_portainer_event_listeners: dict[int, MagicMock],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a state event for an untracked actor id is silently ignored."""
await setup_integration(hass, mock_config_entry)
coordinator = mock_config_entry.runtime_data
data_before = coordinator.data
await _fire_event(
hass,
mock_portainer_event_listeners,
1,
"start",
"unknown-container-id",
)
assert coordinator.data is data_before
+20 -1
View File
@@ -1,7 +1,7 @@
"""Test the Portainer initial specific behavior."""
from typing import Any, cast
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock
from pyportainer.exceptions import (
PortainerAuthenticationError,
@@ -407,6 +407,25 @@ async def test_new_endpoint_callback(
assert stack_device.via_device_id == endpoint_device.id
async def test_removed_endpoint_stops_event_listener(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,
mock_portainer_event_listeners: dict[int, MagicMock],
mock_config_entry: MockConfigEntry,
) -> None:
"""Test a removed endpoint's Docker event listener is stopped and dropped."""
await setup_integration(hass, mock_config_entry)
coordinator = mock_config_entry.runtime_data
assert 1 in coordinator._event_listeners
mock_portainer_client.get_endpoints.return_value = []
await coordinator.async_refresh()
await hass.async_block_till_done()
mock_portainer_event_listeners[1].stop.assert_called_once()
assert 1 not in coordinator._event_listeners
async def test_new_container_callback(
hass: HomeAssistant,
mock_portainer_client: AsyncMock,