mirror of
https://github.com/home-assistant/core.git
synced 2026-10-07 06:50:41 -04:00
Add prune build cache action to Portainer (#183907)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user