Add prune build cache action to Portainer (#183907)

This commit is contained in:
Erwin Douna
2026-10-02 09:59:43 +02:00
committed by GitHub
parent 4357b39a77
commit fdd9c42aed
5 changed files with 220 additions and 3 deletions
@@ -136,6 +136,9 @@
}
},
"services": {
"prune_build_cache": {
"service": "mdi:delete-sweep"
},
"prune_images": {
"service": "mdi:delete-sweep"
},
@@ -21,6 +21,7 @@ from homeassistant.helpers import (
from .const import DOMAIN
from .coordinator import PortainerConfigEntry
ATTR_ALL = "all"
ATTR_DATE_UNTIL = "until"
ATTR_DANGLING = "dangling"
ATTR_TIMEOUT = "timeout"
@@ -38,6 +39,17 @@ SERVICE_PRUNE_IMAGES_SCHEMA = probatio.Schema(
},
)
SERVICE_PRUNE_BUILD_CACHE = "prune_build_cache"
SERVICE_PRUNE_BUILD_CACHE_SCHEMA = probatio.Schema(
{
probatio.Required(ATTR_DEVICE_ID): cv.string,
probatio.Optional(ATTR_ALL, default=True): cv.boolean,
probatio.Optional(ATTR_DATE_UNTIL): probatio.All(
cv.time_period, probatio.Range(min=timedelta(minutes=1))
),
},
)
SERVICE_RECREATE_CONTAINER = "recreate_container"
SERVICE_RECREATE_CONTAINER_SCHEMA = probatio.Schema(
{
@@ -134,6 +146,35 @@ async def prune_images(call: ServiceCall) -> None:
) from err
async def prune_build_cache(call: ServiceCall) -> None:
"""Prune the build cache in Portainer, with more controls."""
device, config_entry = _async_get_device_and_entry(call, call.data[ATTR_DEVICE_ID])
coordinator = config_entry.runtime_data
endpoint_id = _async_get_endpoint_id(device, config_entry)
try:
await coordinator.portainer.prune_build_cache(
endpoint_id,
all_cache=call.data[ATTR_ALL],
until=call.data.get(ATTR_DATE_UNTIL),
)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect",
) from err
async def recreate_container(call: ServiceCall) -> None:
"""Recreate a container in Portainer, with more controls."""
device, config_entry = _async_get_device_and_entry(
@@ -182,6 +223,14 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_PRUNE_IMAGES_SCHEMA,
)
service.async_register_admin_service(
hass,
DOMAIN,
SERVICE_PRUNE_BUILD_CACHE,
prune_build_cache,
SERVICE_PRUNE_BUILD_CACHE_SCHEMA,
)
service.async_register_admin_service(
hass,
DOMAIN,
@@ -17,6 +17,24 @@ prune_images:
selector:
boolean: {}
prune_build_cache:
fields:
device_id:
required: true
selector:
device:
integration: portainer
model: Endpoint
all:
required: false
default: true
selector:
boolean:
until:
required: false
selector:
duration:
recreate_container:
fields:
container_device_id:
@@ -262,6 +262,24 @@
}
},
"services": {
"prune_build_cache": {
"description": "Prunes the build cache on a Portainer endpoint.",
"fields": {
"all": {
"description": "If true (default), prune all unused build cache. If false, only prune dangling build cache.",
"name": "All"
},
"device_id": {
"description": "The endpoint to prune the build cache on.",
"name": "[%key:component::portainer::services::prune_images::fields::device_id::name%]"
},
"until": {
"description": "Only prune build cache that was last used longer ago than this duration. If not provided, build cache of any age is pruned.",
"name": "[%key:component::portainer::services::prune_images::fields::until::name%]"
}
},
"name": "Prune build cache"
},
"prune_images": {
"description": "Prunes unused images on a Portainer endpoint.",
"fields": {
+132 -3
View File
@@ -13,24 +13,30 @@ import pytest
from homeassistant.components.portainer.const import DOMAIN
from homeassistant.components.portainer.services import (
ATTR_ALL,
ATTR_CONTAINER_DEVICE_ID,
ATTR_DANGLING,
ATTR_DATE_UNTIL,
ATTR_PULL_IMAGE,
ATTR_TIMEOUT,
SERVICE_PRUNE_BUILD_CACHE,
SERVICE_PRUNE_IMAGES,
SERVICE_RECREATE_CONTAINER,
_async_get_device_and_entry,
)
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import (
HomeAssistantError,
ServiceValidationError,
Unauthorized,
)
from homeassistant.helpers.device_registry import DeviceRegistry
from . import setup_integration
from .conftest import TEST_CONTAINER_ID, TEST_CONTAINER_NAME, TEST_ENTRY
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, MockUser
TEST_ENDPOINT_ID = 1
TEST_DEVICE_IDENTIFIER = f"{TEST_ENTRY}_{TEST_ENDPOINT_ID}"
@@ -111,6 +117,111 @@ async def test_service_prune_images(
)
@pytest.mark.parametrize(
("call_arguments", "expected_all", "expected_until"),
[
({}, True, None),
({ATTR_ALL: False}, False, None),
({ATTR_DATE_UNTIL: timedelta(hours=12)}, True, timedelta(hours=12)),
(
{ATTR_ALL: False, ATTR_DATE_UNTIL: timedelta(hours=12)},
False,
timedelta(hours=12),
),
],
ids=["no optional", "dangling only", "with duration", "dangling with duration"],
)
async def test_service_prune_build_cache(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
call_arguments: dict,
expected_all: bool,
expected_until: timedelta | None,
) -> None:
"""Test prune build cache service with the variants."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, TEST_DEVICE_IDENTIFIER), mock_config_entry.entry_id
)
assert device is not None
await hass.services.async_call(
DOMAIN,
SERVICE_PRUNE_BUILD_CACHE,
{ATTR_DEVICE_ID: device.id, **call_arguments},
blocking=True,
)
mock_portainer_client.prune_build_cache.assert_called_once_with(
TEST_ENDPOINT_ID,
all_cache=expected_all,
until=expected_until,
)
@pytest.mark.parametrize(
("exception", "translation_key"),
[
(PortainerAuthenticationError("auth"), "invalid_auth"),
(PortainerConnectionError("conn"), "cannot_connect"),
(PortainerTimeoutError("timeout"), "timeout_connect"),
],
)
async def test_service_prune_build_cache_portainer_exceptions(
hass: HomeAssistant,
device_registry: DeviceRegistry,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: PortainerAuthenticationError
| PortainerConnectionError
| PortainerTimeoutError,
translation_key: str,
) -> None:
"""Test prune build cache service handles Portainer exceptions."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, TEST_DEVICE_IDENTIFIER), mock_config_entry.entry_id
)
assert device is not None
mock_portainer_client.prune_build_cache.side_effect = exception
with pytest.raises(HomeAssistantError) as err:
await hass.services.async_call(
DOMAIN,
SERVICE_PRUNE_BUILD_CACHE,
{ATTR_DEVICE_ID: device.id},
blocking=True,
)
assert err.value.translation_key == translation_key
mock_portainer_client.prune_build_cache.assert_called_once()
async def test_service_prune_build_cache_requires_admin(
hass: HomeAssistant,
hass_read_only_user: MockUser,
device_registry: DeviceRegistry,
mock_portainer_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test prune build cache service is only available to admins."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device_by_identifier(
(DOMAIN, TEST_DEVICE_IDENTIFIER), mock_config_entry.entry_id
)
assert device is not None
with pytest.raises(Unauthorized):
await hass.services.async_call(
DOMAIN,
SERVICE_PRUNE_BUILD_CACHE,
{ATTR_DEVICE_ID: device.id},
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
mock_portainer_client.prune_build_cache.assert_not_called()
@pytest.mark.parametrize(
("call_arguments", "extra_expected_kwargs"),
[
@@ -240,6 +351,24 @@ async def test_service_validation_errors(
)
mock_portainer_client.images_prune.assert_not_called()
with pytest.raises(MultipleInvalid, match="value must be at least"):
await hass.services.async_call(
DOMAIN,
SERVICE_PRUNE_BUILD_CACHE,
{ATTR_DEVICE_ID: device.id, ATTR_DATE_UNTIL: timedelta(seconds=30)},
blocking=True,
)
mock_portainer_client.prune_build_cache.assert_not_called()
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
await hass.services.async_call(
DOMAIN,
SERVICE_PRUNE_BUILD_CACHE,
{ATTR_DEVICE_ID: container.id},
blocking=True,
)
mock_portainer_client.prune_build_cache.assert_not_called()
with pytest.raises(ServiceValidationError, match="was not found"):
await hass.services.async_call(
DOMAIN,