diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index 1b84409dbde..a9f6a23a822 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -276,10 +276,16 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD ) -> None: """Add new endpoints, remove non-existing endpoints.""" current_endpoints = {endpoint.id for endpoint in mapped_endpoints.values()} + self.known_endpoints &= current_endpoints new_endpoints = current_endpoints - self.known_endpoints if new_endpoints: _LOGGER.debug("New endpoints found: %s", new_endpoints) self.known_endpoints.update(new_endpoints) + new_endpoint_data = [ + mapped_endpoints[endpoint_id] for endpoint_id in new_endpoints + ] + for endpoint_callback in self.new_endpoints_callbacks: + endpoint_callback(new_endpoint_data) # Surprise, we also handle containers here :) current_containers = { @@ -287,10 +293,22 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD for endpoint in mapped_endpoints.values() for container_name in endpoint.containers } + # Prune departed containers so a recreated container is detected as new + # and its entity is rebuilt with the fresh (ephemeral) Docker container ID. + self.known_containers &= current_containers new_containers = current_containers - self.known_containers if new_containers: _LOGGER.debug("New containers found: %s", new_containers) self.known_containers.update(new_containers) + new_container_data = [ + ( + mapped_endpoints[endpoint_id], + mapped_endpoints[endpoint_id].containers[name], + ) + for endpoint_id, name in new_containers + ] + for container_callback in self.new_containers_callbacks: + container_callback(new_container_data) # Stack management current_stacks = { @@ -298,10 +316,21 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD for endpoint in mapped_endpoints.values() for stack_name in endpoint.stacks } + + self.known_stacks &= current_stacks new_stacks = current_stacks - self.known_stacks if new_stacks: _LOGGER.debug("New stacks found: %s", new_stacks) self.known_stacks.update(new_stacks) + new_stack_data = [ + ( + mapped_endpoints[endpoint_id], + mapped_endpoints[endpoint_id].stacks[name], + ) + for endpoint_id, name in new_stacks + ] + for stack_callback in self.new_stacks_callbacks: + stack_callback(new_stack_data) def _get_container_name(self, container_name: str) -> str: """Sanitize to get a proper container name.""" diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 8cb7c0ced37..174994620ad 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -7,6 +7,9 @@ from pyportainer.exceptions import ( PortainerConnectionError, PortainerTimeoutError, ) +from pyportainer.models.docker import DockerContainer +from pyportainer.models.portainer import Endpoint +from pyportainer.models.stacks import Stack import pytest from syrupy.assertion import SnapshotAssertion @@ -26,7 +29,7 @@ from homeassistant.setup import async_setup_component from . import setup_integration from .conftest import MOCK_TEST_CONFIG, TEST_INSTANCE_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_array_fixture from tests.typing import WebSocketGenerator @@ -296,3 +299,93 @@ async def test_container_stack_device_links( assert standalone_container_device is not None assert standalone_container_device.via_device_id == endpoint_device.id + + +async def test_new_endpoint_callback( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a new endpoint appearing in a subsequent refresh fires the callback and creates entities.""" + mock_portainer_client.get_endpoints.return_value = [] + await setup_integration(hass, mock_config_entry) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 0 + + mock_portainer_client.get_endpoints.return_value = [ + Endpoint.from_dict(endpoint) + for endpoint in await async_load_json_array_fixture( + hass, "endpoints.json", DOMAIN + ) + if endpoint["Status"] == 1 + ] + + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) > 0 + + +async def test_new_container_callback( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a new container appearing in a subsequent refresh fires the callback and creates entities.""" + mock_portainer_client.get_containers.return_value = [] + await setup_integration(hass, mock_config_entry) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + mock_portainer_client.get_containers.return_value = [ + DockerContainer.from_dict(container) + for container in await async_load_json_array_fixture( + hass, "containers.json", DOMAIN + ) + if "/focused_einstein" in container["Names"] + ] + + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) > len(entities) + + +async def test_new_stack_callback( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a new stack appearing in a subsequent refresh fires the callback and creates entities.""" + mock_portainer_client.get_stacks.return_value = [] + await setup_integration(hass, mock_config_entry) + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + mock_portainer_client.get_stacks.return_value = [ + Stack.from_dict(stack) + for stack in await async_load_json_array_fixture(hass, "stacks.json", DOMAIN) + if stack["Name"] == "webstack" + ] + + coordinator = mock_config_entry.runtime_data + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) > len(entities)