From 3b409163353937d6af256dee274e88698282a89c Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 29 Sep 2026 18:25:18 +0200 Subject: [PATCH] Add update stack button to Portainer (#183231) --- homeassistant/components/portainer/button.py | 67 +++++++++++- .../components/portainer/strings.json | 3 + .../portainer/snapshots/test_button.ambr | 102 ++++++++++++++++++ tests/components/portainer/test_button.py | 77 ++++++++++++- 4 files changed, 247 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/portainer/button.py b/homeassistant/components/portainer/button.py index d3ab57f56103..cf25ff09189c 100644 --- a/homeassistant/components/portainer/button.py +++ b/homeassistant/components/portainer/button.py @@ -13,6 +13,7 @@ from pyportainer.exceptions import ( PortainerTimeoutError, ) from pyportainer.models.docker import DockerContainer +from pyportainer.models.stacks import Stack, StackType from homeassistant.components.button import ( ButtonDeviceClass, @@ -30,8 +31,13 @@ from .coordinator import ( PortainerContainerData, PortainerCoordinator, PortainerCoordinatorData, + PortainerStackData, +) +from .entity import ( + PortainerContainerEntity, + PortainerEndpointEntity, + PortainerStackEntity, ) -from .entity import PortainerContainerEntity, PortainerEndpointEntity PARALLEL_UPDATES = 1 @@ -57,6 +63,13 @@ class PortainerContainerButtonDescription(ButtonEntityDescription): available_fn: Callable[[PortainerContainerData], bool] +@dataclass(frozen=True, kw_only=True) +class PortainerStackButtonDescription(ButtonEntityDescription): + """Class to describe a Portainer stack button entity.""" + + press_action: Callable[[Portainer, int, int], Coroutine[Any, Any, Stack]] + + ENDPOINT_BUTTONS: tuple[PortainerEndpointButtonDescription, ...] = ( PortainerEndpointButtonDescription( key="images_prune", @@ -153,6 +166,20 @@ CONTAINER_BUTTONS: tuple[PortainerContainerButtonDescription, ...] = ( ), ) +STACK_BUTTONS: tuple[PortainerStackButtonDescription, ...] = ( + PortainerStackButtonDescription( + key="update_stack", + translation_key="update_stack", + device_class=ButtonDeviceClass.UPDATE, + entity_category=EntityCategory.CONFIG, + press_action=( + lambda portainer, endpoint_id, stack_id: portainer.update_stack( + endpoint_id, stack_id, timeout=timedelta(minutes=10) + ) + ), + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -189,8 +216,26 @@ async def async_setup_entry( for entity_description in CONTAINER_BUTTONS ) + def _async_add_new_stacks( + stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]], + ) -> None: + """Add new stack buttons.""" + async_add_entities( + PortainerStackButton( + coordinator, + entity_description, + stack, + endpoint, + ) + for (endpoint, stack) in stacks + # Portainer updates Kubernetes stacks through a different API + if stack.stack.stack_type != StackType.KUBERNETES + for entity_description in STACK_BUTTONS + ) + coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints) coordinator.new_containers_callbacks.append(_async_add_new_containers) + coordinator.new_stacks_callbacks.append(_async_add_new_stacks) _async_add_new_endpoints( [ @@ -206,6 +251,13 @@ async def async_setup_entry( for container in endpoint.containers.values() ] ) + _async_add_new_stacks( + [ + (endpoint, stack) + for endpoint in coordinator.data.values() + for stack in endpoint.stacks.values() + ] + ) class PortainerBaseButton(ButtonEntity): @@ -278,3 +330,16 @@ class PortainerContainerButton(PortainerContainerEntity, PortainerBaseButton): self.endpoint_id, self.container_data.container.id, ) + + +class PortainerStackButton(PortainerStackEntity, PortainerBaseButton): + """Defines a Portainer stack button.""" + + entity_description: PortainerStackButtonDescription + + @override + async def _async_press_call(self) -> None: + """Call the stack button press action.""" + await self.entity_description.press_action( + self.coordinator.portainer, self.endpoint_id, self.stack_id + ) diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index c8e4c2c0e5cc..505378b0dff0 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -79,6 +79,9 @@ "resume_container": { "name": "Resume container" }, + "update_stack": { + "name": "Update stack" + }, "volumes_prune": { "name": "Prune unused volumes" } diff --git a/tests/components/portainer/snapshots/test_button.ambr b/tests/components/portainer/snapshots/test_button.ambr index 6fb9e0e40b23..0702a5d2c315 100644 --- a/tests/components/portainer/snapshots/test_button.ambr +++ b/tests/components/portainer/snapshots/test_button.ambr @@ -250,6 +250,57 @@ 'state': 'unavailable', }) # --- +# name: test_all_button_entities_snapshot[button.dashy_update_stack-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.dashy_update_stack', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Update stack', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update stack', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'update_stack', + 'unique_id': 'portainer_test_entry_123_2_update_stack', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.dashy_update_stack-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'update', + : 'dashy Update stack', + }), + 'context': , + 'entity_id': 'button.dashy_update_stack', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_button_entities_snapshot[button.focused_einstein_kill_container-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -1606,3 +1657,54 @@ 'state': 'unavailable', }) # --- +# name: test_all_button_entities_snapshot[button.webstack_update_stack-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.webstack_update_stack', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Update stack', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Update stack', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'update_stack', + 'unique_id': 'portainer_test_entry_123_1_update_stack', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_button_entities_snapshot[button.webstack_update_stack-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'update', + : 'webstack Update stack', + }), + 'context': , + 'entity_id': 'button.webstack_update_stack', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/portainer/test_button.py b/tests/components/portainer/test_button.py index 7dee2b1d00a5..83db541c97ac 100644 --- a/tests/components/portainer/test_button.py +++ b/tests/components/portainer/test_button.py @@ -1,5 +1,6 @@ """Tests for the Portainer button platform.""" +from datetime import timedelta from unittest.mock import AsyncMock, patch from pyportainer.exceptions import ( @@ -7,10 +8,12 @@ from pyportainer.exceptions import ( PortainerConnectionError, PortainerTimeoutError, ) +from pyportainer.models.stacks import Stack, StackType import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.portainer.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -18,9 +21,14 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + snapshot_platform, +) BUTTON_DOMAIN = "button" +UPDATE_STACK_ENTITY_ID = "button.webstack_update_stack" async def test_all_button_entities_snapshot( @@ -162,3 +170,70 @@ async def test_buttons_endpoints_exceptions( {ATTR_ENTITY_ID: "button.my_environment_prune_unused_images"}, blocking=True, ) + + +async def test_button_update_stack( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test pressing the update stack button redeploys the stack.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: UPDATE_STACK_ENTITY_ID}, + blocking=True, + ) + + mock_portainer_client.update_stack.assert_called_once_with( + 1, 1, timeout=timedelta(minutes=10) + ) + + +@pytest.mark.parametrize( + ("exception", "translation_key"), + [ + (PortainerAuthenticationError("auth"), "invalid_auth"), + (PortainerConnectionError("conn"), "cannot_connect"), + (PortainerTimeoutError("timeout"), "timeout_connect"), + ], +) +async def test_button_update_stack_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + translation_key: str, +) -> None: + """Test the update stack button raises a translated error when the update fails.""" + await setup_integration(hass, mock_config_entry) + mock_portainer_client.update_stack.side_effect = exception + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: UPDATE_STACK_ENTITY_ID}, + blocking=True, + ) + assert exc_info.value.translation_key == translation_key + + +async def test_button_update_stack_not_for_kubernetes( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Kubernetes stacks don't get an update button.""" + stacks = await async_load_json_array_fixture(hass, "stacks.json", DOMAIN) + stacks[0]["Type"] = StackType.KUBERNETES + mock_portainer_client.get_stacks.return_value = [ + Stack.from_dict(stack) for stack in stacks + ] + + await setup_integration(hass, mock_config_entry) + + assert hass.states.get(UPDATE_STACK_ENTITY_ID) is None + assert hass.states.get("button.dashy_update_stack") is not None