diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py index d7f84b967d99..17d839bc3441 100644 --- a/homeassistant/components/portainer/coordinator.py +++ b/homeassistant/components/portainer/coordinator.py @@ -16,6 +16,7 @@ from pyportainer import ( Portainer, PortainerAuthenticationError, PortainerConnectionError, + PortainerError, PortainerEventListener, PortainerEventListenerResult, PortainerTimeoutError, @@ -217,6 +218,9 @@ class PortainerCoordinator( self._image_cache: dict[ tuple[int, str], tuple[float, LocalImageInformation] ] = {} + self._image_status_cache: dict[ + tuple[int, str], tuple[float, PortainerImageUpdateStatus] + ] = {} self._event_listeners: dict[int, PortainerEventListener] = {} self._event_listeners_enabled = False self._container_ids_by_endpoint: dict[int, dict[str, str]] = {} @@ -341,19 +345,7 @@ class PortainerCoordinator( container_inspect = container_inspects[container_name] local_image = local_images[container_name] - image_status = ( - ( - result.status - if ( - result := self.watcher.results.get( - (endpoint.id, container.id) - ) - ) - else None - ) - if self.watcher - else None - ) + image_status = await self._get_image_status(endpoint.id, container) # Check if container belongs to a stack via docker compose label stack_name: str | None = ( @@ -618,6 +610,47 @@ class PortainerCoordinator( ) return local_image + async def _get_image_status( + self, endpoint_id: int, container: DockerContainer + ) -> PortainerImageUpdateStatus | None: + """Return the image update status, checking containers the watcher has not seen.""" + if self.watcher is None: + return None + + if result := self.watcher.results.get((endpoint_id, container.id)): + return result.status + + # A recreated container gets a new ID, which the watcher only picks up on + # its next run. Check its image now instead of reporting unknown until then. + if ( + self.watcher.last_check is None + or container.state != DockerContainerState.RUNNING + or not container.image + ): + return None + + cache_key = (endpoint_id, container.image) + if cached := self._image_status_cache.get(cache_key): + cached_at, image_status = cached + if cached_at >= self.watcher.last_check: + return image_status + + try: + image_status = await self.portainer.container_image_status( + endpoint_id, container.image + ) + except PortainerError as err: + _LOGGER.debug( + "Failed to check image %s on endpoint %d: %s", + container.image, + endpoint_id, + err, + ) + return None + + self._image_status_cache[cache_key] = (time.time(), image_status) + return image_status + def _async_sync_event_listeners( self, mapped_endpoints: dict[int, PortainerCoordinatorData] ) -> None: diff --git a/tests/components/portainer/test_update.py b/tests/components/portainer/test_update.py index 6bcf621f7672..832a7be5e0a9 100644 --- a/tests/components/portainer/test_update.py +++ b/tests/components/portainer/test_update.py @@ -1,25 +1,44 @@ """Tests for the Portainer update platform.""" +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch +from freezegun.api import FrozenDateTimeFactory from pyportainer.exceptions import ( PortainerAuthenticationError, PortainerConnectionError, ) +from pyportainer.models.docker import DockerContainer, PortainerImageUpdateStatus +from pyportainer.watcher import PortainerImageWatcherResult import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.update import ATTR_INSTALLED_VERSION -from homeassistant.const import Platform +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.components.portainer.coordinator import DEFAULT_SCAN_INTERVAL +from homeassistant.components.update import ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_array_fixture, + snapshot_platform, +) ENTITY_ID = "update.funny_chatelet_image_update_available" +CONTAINER_IMAGE = "docker.io/library/ubuntu:latest" +INSTALLED_DIGEST = ( + "sha256:afcc7f1ac1b49db317a7196c902e61c6c3c4607d63599ee1a82d702d249a0ccb" +) +RECREATED_CONTAINER_ID = ( + "0011facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf" +) @pytest.fixture(autouse=True) @@ -158,3 +177,152 @@ async def test_update_using_cache( ) mock_portainer_client.get_image.assert_not_called() + + +async def _watch_all_containers(hass: HomeAssistant, watcher: MagicMock) -> None: + """Give the watcher a result for every container, as after its first run.""" + containers = cast( + list[dict[str, Any]], + await async_load_json_array_fixture(hass, "containers.json", DOMAIN), + ) + watcher.results = { + (1, container["Id"]): PortainerImageWatcherResult( + endpoint_id=1, + container_id=container["Id"], + status=PortainerImageUpdateStatus( + update_available=True, + local_digest=INSTALLED_DIGEST, + registry_digest="sha256:newdigest123456789", + ), + ) + for container in containers + } + watcher.last_check = 1234 + + +async def _recreate_container(hass: HomeAssistant, client: AsyncMock) -> None: + """Give the funny_chatelet container a new ID, as a recreate does.""" + containers = cast( + list[dict[str, Any]], + await async_load_json_array_fixture(hass, "containers.json", DOMAIN), + ) + recreated = next( + container for container in containers if "/funny_chatelet" in container["Names"] + ) + recreated["Id"] = RECREATED_CONTAINER_ID + client.get_containers.return_value = [ + DockerContainer.from_dict(container) for container in containers + ] + + +async def test_update_recreated_container( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a recreated container gets its image checked instead of staying unknown.""" + await _watch_all_containers(hass, mock_portainer_watcher) + + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + # The watcher only has a result for the old container ID + await _recreate_container(hass, mock_portainer_client) + mock_portainer_client.container_image_status.return_value = ( + PortainerImageUpdateStatus( + update_available=False, + local_digest=INSTALLED_DIGEST, + registry_digest=INSTALLED_DIGEST, + ) + ) + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_portainer_client.container_image_status.assert_called_once_with( + 1, CONTAINER_IMAGE + ) + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_OFF + assert state.attributes[ATTR_LATEST_VERSION] == "sha256:afcc7f1ac1b4" + + # The result is kept until the watcher runs again, not fetched every poll + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_portainer_client.container_image_status.assert_called_once() + + +async def test_update_recreated_container_before_watcher_ran( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a container is left to the watcher's first run.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + await _recreate_container(hass, mock_portainer_client) + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + mock_portainer_client.container_image_status.assert_not_called() + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + "exception", + [ + pytest.param(PortainerConnectionError("conn"), id="connection"), + pytest.param(PortainerAuthenticationError("auth"), id="authentication"), + ], +) +async def test_update_recreated_container_check_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_portainer_client: AsyncMock, + mock_portainer_watcher: MagicMock, + mock_config_entry: MockConfigEntry, + exception: Exception, +) -> None: + """Test a failed image check leaves the update unknown without failing the refresh.""" + await _watch_all_containers(hass, mock_portainer_watcher) + + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.UPDATE], + ): + await setup_integration(hass, mock_config_entry) + + await _recreate_container(hass, mock_portainer_client) + mock_portainer_client.container_image_status.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done(wait_background_tasks=True) + + assert mock_config_entry.runtime_data.last_update_success + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN