← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~lgp171188/lpcraft:add-clean-command-clean-flag into lpcraft:main

 

Guruprasad has proposed merging ~lgp171188/lpcraft:add-clean-command-clean-flag into lpcraft:main.

Commit message:
Implement the clean command and the clean flag to the run commands

With this, the managed environments can either be cleaned up manually
or after a run/run-once command.
    
LP: #1962238

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #1962238 in lpcraft: "lpcraft does not remove zfs storage artifacts"
  https://bugs.launchpad.net/lpcraft/+bug/1962238

For more details, see:
https://code.launchpad.net/~lgp171188/lpcraft/+git/lpcraft/+merge/416656
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/lpcraft:add-clean-command-clean-flag into lpcraft:main.
diff --git a/lpcraft/commands/clean.py b/lpcraft/commands/clean.py
new file mode 100644
index 0000000..d92dd32
--- /dev/null
+++ b/lpcraft/commands/clean.py
@@ -0,0 +1,32 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from argparse import Namespace
+from pathlib import Path
+
+from craft_cli import emit
+
+from lpcraft.config import Config
+from lpcraft.providers import get_provider
+
+
+def clean(args: Namespace) -> int:
+    """
+    Clean the managed environments for a project.
+    """
+    # Verify that the there is an lpcraft configuration file
+    # before trying to clean a project.
+    config_path = getattr(args, "config", Path(".launchpad.yaml"))
+    _ = Config.load(config_path)
+
+    cwd = Path.cwd()
+    emit.progress(f"Cleaning project {cwd.name!r}.")
+
+    provider = get_provider()
+    provider.ensure_provider_is_available()
+
+    provider.clean_project_environments(
+        project_name=cwd.name, project_path=cwd
+    )
+    emit.message(f"Cleaned project {cwd.name!r}.")
+    return 0
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
index 950549b..1850dce 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -17,6 +17,7 @@ from craft_providers.actions.snap_installer import install_from_store
 from dotenv import dotenv_values
 
 from lpcraft import env
+from lpcraft.commands.clean import clean
 from lpcraft.config import Config, Job, Output
 from lpcraft.errors import CommandError
 from lpcraft.plugin.manager import get_plugin_manager
@@ -283,34 +284,40 @@ def run(args: Namespace) -> int:
 
     provider = get_provider()
     provider.ensure_provider_is_available()
+    try:
+        for stage in config.pipeline:
+            stage_failed = False
+            for job_name in stage:
+                try:
+                    jobs = config.jobs.get(job_name, [])
+                    if not jobs:
+                        raise CommandError(
+                            f"No job definition for {job_name!r}"
+                        )
+                    for job in jobs:
+                        _run_job(
+                            job_name,
+                            job,
+                            provider,
+                            getattr(args, "output_directory", None),
+                        )
+                except CommandError as e:
+                    if len(stage) == 1:
+                        # Single-job stage, so just reraise this
+                        # in order to get simpler error messages.
+                        raise
+                    else:
+                        emit.error(e)
+                        stage_failed = True
+            if stage_failed:
+                raise CommandError(
+                    f"Some jobs in {stage} failed; stopping.", retcode=1
+                )
+    finally:
+        clean_environment = getattr(args, "clean", False)
 
-    for stage in config.pipeline:
-        stage_failed = False
-        for job_name in stage:
-            try:
-                jobs = config.jobs.get(job_name, [])
-                if not jobs:
-                    raise CommandError(f"No job definition for {job_name!r}")
-                for job in jobs:
-                    _run_job(
-                        job_name,
-                        job,
-                        provider,
-                        getattr(args, "output_directory", None),
-                    )
-            except CommandError as e:
-                if len(stage) == 1:
-                    # Single-job stage, so just reraise this in order to get
-                    # simpler error messages.
-                    raise
-                else:
-                    emit.error(e)
-                    stage_failed = True
-        if stage_failed:
-            raise CommandError(
-                f"Some jobs in {stage} failed; stopping.", retcode=1
-            )
-
+        if clean_environment:
+            clean(args)
     return 0
 
 
@@ -333,6 +340,12 @@ def run_one(args: Namespace) -> int:
     provider = get_provider()
     provider.ensure_provider_is_available()
 
-    _run_job(args.job, job, provider, getattr(args, "output", None))
+    try:
+        _run_job(args.job, job, provider, getattr(args, "output", None))
+    finally:
+        clean_environment = getattr(args, "clean", False)
+
+        if clean_environment:
+            clean(args)
 
     return 0
diff --git a/lpcraft/commands/tests/__init__.py b/lpcraft/commands/tests/__init__.py
index aba747f..f21c05a 100644
--- a/lpcraft/commands/tests/__init__.py
+++ b/lpcraft/commands/tests/__init__.py
@@ -2,13 +2,16 @@
 # GNU General Public License version 3 (see the file LICENSE).
 
 from dataclasses import dataclass
-from typing import List
-from unittest.mock import ANY, call
+from typing import List, Optional
+from unittest.mock import ANY, Mock, call
 
 from craft_cli import CraftError
+from craft_providers.lxd import LXC, launch
 from testtools import TestCase
 
 from lpcraft.main import main
+from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
+from lpcraft.providers.tests import FakeLXDInstaller
 from lpcraft.tests.fixtures import RecordingEmitterFixture
 
 
@@ -45,3 +48,19 @@ class CommandBaseTestCase(TestCase):
                 ],
             )
             return result
+
+
+def makeLXDProvider(
+    is_ready: bool = True,
+    lxd_launcher: Optional[_LXDLauncher] = None,
+) -> LXDProvider:
+    lxc = Mock(spec=LXC)
+    lxc.remote_list.return_value = {}
+    lxd_installer = FakeLXDInstaller(is_ready=is_ready)
+    if lxd_launcher is None:
+        lxd_launcher = Mock(spec=launch)
+    return LXDProvider(
+        lxc=lxc,
+        lxd_installer=lxd_installer,
+        lxd_launcher=lxd_launcher,
+    )
diff --git a/lpcraft/commands/tests/test_clean.py b/lpcraft/commands/tests/test_clean.py
new file mode 100644
index 0000000..cd042d8
--- /dev/null
+++ b/lpcraft/commands/tests/test_clean.py
@@ -0,0 +1,98 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import os
+from pathlib import Path
+from textwrap import dedent
+from unittest.mock import patch
+
+from fixtures import TempDir
+from testtools.matchers import MatchesStructure
+
+from lpcraft.commands.tests import CommandBaseTestCase, makeLXDProvider
+from lpcraft.errors import CommandError, ConfigurationError
+
+
+class TestClean(CommandBaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.tmp_project_path = Path(
+            self.useFixture(TempDir()).join("test-clean-project")
+        )
+        self.tmp_project_path.mkdir()
+        cwd = Path.cwd()
+        os.chdir(self.tmp_project_path)
+        self.addCleanup(os.chdir, cwd)
+
+    def test_missing_config_file(self):
+        result = self.run_command("clean")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    ConfigurationError(
+                        "Couldn't find config file '.launchpad.yaml'"
+                    ),
+                ],
+            ),
+        )
+        tmp_config_file = os.path.join(
+            self.tmp_project_path, "test-clean-config/lpcraft-configuration.yaml"
+        )
+        result = self.run_command("clean", "-c", tmp_config_file)
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    ConfigurationError(
+                        f"Couldn't find config file '{tmp_config_file}'"
+                        )
+                ],
+            )
+        )
+
+    @patch("lpcraft.commands.clean.get_provider")
+    def test_lxd_not_ready(
+            self, mock_get_provider
+    ):
+        mock_get_provider.return_value = makeLXDProvider(is_ready=False)
+        config = dedent(
+            """
+            pipeline: []
+            jobs: {}
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("clean")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("LXD is broken")],
+            ),
+        )
+
+    @patch("lpcraft.commands.clean.get_provider")
+    @patch("lpcraft.providers._lxd.LXDProvider.clean_project_environments")
+    def test_clean_cleans_project_environments(
+            self, mock_clean_project_environments, mock_get_provider
+    ):
+        mock_get_provider.return_value = makeLXDProvider(is_ready=True)
+
+        config = dedent(
+            """
+            pipeline: []
+            jobs: {}
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        self.run_command("clean")
+        mock_clean_project_environments.assert_called_with(
+            project_name=self.tmp_project_path.name, project_path=self.tmp_project_path
+        )
diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
index 19cb494..dc35f54 100644
--- a/lpcraft/commands/tests/test_run.py
+++ b/lpcraft/commands/tests/test_run.py
@@ -11,14 +11,12 @@ from textwrap import dedent
 from typing import Any, AnyStr, Dict, List, Optional
 from unittest.mock import ANY, Mock, call, patch
 
-from craft_providers.lxd import LXC, launch
+from craft_providers.lxd import launch
 from fixtures import TempDir
 from testtools.matchers import MatchesStructure
 
-from lpcraft.commands.tests import CommandBaseTestCase
+from lpcraft.commands.tests import CommandBaseTestCase, makeLXDProvider
 from lpcraft.errors import CommandError, ConfigurationError
-from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
-from lpcraft.providers.tests import FakeLXDInstaller
 
 
 class LocalExecuteRun:
@@ -71,22 +69,6 @@ class RunBaseTestCase(CommandBaseTestCase):
 
         self.addCleanup(os.chdir, cwd)
 
-    def makeLXDProvider(
-        self,
-        is_ready: bool = True,
-        lxd_launcher: Optional[_LXDLauncher] = None,
-    ) -> LXDProvider:
-        lxc = Mock(spec=LXC)
-        lxc.remote_list.return_value = {}
-        lxd_installer = FakeLXDInstaller(is_ready=is_ready)
-        if lxd_launcher is None:
-            lxd_launcher = Mock(spec=launch)
-        return LXDProvider(
-            lxc=lxc,
-            lxd_installer=lxd_installer,
-            lxd_launcher=lxd_launcher,
-        )
-
 
 class TestRun(RunBaseTestCase):
     def test_missing_config_file(self):
@@ -116,7 +98,7 @@ class TestRun(RunBaseTestCase):
         )
         Path(self.tmp_config_path).mkdir(parents=True, exist_ok=True)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -150,7 +132,7 @@ class TestRun(RunBaseTestCase):
     def test_lxd_not_ready(
         self, mock_get_host_architecture, mock_get_provider
     ):
-        mock_get_provider.return_value = self.makeLXDProvider(is_ready=False)
+        mock_get_provider.return_value = makeLXDProvider(is_ready=False)
         config = dedent(
             """
             pipeline: []
@@ -174,7 +156,7 @@ class TestRun(RunBaseTestCase):
     def test_job_not_defined(
         self, mock_get_host_architecture, mock_get_provider
     ):
-        mock_get_provider.return_value = self.makeLXDProvider()
+        mock_get_provider.return_value = makeLXDProvider()
         config = dedent(
             """
             pipeline:
@@ -204,7 +186,7 @@ class TestRun(RunBaseTestCase):
         # assumed that the dispatcher won't dispatch anything for an
         # architecture if it has no jobs at all.)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -252,7 +234,7 @@ class TestRun(RunBaseTestCase):
     def test_no_run_definition(
         self, mock_get_host_architecture, mock_get_provider
     ):
-        mock_get_provider.return_value = self.makeLXDProvider()
+        mock_get_provider.return_value = makeLXDProvider()
         config = dedent(
             """
             pipeline:
@@ -286,7 +268,7 @@ class TestRun(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 2)
@@ -338,7 +320,7 @@ class TestRun(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -402,7 +384,7 @@ class TestRun(RunBaseTestCase):
         # calling `lpcraft` with no arguments triggers the run command
         # and is functionally equivalent to `lpcraft run`
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -446,7 +428,7 @@ class TestRun(RunBaseTestCase):
         # one job in a stage fails, we run all the jobs in that stage before
         # stopping.
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.side_effect = iter(
@@ -520,7 +502,7 @@ class TestRun(RunBaseTestCase):
         # but we do at least wait for all of them to succeed before
         # proceeding to the next stage in the pipeline.
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.side_effect = iter(
@@ -576,7 +558,7 @@ class TestRun(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -648,7 +630,7 @@ class TestRun(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -698,7 +680,7 @@ class TestRun(RunBaseTestCase):
 
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -761,7 +743,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -805,7 +787,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -850,7 +832,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -898,7 +880,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -945,7 +927,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -990,7 +972,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -1035,7 +1017,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -1085,7 +1067,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -1129,7 +1111,7 @@ class TestRun(RunBaseTestCase):
     ):
         target_path = Path(self.useFixture(TempDir()).path)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = LocalExecuteRun(self.tmp_project_path)
         launcher.return_value.execute_run = execute_run
@@ -1169,7 +1151,7 @@ class TestRun(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -1256,7 +1238,7 @@ class TestRun(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -1310,7 +1292,7 @@ class TestRun(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 100)
@@ -1358,7 +1340,7 @@ class TestRun(RunBaseTestCase):
             return subprocess.CompletedProcess([], 0)
 
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         launcher.return_value.execute_run.side_effect = execute_run
         config = dedent(
@@ -1392,7 +1374,7 @@ class TestRun(RunBaseTestCase):
             return subprocess.CompletedProcess([], 0)
 
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         launcher.return_value.execute_run.side_effect = execute_run
         config = dedent(
@@ -1428,6 +1410,82 @@ class TestRun(RunBaseTestCase):
             stderr_lines[-2:],
         )
 
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    @patch("lpcraft.providers._lxd.LXDProvider.clean_project_environments")
+    def test_clean_flag_always_cleans_up_even_when_there_are_errors(
+        self,
+        mock_clean_project_environments,
+        mock_get_host_architecture,
+        mock_get_provider,
+    ):
+        def execute_run(
+            command: List[str], **kwargs: Any
+        ) -> "subprocess.CompletedProcess[AnyStr]":
+            os.write(kwargs["stdout"], b"test\n")
+            return subprocess.CompletedProcess([], 0)
+
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        launcher.return_value.execute_run.side_effect = execute_run
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: echo test
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run", "--clean")
+        self.assertEqual(0, result.exit_code)
+
+        mock_clean_project_environments.assert_called_with(
+            project_name=self.tmp_project_path.name,
+            project_path=self.tmp_project_path,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    @patch("lpcraft.providers._lxd.LXDProvider.clean_project_environments")
+    def test_clean_flag_cleans_up_the_managed_environment(
+        self,
+        mock_clean_project_environments,
+        mock_get_host_architecture,
+        mock_get_provider,
+    ):
+        mock_get_provider.return_value = makeLXDProvider()
+        # There are no jobs defined. So there will be an error
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs: {}
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        result = self.run_command("run", "--clean")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("No job definition for 'test'")],
+            ),
+        )
+
+        mock_clean_project_environments.assert_called_with(
+            project_name=self.tmp_project_path.name,
+            project_path=self.tmp_project_path,
+        )
+
 
 class TestRunOne(RunBaseTestCase):
     def test_missing_config_file(self):
@@ -1457,7 +1515,7 @@ class TestRunOne(RunBaseTestCase):
         )
         Path(self.tmp_config_path).mkdir(parents=True, exist_ok=True)
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -1499,7 +1557,7 @@ class TestRunOne(RunBaseTestCase):
     def test_job_not_defined(
         self, mock_get_host_architecture, mock_get_provider
     ):
-        mock_get_provider.return_value = self.makeLXDProvider()
+        mock_get_provider.return_value = makeLXDProvider()
         config = dedent(
             """
             pipeline:
@@ -1525,7 +1583,7 @@ class TestRunOne(RunBaseTestCase):
     def test_job_index_not_defined(
         self, mock_get_host_architecture, mock_get_provider
     ):
-        mock_get_provider.return_value = self.makeLXDProvider()
+        mock_get_provider.return_value = makeLXDProvider()
         config = dedent(
             """
             pipeline:
@@ -1556,7 +1614,7 @@ class TestRunOne(RunBaseTestCase):
     @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
     def test_job_fails(self, mock_get_host_architecture, mock_get_provider):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 2)
@@ -1608,7 +1666,7 @@ class TestRunOne(RunBaseTestCase):
         self, mock_get_host_architecture, mock_get_provider
     ):
         launcher = Mock(spec=launch)
-        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        provider = makeLXDProvider(lxd_launcher=launcher)
         mock_get_provider.return_value = provider
         execute_run = launcher.return_value.execute_run
         execute_run.return_value = subprocess.CompletedProcess([], 0)
@@ -1649,3 +1707,98 @@ class TestRunOne(RunBaseTestCase):
             stdout=ANY,
             stderr=ANY,
         )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    @patch("lpcraft.providers._lxd.LXDProvider.clean_project_environments")
+    def test_run_one_clean_flag_always_cleans_up_even_when_there_are_errors(
+        self,
+        mock_clean_project_environments,
+        mock_get_host_architecture,
+        mock_get_provider,
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 2)
+        config = dedent(
+            """
+            pipeline:
+                - test
+                - build-wheel
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: tox
+                build-wheel:
+                    series: focal
+                    architectures: amd64
+                    run: pyproject-build
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        result = self.run_command("run-one", "--clean", "build-wheel", "0")
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=2,
+                errors=[
+                    CommandError(
+                        "Job 'build-wheel' for focal/amd64 failed with exit "
+                        "status 2.",
+                        retcode=2,
+                    )
+                ],
+            ),
+        )
+        mock_clean_project_environments.assert_called_with(
+            project_name=self.tmp_project_path.name,
+            project_path=self.tmp_project_path,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    @patch("lpcraft.providers._lxd.LXDProvider.clean_project_environments")
+    def test_run_one_clean_flag_cleans_up_the_managed_environment(
+        self,
+        mock_clean_project_environments,
+        mock_get_host_architecture,
+        mock_get_provider,
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - test
+                - build-wheel
+
+            jobs:
+                test:
+                    matrix:
+                        - series: bionic
+                          architectures: amd64
+                        - series: focal
+                          architectures: [amd64, s390x]
+                    run: tox
+                build-wheel:
+                    series: bionic
+                    architectures: amd64
+                    run: pyproject-build
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run-one", "--clean", "test", "1")
+
+        self.assertEqual(0, result.exit_code)
+        mock_clean_project_environments.assert_called_with(
+            project_name=self.tmp_project_path.name,
+            project_path=self.tmp_project_path,
+        )
diff --git a/lpcraft/main.py b/lpcraft/main.py
index 8afe421..028dfc5 100644
--- a/lpcraft/main.py
+++ b/lpcraft/main.py
@@ -11,6 +11,7 @@ from typing import List, Optional
 from craft_cli import CraftError, EmitterMode, emit
 
 from lpcraft._version import version_description as lpcraft_version
+from lpcraft.commands.clean import clean
 from lpcraft.commands.run import run, run_one
 from lpcraft.commands.version import version
 from lpcraft.errors import CommandError
@@ -62,6 +63,16 @@ def main(argv: Optional[List[str]] = None) -> int:
     # XXX cjwatson 2021-11-15: Subcommand arguments should be defined
     # alongside the individual subcommands rather than here.
 
+    parser_clean = subparsers.add_parser("clean", help=clean.__doc__)
+    parser_clean.add_argument(
+        "-c",
+        "--config",
+        type=Path,
+        default=".launchpad.yaml",
+        help="Read the configuration file from this path.",
+    )
+    parser_clean.set_defaults(func=clean)
+
     parser_run = subparsers.add_parser("run", help=run.__doc__)
     parser_run.add_argument(
         "--output-directory",
@@ -75,6 +86,12 @@ def main(argv: Optional[List[str]] = None) -> int:
         default=".launchpad.yaml",
         help="Read the configuration file from this path.",
     )
+    parser_run.add_argument(
+        "--clean",
+        default=False,
+        action="store_true",
+        help="Clean the managed environments after the run.",
+    )
     parser_run.set_defaults(func=run)
 
     parser_run_one = subparsers.add_parser("run-one", help=run_one.__doc__)
@@ -90,6 +107,12 @@ def main(argv: Optional[List[str]] = None) -> int:
         default=".launchpad.yaml",
         help="Read the configuration file from this path.",
     )
+    parser_run_one.add_argument(
+        "--clean",
+        default=False,
+        action="store_true",
+        help="Clean the managed environments after the run.",
+    )
     parser_run_one.add_argument("job", help="Run only this job name.")
     parser_run_one.add_argument(
         "index",

Follow ups