diff --git a/homeassistant/components/portainer/icons.json b/homeassistant/components/portainer/icons.json index 16d2221b3c3e..b457c09e0dfb 100644 --- a/homeassistant/components/portainer/icons.json +++ b/homeassistant/components/portainer/icons.json @@ -136,6 +136,9 @@ } }, "services": { + "prune_build_cache": { + "service": "mdi:delete-sweep" + }, "prune_images": { "service": "mdi:delete-sweep" }, diff --git a/homeassistant/components/portainer/services.py b/homeassistant/components/portainer/services.py index 527cbf973c70..1d480ad3459c 100644 --- a/homeassistant/components/portainer/services.py +++ b/homeassistant/components/portainer/services.py @@ -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, diff --git a/homeassistant/components/portainer/services.yaml b/homeassistant/components/portainer/services.yaml index 383c02f94fd3..31f3e042863d 100644 --- a/homeassistant/components/portainer/services.yaml +++ b/homeassistant/components/portainer/services.yaml @@ -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: diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index 8197aab904c4..e5c2c6dbcbaa 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -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": { diff --git a/tests/components/portainer/test_services.py b/tests/components/portainer/test_services.py index 526553bb81f7..5edca2373e84 100644 --- a/tests/components/portainer/test_services.py +++ b/tests/components/portainer/test_services.py @@ -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,