From 834505ff625e8965674313eb4c3fa7919dc72a9d Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 24 Aug 2026 21:59:13 +0200 Subject: [PATCH] Add events to Portainer (#179242) --- .../components/portainer/__init__.py | 18 + homeassistant/components/portainer/const.py | 18 + .../components/portainer/coordinator.py | 112 +++- homeassistant/components/portainer/event.py | 74 +++ homeassistant/components/portainer/icons.json | 5 + .../components/portainer/strings.json | 23 + tests/components/portainer/conftest.py | 20 + .../portainer/snapshots/test_event.ambr | 481 ++++++++++++++++++ tests/components/portainer/test_event.py | 150 ++++++ tests/components/portainer/test_init.py | 21 +- 10 files changed, 918 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/portainer/event.py create mode 100644 tests/components/portainer/snapshots/test_event.ambr create mode 100644 tests/components/portainer/test_event.py diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index 10fca41d7654..1322134c9fc1 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -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 diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py index ae8e8ee98dff..79e94d81be66 100644 --- a/homeassistant/components/portainer/const.py +++ b/homeassistant/components/portainer/const.py @@ -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) diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 66e03cbc4679..94e9f9a1a1f7 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -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]] diff --git a/homeassistant/components/portainer/event.py b/homeassistant/components/portainer/event.py new file mode 100644 index 000000000000..ca11e73c2588 --- /dev/null +++ b/homeassistant/components/portainer/event.py @@ -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() diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index e78c26c996c6..1a655891b570 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -17,6 +17,11 @@ "default": "mdi:delete-sweep" } }, + "event": { + "docker_event": { + "default": "mdi:docker" + } + }, "sensor": { "api_version": { "default": "mdi:api" diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 6f748880a8ed..671e94315650 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -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" diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py index 1bd89ba728e0..83840884f647 100644 --- a/tests/components/portainer/conftest.py +++ b/tests/components/portainer/conftest.py @@ -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.""" diff --git a/tests/components/portainer/snapshots/test_event.ambr b/tests/components/portainer/snapshots/test_event.ambr new file mode 100644 index 000000000000..377c23113596 --- /dev/null +++ b/tests/components/portainer/snapshots/test_event.ambr @@ -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({ + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': , + 'entity_id': 'event.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_docker_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : None, + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 Docker event', + }), + 'context': , + 'entity_id': 'event.dashy_dashy_1_qgza68hnz4n1qvyz3iohynx05_docker_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[event.focused_einstein_docker_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': , + 'entity_id': 'event.focused_einstein_docker_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : None, + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + : 'focused_einstein Docker event', + }), + 'context': , + 'entity_id': 'event.focused_einstein_docker_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[event.funny_chatelet_docker_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': , + 'entity_id': 'event.funny_chatelet_docker_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : None, + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + : 'funny_chatelet Docker event', + }), + 'context': , + 'entity_id': 'event.funny_chatelet_docker_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[event.practical_morse_docker_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': , + 'entity_id': 'event.practical_morse_docker_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : None, + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + : 'practical_morse Docker event', + }), + 'context': , + 'entity_id': 'event.practical_morse_docker_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[event.serene_banach_docker_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': , + 'entity_id': 'event.serene_banach_docker_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : None, + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + : 'serene_banach Docker event', + }), + 'context': , + 'entity_id': 'event.serene_banach_docker_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[event.stoic_turing_docker_event-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': , + 'entity_id': 'event.stoic_turing_docker_event', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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({ + : None, + : list([ + 'die', + 'kill', + 'oom', + 'pause', + 'restart', + 'start', + 'stop', + 'unpause', + 'update', + 'health_status_healthy', + 'health_status_unhealthy', + 'health_status_starting', + ]), + : 'stoic_turing Docker event', + }), + 'context': , + 'entity_id': 'event.stoic_turing_docker_event', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/portainer/test_event.py b/tests/components/portainer/test_event.py new file mode 100644 index 000000000000..d1580a633f95 --- /dev/null +++ b/tests/components/portainer/test_event.py @@ -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 diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index ecbd08fc0c79..b67e3647433f 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -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,