mirror of
https://github.com/home-assistant/core.git
synced 2026-08-27 02:24:53 -05:00
Add events to Portainer (#179242)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user