Skip to content

feat: add prune instances#1058

Open
Aeonoi wants to merge 13 commits into
canonical:mainfrom
Aeonoi:add-prune-instances
Open

feat: add prune instances#1058
Aeonoi wants to merge 13 commits into
canonical:mainfrom
Aeonoi:add-prune-instances

Conversation

@Aeonoi
Copy link
Copy Markdown

@Aeonoi Aeonoi commented Apr 10, 2026

  • Have you followed the guidelines for contributing?
  • Have you signed the CLA?
  • Have you successfully run make lint && make test?
  • Have you added an entry to the changelog (docs/reference/changelog.rst)?

closes #991


@Aeonoi Aeonoi marked this pull request as ready for review April 14, 2026 16:35
@Aeonoi Aeonoi requested a review from a team as a code owner April 14, 2026 16:35
Copilot AI review requested due to automatic review settings April 14, 2026 16:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements the new prune-instances CLI command requested in #991, wiring it through ProviderService so users can prune managed provider instances (optionally including base/template instances).

Changes:

  • Add PruneInstancesCommand and register it in the “Other” command group and command exports.
  • Add ProviderService.prune_instances() to prune instances for a selected provider or across providers.
  • Add unit tests for the new command and service behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
craft_application/services/provider.py Adds prune_instances() implementation to call into craft-providers’ prune functionality.
craft_application/commands/prune_instances.py Introduces the new prune-instances command and CLI flags.
craft_application/commands/other.py Registers the new command in the “Other” command group.
craft_application/commands/__init__.py Exports PruneInstancesCommand.
tests/unit/services/test_provider.py Adds unit tests for provider-service pruning paths.
tests/unit/commands/test_prune_instances.py Adds unit test for command-to-service wiring.
tests/unit/commands/test_other.py Updates “Other commands” group test to include the new command.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

parsed_args = argparse.Namespace(
all_providers=True,
provider="lxd",
prune_templates=True,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

Comment on lines +1191 to +1210
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to care about this - we're able to assume commands.prune_instances is only providing lowercase provider names to this function.

Comment thread craft_application/services/provider.py Outdated
Comment on lines +621 to +627
providers.append(self._get_provider_by_name("LXD"))
except RuntimeError:
emit.debug("LXD provider not available, skipping.")

try:
providers.append(self._get_provider_by_name("multipass"))
providers.append(self._get_provider_by_name("Multipass"))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, _get_provider_by_name runs lower().strip(), so you can just call it once.

Comment thread craft_application/services/provider.py Outdated
Comment on lines +619 to +630
try:
providers.append(self._get_provider_by_name("lxd"))
providers.append(self._get_provider_by_name("LXD"))
except RuntimeError:
emit.debug("LXD provider not available, skipping.")

try:
providers.append(self._get_provider_by_name("multipass"))
providers.append(self._get_provider_by_name("Multipass"))
except RuntimeError:
emit.debug("Multipass provider not available, skipping.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reasoning here is correct, if you decide to do _add_provider_if_available, I make it a class level function rather than a function within a function.

Comment thread craft_application/services/provider.py
Comment on lines +28 to +46
"""Prune instances for the active provider."""

name = "prune-instances"
help_msg = "Prune instances for the active provider"
overview = textwrap.dedent(
"""
Prune instances for the active provider.
"""
)

def _fill_parser(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--all-providers",
action="store_true",
help="Prune instances from all providers",
)
parser.add_argument(
"--provider",
help="Prune for a specific provider",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 on the mutual exclusion

Comment thread craft_application/commands/prune_instances.py
Copy link
Copy Markdown
Contributor

@mr-cal mr-cal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice start, I left most of my feedback on top of the copilot review.

action="store_true",
help="Prune instances from all providers",
)
parser.add_argument(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add choices? That will improve the UX by giving quick errors up front if a user provides something besides lxd or multipass.

It will also make your life easier in the prune_instances command, because you'll know the user declared a valid provider name.

@lengau lengau requested a review from Copilot April 21, 2026 21:18
@lengau
Copy link
Copy Markdown
Collaborator

lengau commented Apr 21, 2026

@copilot create a child PR that fixes the linting issues

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new prune-instances CLI command and supporting service logic to remove managed provider instances (optionally across providers), plus accompanying unit and spread coverage to exercise the behavior end-to-end (per #991).

Changes:

  • Introduce PruneInstancesCommand and register it under the “Other” command group.
  • Add ProviderService.prune_instances() (with helper to skip unavailable providers) and unit tests for provider pruning behavior.
  • Add a spread test scenario for testcraft prune-instances.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
craft_application/services/provider.py Adds prune_instances() service API and availability-aware provider selection helper.
craft_application/commands/prune_instances.py Implements the new prune-instances CLI command and argument parsing.
craft_application/commands/other.py Registers PruneInstancesCommand in the “Other” command group.
craft_application/commands/__init__.py Exports PruneInstancesCommand from the commands package.
tests/unit/services/test_provider.py Adds unit tests covering provider-service pruning scenarios.
tests/unit/commands/test_prune_instances.py Adds a unit test ensuring the command calls the provider service.
tests/unit/commands/test_other.py Updates command-group expectations to include the new command.
tests/spread/testcraft/prune-instances/testcraft.yaml Adds a minimal testcraft project used by the spread scenario.
tests/spread/testcraft/prune-instances/task.yaml Adds a spread task that creates an LXD instance then prunes it.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/spread/testcraft/prune-instances/task.yaml
Comment thread craft_application/services/provider.py Outdated
providers: list[craft_providers.Provider] = []

if provider_name:
providers.append(self.get_provider(name=provider_name))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Nice to see copilot is making the same suggestions I was going to make.

Comment on lines +53 to +59
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,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I think include_templates accidentally got removed when making the other args mutually exclusive.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, would the _fill_parser() method add the include_templates or prune_templates flag?

Comment on lines +24 to +28
parsed_args = argparse.Namespace(
all_providers=True,
provider="lxd",
prune_templates=True,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - these argparse values don't reflect what a user would be able to provide at the command line.

IIRC, manually defining parsed_args = argparse.Namespace(...) in the test bypasses code like the mutually exclusive args. To test that code, you have to mock argv and assert the error with capsys (example).

I think you can do this in 2 tests. One capsys-based test that ensures the args are mutually exclusive and another test that verifies the namespace args are successfully passed to provider.prune_instances(...).

You already have the second test written, but I recommend adding parametrizing it with @pytest.mark.parameterize() such that the test ensures what you set in parsed_args = argparse.Namespace(...) actually get passed to provider.prune_instances(...).


mock_lxd.prune.assert_called_once_with(prune_templates=True)


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can ignore this, as this test needs to be updated once https://github.com/canonical/craft-application/pull/1058/changes#r3120374251 is accomdated.

Comment thread tests/spread/testcraft/prune-instances/task.yaml
fi

restore: |
testcraft prune-instances --all
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here too:

Suggested change
testcraft prune-instances --all
testcraft prune-instances --all-providers

Comment on lines +53 to +59
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,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, I think include_templates accidentally got removed when making the other args mutually exclusive.

Comment thread craft_application/services/provider.py Outdated
providers: list[craft_providers.Provider] = []

if provider_name:
providers.append(self.get_provider(name=provider_name))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Nice to see copilot is making the same suggestions I was going to make.

parsed_args = argparse.Namespace(
all_providers=True,
provider="lxd",
prune_templates=True,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed.

Comment on lines +24 to +28
parsed_args = argparse.Namespace(
all_providers=True,
provider="lxd",
prune_templates=True,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - these argparse values don't reflect what a user would be able to provide at the command line.

IIRC, manually defining parsed_args = argparse.Namespace(...) in the test bypasses code like the mutually exclusive args. To test that code, you have to mock argv and assert the error with capsys (example).

I think you can do this in 2 tests. One capsys-based test that ensures the args are mutually exclusive and another test that verifies the namespace args are successfully passed to provider.prune_instances(...).

You already have the second test written, but I recommend adding parametrizing it with @pytest.mark.parameterize() such that the test ensures what you set in parsed_args = argparse.Namespace(...) actually get passed to provider.prune_instances(...).

Comment on lines +1191 to +1210
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to care about this - we're able to assume commands.prune_instances is only providing lowercase provider names to this function.


mock_lxd.prune.assert_called_once_with(prune_templates=True)


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you can ignore this, as this test needs to be updated once https://github.com/canonical/craft-application/pull/1058/changes#r3120374251 is accomdated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a third unit test for when provider_name = None? It should call get_provider(), which you can mock to return lxd.

Aeonoi and others added 4 commits April 24, 2026 12:14
@Aeonoi
Copy link
Copy Markdown
Author

Aeonoi commented Apr 24, 2026

How would I go about testing for the prune method since the current craft-provider version doesn't have the prune method. So I'm not sure if the prune-instances method is actually done right when running it locally.

@mr-cal
Copy link
Copy Markdown
Contributor

mr-cal commented Apr 24, 2026

@Aeonoi, you can update the pyproject.toml and run uv sync to update the lockfile.

Feel free to push that change to this PR. If the tests succeed in this PR, then we can make a new craft-providers release and you can update the pyproject.toml to the new release.

Here's an example: f155f77

@Aeonoi
Copy link
Copy Markdown
Author

Aeonoi commented Apr 29, 2026

Here's an example: f155f77

This worked, thanks.

@mr-cal
Copy link
Copy Markdown
Contributor

mr-cal commented May 5, 2026

Great! By the way, there is now a 3.6.0 release of craft-providers.

https://canonical-craft-providers.readthedocs-hosted.com/en/latest/reference/changelog/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a prune-instances command

4 participants