Add update stack button to Portainer (#183231)

This commit is contained in:
Erwin Douna
2026-09-29 18:25:18 +02:00
committed by GitHub
parent 1d662ee231
commit 3b40916335
4 changed files with 247 additions and 2 deletions
+66 -1
View File
@@ -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
)
@@ -79,6 +79,9 @@
"resume_container": {
"name": "Resume container"
},
"update_stack": {
"name": "Update stack"
},
"volumes_prune": {
"name": "Prune unused volumes"
}
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.dashy_update_stack',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Update stack',
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.UPDATE: 'update'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'update',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'dashy Update stack',
}),
'context': <ANY>,
'entity_id': 'button.dashy_update_stack',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'button.webstack_update_stack',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Update stack',
'options': dict({
}),
'original_device_class': <ButtonDeviceClass.UPDATE: 'update'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'update',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'webstack Update stack',
}),
'context': <ANY>,
'entity_id': 'button.webstack_update_stack',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
+76 -1
View File
@@ -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