mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 22:38:02 -04:00
Add update stack button to Portainer (#183231)
This commit is contained in:
@@ -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',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user