diff --git a/craft_application/commands/__init__.py b/craft_application/commands/__init__.py index d199335b1..476ae6fd6 100644 --- a/craft_application/commands/__init__.py +++ b/craft_application/commands/__init__.py @@ -20,12 +20,14 @@ from .init import InitCommand from .lifecycle import get_lifecycle_command_group, LifecycleCommand, TestCommand from .other import get_other_command_group +from .prune_instances import PruneInstancesCommand from .remote import RemoteBuild # Not part of the default commands. __all__ = [ "AppCommand", "ExtensibleCommand", "InitCommand", + "PruneInstancesCommand", "RemoteBuild", "lifecycle", "LifecycleCommand", diff --git a/craft_application/commands/other.py b/craft_application/commands/other.py index 38b6de590..4c15a3bee 100644 --- a/craft_application/commands/other.py +++ b/craft_application/commands/other.py @@ -20,6 +20,7 @@ from craft_cli import CommandGroup, emit from . import InitCommand, base +from .prune_instances import PruneInstancesCommand if TYPE_CHECKING: # pragma: no cover import argparse @@ -29,6 +30,7 @@ def get_other_command_group() -> CommandGroup: """Return the lifecycle related command group.""" commands: list[type[base.AppCommand]] = [ InitCommand, + PruneInstancesCommand, VersionCommand, ] diff --git a/craft_application/commands/prune_instances.py b/craft_application/commands/prune_instances.py new file mode 100644 index 000000000..0ae3500d4 --- /dev/null +++ b/craft_application/commands/prune_instances.py @@ -0,0 +1,59 @@ +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . +"""Provider-related commands.""" + +from __future__ import annotations + +import textwrap +from typing import TYPE_CHECKING + +from . import base + +if TYPE_CHECKING: # pragma: no cover + import argparse + + +class PruneInstancesCommand(base.AppCommand): + """Prune instances for the active provider.""" + + name = "prune-instances" + help_msg = "Prune instances for the selected provider or all providers" + overview = textwrap.dedent( + """ + Prune instances using the standard provider-selection logic. + Use --provider to prune instances for a specific provider, or + --all-providers to prune instances from all providers. + """ + ) + + def _fill_parser(self, parser: argparse.ArgumentParser) -> None: + provider_group = parser.add_mutually_exclusive_group() + provider_group.add_argument( + "--all-providers", + action="store_true", + help="Prune instances from all providers", + ) + provider_group.add_argument( + "--provider", + help="Prune instances for a specific provider; omit to use the standard provider-selection logic", + choices=["lxd", "multipass"], + ) + + def run(self, parsed_args: argparse.Namespace) -> None: + """Run the prune-instances command.""" + self._services.provider.prune_instances( + all_providers=parsed_args.all_providers, + provider_name=parsed_args.provider, + prune_templates=parsed_args.prune_templates, + ) diff --git a/craft_application/services/provider.py b/craft_application/services/provider.py index 0bfae3f8d..7551e6455 100644 --- a/craft_application/services/provider.py +++ b/craft_application/services/provider.py @@ -597,3 +597,54 @@ def prepare_instance(instance: craft_providers.Executor) -> None: finally: if active_fetch_service: self._services.get("fetch").teardown_instance() + + def prune_instances( + self, + *, + all_providers: bool = False, + provider_name: str | None = None, + prune_templates: bool = False, + ) -> None: + """Prune instances and optionally templates for the provider(s). + + :param all_providers: Whether to prune instances for all providers or just the + current provider. + :param provider_name: Optional name of the provider to prune (if all_providers + is False). + :param prune_templates: Whether to also prune templates (if supported by the + provider). + """ + providers: list[craft_providers.Provider] = [] + + if provider_name: + providers.append(self._get_provider_by_name(name=provider_name)) + elif all_providers: + self.add_provider_if_available( + providers, "lxd", "LXD provider not available, skipping LXD pruning." + ) + self.add_provider_if_available( + providers, + "multipass", + "Multipass provider not available, skipping multipass pruning.", + ) + else: + providers.append(self.get_provider(name=provider_name)) + + for provider in providers: + emit.progress(f"Pruning instances for provider {provider.name!r}...") + provider.prune(prune_templates=prune_templates) + + def add_provider_if_available( + self, + providers: list[craft_providers.Provider], + provider_key: str, + unavailable_message: str, + ) -> None: + """Add provider to the providers list if it is available, otherwise logs the unavailable_message.""" + try: + provider = self._get_provider_by_name(provider_key) + provider.ensure_provider_is_available() + except (craft_providers.ProviderError, RuntimeError): + emit.debug(unavailable_message) + return + providers.append(provider) diff --git a/tests/spread/testcraft/prune-instances/task.yaml b/tests/spread/testcraft/prune-instances/task.yaml new file mode 100644 index 000000000..16202c8f0 --- /dev/null +++ b/tests/spread/testcraft/prune-instances/task.yaml @@ -0,0 +1,23 @@ +summary: Testcraft should prune its instances + +systems: + - ubuntu-24.04-64 + +execute: | + # Create a LXD instance. + testcraft pull --use-lxd + + # Verify the instance exists. The name should start with 'testcraft-prune-instances-test-'. + lxc list --format csv -c n | grep "^testcraft-prune-instances-test-" + + # Prune instances. + testcraft prune-instances --all-providers + + # Verify the instance is gone. + if lxc list --format csv -c n | grep "^testcraft-prune-instances-test-"; then + echo "Instance was not pruned!" + exit 1 + fi + +restore: | + testcraft prune-instances --all-providers diff --git a/tests/spread/testcraft/prune-instances/testcraft.yaml b/tests/spread/testcraft/prune-instances/testcraft.yaml new file mode 100644 index 000000000..d2a150b77 --- /dev/null +++ b/tests/spread/testcraft/prune-instances/testcraft.yaml @@ -0,0 +1,11 @@ +name: prune-instances-test +summary: test summary +version: "0.1" + +base: ubuntu@24.04 +platforms: + amd64: + +parts: + my-part: + plugin: nil diff --git a/tests/unit/commands/test_other.py b/tests/unit/commands/test_other.py index d8a936ff0..25cd8abc7 100644 --- a/tests/unit/commands/test_other.py +++ b/tests/unit/commands/test_other.py @@ -18,10 +18,10 @@ import argparse import pytest -from craft_application.commands import InitCommand +from craft_application.commands import InitCommand, PruneInstancesCommand from craft_application.commands.other import VersionCommand, get_other_command_group -OTHER_COMMANDS = {InitCommand, VersionCommand} +OTHER_COMMANDS = {InitCommand, VersionCommand, PruneInstancesCommand} @pytest.mark.parametrize("commands", [OTHER_COMMANDS]) diff --git a/tests/unit/commands/test_prune_instances.py b/tests/unit/commands/test_prune_instances.py new file mode 100644 index 000000000..4be1f814e --- /dev/null +++ b/tests/unit/commands/test_prune_instances.py @@ -0,0 +1,36 @@ +# This file is part of craft-application. +# +# Copyright 2026 Canonical Ltd. +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License version 3, as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranties of MERCHANTABILITY, +# SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with this program. If not, see . +"""Tests for prune-instances command.""" + +import argparse + +from craft_application.commands import PruneInstancesCommand + + +def test_prune_instances_run(app_metadata, mock_services): + parsed_args = argparse.Namespace( + all_providers=True, + provider="lxd", + prune_templates=True, + ) + command = PruneInstancesCommand({"app": app_metadata, "services": mock_services}) + command.run(parsed_args) + + mock_services.provider.prune_instances.assert_called_once_with( + all_providers=True, + provider_name="lxd", + prune_templates=True, + ) diff --git a/tests/unit/services/test_provider.py b/tests/unit/services/test_provider.py index a2fb5311b..5d68bff27 100644 --- a/tests/unit/services/test_provider.py +++ b/tests/unit/services/test_provider.py @@ -1174,3 +1174,66 @@ def test_configure_instance_with_pro_skipped(mocker, provider_service): mock_instance.install_pro_client.assert_not_called() mock_instance.attach_pro_subscription.assert_not_called() mock_instance.enable_pro_service.assert_not_called() + + +def test_prune_instances_single_provider(monkeypatch, provider_service): + """Prune for a single provider.""" + mock_lxd = mock.MagicMock() + mock_lxd.name = "lxd" + + monkeypatch.setattr(provider_service, "get_provider", lambda name: mock_lxd) + + provider_service.prune_instances(provider_name="lxd", prune_templates=True) + + mock_lxd.prune.assert_called_once_with(prune_templates=True) + + +def test_prune_instances_none_provider(monkeypatch, provider_service): + """Prune for a single provider.""" + mock_lxd = mock.MagicMock() + mock_lxd.name = "lxd" + + monkeypatch.setattr(provider_service, "get_provider", lambda name: mock_lxd) + + provider_service.prune_instances(provider_name=None, prune_templates=True) + + mock_lxd.prune.assert_called_once_with(prune_templates=True) + + +def test_prune_instances_all_providers(monkeypatch, provider_service): + """Prune for all supported providers.""" + mock_lxd = mock.MagicMock() + mock_lxd.name = "lxd" + mock_multipass = mock.MagicMock() + mock_multipass.name = "multipass" + + def mock_get_by_name(name): + if name == "lxd": + return mock_lxd + if name == "multipass": + return mock_multipass + raise RuntimeError(f"Unknown provider: {name}") + + monkeypatch.setattr(provider_service, "_get_provider_by_name", mock_get_by_name) + + provider_service.prune_instances(all_providers=True, prune_templates=False) + + mock_lxd.prune.assert_called_once_with(prune_templates=False) + mock_multipass.prune.assert_called_once_with(prune_templates=False) + + +def test_prune_instances_all_providers_skips_unsupported(monkeypatch, provider_service): + """Prune for all supported providers, skipping those not supported on the host.""" + mock_lxd = mock.MagicMock() + mock_lxd.name = "lxd" + + def mock_get_by_name(name): + if name == "lxd": + return mock_lxd + raise RuntimeError("Unsupported on this platform") + + monkeypatch.setattr(provider_service, "_get_provider_by_name", mock_get_by_name) + + provider_service.prune_instances(all_providers=True) + + mock_lxd.prune.assert_called_once_with(prune_templates=False)