← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/lpcraft:provider into lpcraft:main

 

Colin Watson has proposed merging ~cjwatson/lpcraft:provider into lpcraft:main with ~cjwatson/lpcraft:snap-skeleton as a prerequisite.

Commit message:
Add a basic provider framework

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/lpcraft/+git/lpcraft/+merge/411416

This is enough to be able to run code in a dynamically-provisioned LXD container, with space for slotting in VM code later if it becomes necessary.  The structure is borrowed substantially from `charmcraft`.

I'm not sure I would have done all of this quite this way if I'd been working entirely from scratch: in particular, the tests are very mock-heavy.  However, I wanted to make use of `craft-providers` rather than making the LXD arrangements myself, so structuring things similarly to `charmcraft` was the path of least resistance.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:provider into lpcraft:main.
diff --git a/lpcraft/env.py b/lpcraft/env.py
new file mode 100644
index 0000000..32ec39f
--- /dev/null
+++ b/lpcraft/env.py
@@ -0,0 +1,22 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+"""lpcraft environment utilities."""
+
+import os
+from distutils.util import strtobool
+from pathlib import Path
+
+
+def get_managed_environment_home_path():
+    """Path for home when running in managed environment."""
+    return Path("/root")
+
+
+def get_managed_environment_project_path():
+    """Path for project when running in managed environment."""
+    return get_managed_environment_home_path() / "project"
+
+
+def is_managed_mode():
+    return bool(strtobool(os.environ.get("LPCRAFT_MANAGED_MODE", "n")))
diff --git a/lpcraft/providers/__init__.py b/lpcraft/providers/__init__.py
new file mode 100644
index 0000000..218b7c1
--- /dev/null
+++ b/lpcraft/providers/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    "get_provider",
+]
+
+from lpcraft.providers._lxd import LXDProvider
+
+
+def get_provider():
+    """Get the configured or appropriate provider for the host OS."""
+    return LXDProvider()
diff --git a/lpcraft/providers/_base.py b/lpcraft/providers/_base.py
new file mode 100644
index 0000000..72aac39
--- /dev/null
+++ b/lpcraft/providers/_base.py
@@ -0,0 +1,98 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+"""Build environment provider support for lpcraft."""
+
+__all__ = [
+    "Provider",
+]
+
+import os
+from abc import ABC, abstractmethod
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Dict, List
+
+from craft_providers import bases
+
+
+class Provider(ABC):
+    """A build environment provider for lpcraft."""
+
+    @abstractmethod
+    def clean_project_environments(
+        self, *, project_name: str, project_path: Path
+    ) -> List[str]:
+        """Clean up any environments created for a project.
+
+        :param project_name: Name of project.
+        :param project_path: Path to project.
+
+        :return: List of containers deleted.
+        """
+
+    @classmethod
+    @abstractmethod
+    def ensure_provider_is_available(cls) -> None:
+        """Ensure provider is available, prompting to install it if required.
+
+        :raises CommandError: if provider is not available.
+        """
+
+    @classmethod
+    @abstractmethod
+    def is_provider_available(cls) -> bool:
+        """Check if provider is installed and available for use.
+
+        :return: True if installed.
+        """
+
+    def get_instance_name(
+        self,
+        *,
+        project_name: str,
+        project_path: Path,
+        series: str,
+        architecture: str,
+    ) -> str:
+        """Get the name for an instance using the given parameters.
+
+        :param project_name: Name of project.
+        :param project_path: Path to project.
+        :param series: Distribution series name.
+        :param architecture: Targeted architecture name.
+        """
+        return (
+            f"lpcraft-{project_name}-{project_path.stat().st_ino}"
+            f"-{series}-{architecture}"
+        )
+
+    def get_command_environment(self) -> Dict[str, str]:
+        """Construct the required environment."""
+        env = bases.buildd.default_command_environment()
+        env["LPCRAFT_MANAGED_MODE"] = "1"
+
+        # Pass through host environment that target may need.
+        for env_key in ("http_proxy", "https_proxy", "no_proxy"):
+            if env_key in os.environ:
+                env[env_key] = os.environ[env_key]
+
+        return env
+
+    @abstractmethod
+    @contextmanager
+    def launched_environment(
+        self,
+        *,
+        project_name: str,
+        project_path: Path,
+        series: str,
+        architecture: str,
+    ):
+        """Launch environment for specified series and architecture.
+
+        :param project_name: Name of project.
+        :param project_path: Path to project.
+        :param series: Distribution series name.
+        :param architecture: Targeted architecture name.
+        """
diff --git a/lpcraft/providers/_buildd.py b/lpcraft/providers/_buildd.py
new file mode 100644
index 0000000..036963a
--- /dev/null
+++ b/lpcraft/providers/_buildd.py
@@ -0,0 +1,80 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+"""Buildd-related code for lpcraft."""
+
+__all__ = [
+    "LPCraftBuilddBaseConfiguration",
+    "SERIES_TO_BUILDD_IMAGE_ALIAS",
+]
+
+from typing import Optional
+
+from craft_providers import Executor, bases
+from craft_providers.actions import snap_installer
+
+# Why can't we just pass a series name and be done with it?
+SERIES_TO_BUILDD_IMAGE_ALIAS = {
+    "xenial": bases.BuilddBaseAlias.XENIAL,
+    "bionic": bases.BuilddBaseAlias.BIONIC,
+    "focal": bases.BuilddBaseAlias.FOCAL,
+}
+
+
+class LPCraftBuilddBaseConfiguration(bases.BuilddBase):
+    """Base configuration for lpcraft.
+
+    :cvar compatibility_tag: Tag/version for variant of build configuration
+        and setup.  Any change to this version indicates that prior
+        (versioned) instances are incompatible and must be cleaned.  As
+        such, any new value should be unique by comparison with old values
+        (e.g. incrementing).  lpcraft extends the buildd tag to include its
+        own version indicator (.0) and namespace ("lpcraft").
+    """
+
+    compatibility_tag: str = f"lpcraft-{bases.BuilddBase.compatibility_tag}.0"
+
+    def _setup_lpcraft(self, *, executor: Executor) -> None:
+        """Install lpcraft in target environment.
+
+        The default behaviour is to inject the host snap into the target
+        environment.
+
+        :raises BaseConfigurationError: on error.
+        """
+        try:
+            snap_installer.inject_from_host(
+                executor=executor, snap_name="lpcraft", classic=True
+            )
+        except snap_installer.SnapInstallationError as error:
+            raise bases.BaseConfigurationError(
+                brief=(
+                    "Failed to inject host lpcraft snap into target "
+                    "environment."
+                )
+            ) from error
+
+    def setup(
+        self,
+        *,
+        executor: Executor,
+        retry_wait: float = 0.25,
+        timeout: Optional[float] = None,
+    ) -> None:
+        """Prepare base instance for use by the application.
+
+        In addition to the guarantees provided by buildd, the lpcraft snap
+        is installed.
+
+        :param executor: Executor for target container.
+        :param retry_wait: Duration to sleep() between status checks (if
+            required).
+        :param timeout: Timeout in seconds.
+
+        :raises BaseCompatibilityError: if the instance is incompatible.
+        :raises BaseConfigurationError: on any other unexpected error.
+        """
+        super().setup(
+            executor=executor, retry_wait=retry_wait, timeout=timeout
+        )
+        self._setup_lpcraft(executor=executor)
diff --git a/lpcraft/providers/_lxd.py b/lpcraft/providers/_lxd.py
new file mode 100644
index 0000000..a8e178b
--- /dev/null
+++ b/lpcraft/providers/_lxd.py
@@ -0,0 +1,193 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+"""LXD build environment provider support for lpcraft."""
+
+__all__ = [
+    "LXDProvider",
+]
+
+import re
+from contextlib import contextmanager
+from pathlib import Path
+from typing import List
+
+from craft_cli import emit
+from craft_providers import bases, lxd
+
+from lpcraft.env import get_managed_environment_project_path
+from lpcraft.errors import CommandError
+from lpcraft.providers._base import Provider
+from lpcraft.providers._buildd import (
+    SERIES_TO_BUILDD_IMAGE_ALIAS,
+    LPCraftBuilddBaseConfiguration,
+)
+from lpcraft.utils import ask_user
+
+
+class LXDProvider(Provider):
+    """A LXD build environment provider for lpcraft.
+
+    :param lxc: Optional lxc client to use.
+    :param lxd_project: LXD project to use (default is lpcraft).
+    :param lxd_remote: LXD remote to use (default is local).
+    """
+
+    def __init__(
+        self,
+        *,
+        lxc: lxd.LXC = lxd.LXC(),
+        lxd_project: str = "lpcraft",
+        lxd_remote: str = "local",
+    ) -> None:
+        self.lxc = lxc
+        self.lxd_project = lxd_project
+        self.lxd_remote = lxd_remote
+
+    def clean_project_environments(
+        self, *, project_name: str, project_path: Path
+    ) -> List[str]:
+        """Clean up any environments created for a project.
+
+        :param project_name: Name of project.
+        :param project_path: Path to project.
+
+        :return: List of containers deleted.
+        """
+        deleted: List[str] = []
+
+        if not self.is_provider_available():
+            return deleted
+
+        inode = str(project_path.stat().st_ino)
+
+        try:
+            names = self.lxc.list_names(
+                project=self.lxd_project, remote=self.lxd_remote
+            )
+        except lxd.LXDError as error:
+            raise CommandError(str(error)) from error
+
+        for name in names:
+            if re.match(
+                fr"^lpcraft-{re.escape(project_name)}-{re.escape(inode)}"
+                fr"-.+-.+$",
+                name,
+            ):
+                emit.trace(f"Deleting container {name!r}.")
+                try:
+                    self.lxc.delete(
+                        instance_name=name,
+                        force=True,
+                        project=self.lxd_project,
+                        remote=self.lxd_remote,
+                    )
+                except lxd.LXDError as error:
+                    raise CommandError(str(error)) from error
+                deleted.append(name)
+            else:
+                emit.trace(f"Not deleting container {name!r}.")
+
+        return deleted
+
+    @classmethod
+    def ensure_provider_is_available(cls) -> None:
+        """Ensure provider is available, prompting to install it if required.
+
+        :raises CommandError: if provider is not available.
+        """
+        if not lxd.is_installed():
+            if ask_user(
+                "LXD is required, but not installed. Do you wish to install "
+                "LXD and configure it with the defaults?",
+                default=False,
+            ):
+                try:
+                    lxd.install()
+                except lxd.LXDInstallationError as error:
+                    raise CommandError(
+                        "Failed to install LXD. Visit "
+                        "https://snapcraft.io/lxd for instructions on how to "
+                        "install the LXD snap for your distribution."
+                    ) from error
+            else:
+                raise CommandError(
+                    "LXD is required, but not installed. Visit "
+                    "https://snapcraft.io/lxd for instructions on how to "
+                    "install the LXD snap for your distribution."
+                )
+
+        try:
+            lxd.ensure_lxd_is_ready()
+        except lxd.LXDError as error:
+            raise CommandError(str(error)) from error
+
+    @classmethod
+    def is_provider_available(cls) -> bool:
+        """Check if provider is installed and available for use.
+
+        :return: True if installed.
+        """
+        return lxd.is_installed()
+
+    @contextmanager
+    def launched_environment(
+        self,
+        *,
+        project_name: str,
+        project_path: Path,
+        series: str,
+        architecture: str,
+    ):
+        """Launch environment for specified series and architecture.
+
+        :param project_name: Name of project.
+        :param project_path: Path to project.
+        :param series: Distribution series name.
+        :param architecture: Targeted architecture name.
+        """
+        alias = SERIES_TO_BUILDD_IMAGE_ALIAS[series]
+        instance_name = self.get_instance_name(
+            project_name=project_name,
+            project_path=project_path,
+            series=series,
+            architecture=architecture,
+        )
+        environment = self.get_command_environment()
+        try:
+            image_remote = lxd.configure_buildd_image_remote()
+        except lxd.LXDError as error:
+            raise CommandError(str(error)) from error
+        base_configuration = LPCraftBuilddBaseConfiguration(
+            alias=alias, environment=environment, hostname=instance_name
+        )
+
+        try:
+            instance = lxd.launch(
+                name=instance_name,
+                base_configuration=base_configuration,
+                image_name=series,
+                image_remote=image_remote,
+                auto_clean=True,
+                auto_create_project=True,
+                map_user_uid=True,
+                use_snapshots=True,
+                project=self.lxd_project,
+                remote=self.lxd_remote,
+            )
+        except (bases.BaseConfigurationError, lxd.LXDError) as error:
+            raise CommandError(str(error)) from error
+
+        instance.mount(
+            host_source=project_path,
+            target=get_managed_environment_project_path(),
+        )
+
+        try:
+            yield instance
+        finally:
+            try:
+                instance.unmount_all()
+                instance.stop()
+            except lxd.LXDError as error:
+                raise CommandError(str(error)) from error
diff --git a/lpcraft/providers/tests/__init__.py b/lpcraft/providers/tests/__init__.py
new file mode 100644
index 0000000..6f6b0f5
--- /dev/null
+++ b/lpcraft/providers/tests/__init__.py
@@ -0,0 +1,22 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from fixtures import MockPatch
+from testtools import TestCase
+
+
+class ProviderBaseTestCase(TestCase):
+    def setUp(self):
+        super().setUp()
+        # Patch out inherited setup steps.
+        self.useFixture(
+            MockPatch(
+                "craft_providers.bases.BuilddBase.setup",
+                lambda *args, **kwargs: None,
+            )
+        )
+
+
+class MockLXC(MockPatch):
+    def __init__(self):
+        super().__init__("craft_providers.lxd.LXC", autospec=True)
diff --git a/lpcraft/providers/tests/test_buildd.py b/lpcraft/providers/tests/test_buildd.py
new file mode 100644
index 0000000..57b134f
--- /dev/null
+++ b/lpcraft/providers/tests/test_buildd.py
@@ -0,0 +1,49 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from unittest.mock import Mock
+
+from craft_providers import Executor, bases
+from craft_providers.actions import snap_installer
+from fixtures import MockPatch
+
+from lpcraft.providers._buildd import LPCraftBuilddBaseConfiguration
+from lpcraft.providers.tests import ProviderBaseTestCase
+
+
+class TestLPCraftBuilddBaseConfiguration(ProviderBaseTestCase):
+    def test_setup_inject_from_host(self):
+        mock_instance = Mock(spec=Executor)
+        mock_inject = self.useFixture(
+            MockPatch(
+                "craft_providers.actions.snap_installer.inject_from_host"
+            )
+        ).mock
+        config = LPCraftBuilddBaseConfiguration(alias="focal")
+
+        config.setup(executor=mock_instance)
+
+        self.assertEqual("lpcraft-buildd-base-v0.0", config.compatibility_tag)
+        mock_inject.assert_called_once_with(
+            executor=mock_instance, snap_name="lpcraft", classic=True
+        )
+
+    def test_setup_inject_from_host_error(self):
+        mock_instance = Mock(spec=Executor)
+        mock_inject = self.useFixture(
+            MockPatch(
+                "craft_providers.actions.snap_installer.inject_from_host"
+            )
+        ).mock
+        mock_inject.side_effect = snap_installer.SnapInstallationError(
+            brief="Boom"
+        )
+        config = LPCraftBuilddBaseConfiguration(alias="focal")
+
+        with self.assertRaisesRegex(
+            bases.BaseConfigurationError,
+            r"^Failed to inject host lpcraft snap into target environment\.$",
+        ) as raised:
+            config.setup(executor=mock_instance)
+
+        self.assertIsNotNone(raised.exception.__cause__)
diff --git a/lpcraft/providers/tests/test_get_provider.py b/lpcraft/providers/tests/test_get_provider.py
new file mode 100644
index 0000000..fbf7e88
--- /dev/null
+++ b/lpcraft/providers/tests/test_get_provider.py
@@ -0,0 +1,12 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from testtools import TestCase
+
+from lpcraft.providers import get_provider
+from lpcraft.providers._lxd import LXDProvider
+
+
+class TestGetProvider(TestCase):
+    def test_default(self):
+        self.assertIsInstance(get_provider(), LXDProvider)
diff --git a/lpcraft/providers/tests/test_lxd.py b/lpcraft/providers/tests/test_lxd.py
new file mode 100644
index 0000000..d1aca1f
--- /dev/null
+++ b/lpcraft/providers/tests/test_lxd.py
@@ -0,0 +1,399 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import re
+from pathlib import Path
+from unittest.mock import Mock, call
+
+from craft_providers.bases import BaseConfigurationError, BuilddBaseAlias
+from craft_providers.lxd import LXDError, LXDInstallationError
+from fixtures import EnvironmentVariable, MockPatch
+
+from lpcraft.errors import CommandError
+from lpcraft.providers._lxd import LXDProvider
+from lpcraft.providers.tests import MockLXC, ProviderBaseTestCase
+from lpcraft.tests.fixtures import EmitterFixture
+
+_base_path = (
+    "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
+)
+
+
+class TestLXDProvider(ProviderBaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.mock_lxc = self.useFixture(MockLXC()).mock
+        self.mock_lxd_is_installed = self.useFixture(
+            MockPatch("craft_providers.lxd.is_installed", return_value=True)
+        ).mock
+        self.mock_ask_user = self.useFixture(
+            MockPatch("lpcraft.providers._lxd.ask_user", return_value=False)
+        ).mock
+        self.mock_lxd_install = self.useFixture(
+            MockPatch("craft_providers.lxd.install")
+        ).mock
+        self.mock_configure_buildd_image_remote = self.useFixture(
+            MockPatch(
+                "craft_providers.lxd.configure_buildd_image_remote",
+                return_value="buildd-remote",
+            )
+        ).mock
+        self.mock_buildd_base_configuration = self.useFixture(
+            MockPatch(
+                "lpcraft.providers._lxd.LPCraftBuilddBaseConfiguration",
+                autospec=True,
+            )
+        ).mock
+        self.mock_lxd_launch = self.useFixture(
+            MockPatch("craft_providers.lxd.launch", autospec=True)
+        ).mock
+        self.mock_path = Mock(spec=Path)
+        self.mock_path.stat.return_value.st_ino = 12345
+        self.emitter = self.useFixture(EmitterFixture())
+
+    def test_clean_project_environments_without_lxd(self):
+        self.mock_lxd_is_installed.return_value = False
+        provider = LXDProvider(
+            lxc=self.mock_lxc,
+            lxd_project="test-project",
+            lxd_remote="test-remote",
+        )
+
+        self.assertEqual(
+            [],
+            provider.clean_project_environments(
+                project_name="my-project", project_path=self.mock_path
+            ),
+        )
+
+        self.mock_lxd_is_installed.assert_called_once_with()
+        self.mock_lxc.assert_not_called()
+
+    def test_clean_project_environments_no_matches(self):
+        self.mock_lxc.list_names.return_value = [
+            "lpcraft-testproject-12345-focal-amd64"
+        ]
+        provider = LXDProvider(
+            lxc=self.mock_lxc,
+            lxd_project="test-project",
+            lxd_remote="test-remote",
+        )
+
+        self.assertEqual(
+            [],
+            provider.clean_project_environments(
+                project_name="my-project", project_path=self.mock_path
+            ),
+        )
+
+        self.assertEqual(
+            [call.list_names(project="test-project", remote="test-remote")],
+            self.mock_lxc.mock_calls,
+        )
+
+    def test_clean_project_environments(self):
+        self.mock_lxc.list_names.return_value = [
+            "do-not-delete-me",
+            "lpcraft-testproject-12345-focal-amd64",
+            "lpcraft-my-project-12345--",
+            "lpcraft-my-project-12345-focal-amd64",
+            "lpcraft-my-project-12345-bionic-arm64",
+            "lpcraft-my-project-123456--",
+            "lpcraft_12345_focal_amd64",
+        ]
+        provider = LXDProvider(
+            lxc=self.mock_lxc,
+            lxd_project="test-project",
+            lxd_remote="test-remote",
+        )
+
+        self.assertEqual(
+            [
+                "lpcraft-my-project-12345-focal-amd64",
+                "lpcraft-my-project-12345-bionic-arm64",
+            ],
+            provider.clean_project_environments(
+                project_name="my-project", project_path=self.mock_path
+            ),
+        )
+
+        self.assertEqual(
+            [
+                call.list_names(project="test-project", remote="test-remote"),
+                call.delete(
+                    instance_name="lpcraft-my-project-12345-focal-amd64",
+                    force=True,
+                    project="test-project",
+                    remote="test-remote",
+                ),
+                call.delete(
+                    instance_name="lpcraft-my-project-12345-bionic-arm64",
+                    force=True,
+                    project="test-project",
+                    remote="test-remote",
+                ),
+            ],
+            self.mock_lxc.mock_calls,
+        )
+
+    def test_clean_project_environments_list_failure(self):
+        error = LXDError(brief="Boom")
+        self.mock_lxc.list_names.side_effect = error
+        provider = LXDProvider(lxc=self.mock_lxc)
+
+        with self.assertRaisesRegex(CommandError, r"Boom") as raised:
+            provider.clean_project_environments(
+                project_name="test", project_path=self.mock_path
+            )
+
+        self.assertIs(error, raised.exception.__cause__)
+
+    def test_clean_project_environments_delete_failure(self):
+        error = LXDError(brief="Boom")
+        self.mock_lxc.list_names.return_value = [
+            "lpcraft-test-12345-focal-amd64"
+        ]
+        self.mock_lxc.delete.side_effect = error
+        provider = LXDProvider(lxc=self.mock_lxc)
+
+        with self.assertRaisesRegex(CommandError, r"Boom") as raised:
+            provider.clean_project_environments(
+                project_name="test", project_path=self.mock_path
+            )
+
+        self.assertIs(error, raised.exception.__cause__)
+
+    def test_ensure_provider_is_available_ok_when_installed(self):
+        provider = LXDProvider()
+
+        provider.ensure_provider_is_available()
+
+    def test_ensure_provider_is_available_errors_when_user_declines(self):
+        self.mock_lxd_is_installed.return_value = False
+        provider = LXDProvider()
+
+        self.assertRaisesRegex(
+            CommandError,
+            re.escape(
+                "LXD is required, but not installed. Visit "
+                "https://snapcraft.io/lxd for instructions on how to install "
+                "the LXD snap for your distribution."
+            ),
+            provider.ensure_provider_is_available,
+        )
+
+        self.mock_ask_user.assert_called_once_with(
+            "LXD is required, but not installed. Do you wish to install LXD "
+            "and configure it with the defaults?",
+            default=False,
+        )
+
+    def test_ensure_provider_is_available_errors_when_lxd_install_fails(self):
+        error = LXDInstallationError("Boom")
+        self.mock_lxd_is_installed.return_value = False
+        self.mock_ask_user.return_value = True
+        self.mock_lxd_install.side_effect = error
+        provider = LXDProvider()
+
+        with self.assertRaisesRegex(
+            CommandError,
+            re.escape(
+                "Failed to install LXD. Visit https://snapcraft.io/lxd for "
+                "instructions on how to install the LXD snap for your "
+                "distribution."
+            ),
+        ) as raised:
+            provider.ensure_provider_is_available()
+
+        self.mock_ask_user.assert_called_once_with(
+            "LXD is required, but not installed. Do you wish to install LXD "
+            "and configure it with the defaults?",
+            default=False,
+        )
+        self.assertIs(error, raised.exception.__cause__)
+
+    def test_is_provider_available(self):
+        for is_installed in (True, False):
+            with self.subTest(is_installed=is_installed):
+                self.mock_lxd_is_installed.return_value = is_installed
+                provider = LXDProvider()
+
+                self.assertIs(is_installed, provider.is_provider_available())
+
+    def test_get_instance_name(self):
+        provider = LXDProvider()
+
+        self.assertEqual(
+            "lpcraft-my-project-12345-focal-amd64",
+            provider.get_instance_name(
+                project_name="my-project",
+                project_path=self.mock_path,
+                series="focal",
+                architecture="amd64",
+            ),
+        )
+
+    def test_get_command_environment_minimal(self):
+        self.useFixture(EnvironmentVariable("IGNORE", "sentinel"))
+        self.useFixture(EnvironmentVariable("PATH", "not-using-host-path"))
+        provider = LXDProvider()
+
+        env = provider.get_command_environment()
+
+        self.assertEqual(
+            {
+                "LPCRAFT_MANAGED_MODE": "1",
+                "PATH": _base_path,
+            },
+            env,
+        )
+
+    def test_get_command_environment_with_proxy(self):
+        self.useFixture(EnvironmentVariable("IGNORE", "sentinel"))
+        self.useFixture(EnvironmentVariable("PATH", "not-using-host-path"))
+        self.useFixture(EnvironmentVariable("http_proxy", "test-http-proxy"))
+        self.useFixture(EnvironmentVariable("https_proxy", "test-https-proxy"))
+        self.useFixture(EnvironmentVariable("no_proxy", "test-no-proxy"))
+        provider = LXDProvider()
+
+        env = provider.get_command_environment()
+
+        self.assertEqual(
+            {
+                "LPCRAFT_MANAGED_MODE": "1",
+                "PATH": _base_path,
+                "http_proxy": "test-http-proxy",
+                "https_proxy": "test-https-proxy",
+                "no_proxy": "test-no-proxy",
+            },
+            env,
+        )
+
+    def test_launched_environment(self):
+        expected_instance_name = "lpcraft-my-project-12345-focal-amd64"
+        provider = LXDProvider()
+
+        with provider.launched_environment(
+            project_name="my-project",
+            project_path=self.mock_path,
+            series="focal",
+            architecture="amd64",
+        ) as instance:
+            self.assertIsNotNone(instance)
+            self.mock_configure_buildd_image_remote.assert_called_once_with()
+            self.mock_buildd_base_configuration.assert_called_once_with(
+                alias=BuilddBaseAlias.FOCAL,
+                environment={"LPCRAFT_MANAGED_MODE": "1", "PATH": _base_path},
+                hostname=expected_instance_name,
+            )
+            self.assertEqual(
+                [
+                    call(
+                        name=expected_instance_name,
+                        base_configuration=(
+                            self.mock_buildd_base_configuration.return_value
+                        ),
+                        image_name="focal",
+                        image_remote="buildd-remote",
+                        auto_clean=True,
+                        auto_create_project=True,
+                        map_user_uid=True,
+                        use_snapshots=True,
+                        project="lpcraft",
+                        remote="local",
+                    ),
+                    call().mount(
+                        host_source=self.mock_path,
+                        target=Path("/root/project"),
+                    ),
+                ],
+                self.mock_lxd_launch.mock_calls,
+            )
+            self.mock_lxd_launch.reset_mock()
+
+        self.assertEqual(
+            [call().unmount_all(), call().stop()],
+            self.mock_lxd_launch.mock_calls,
+        )
+
+    def test_launched_environment_launch_base_configuration_error(self):
+        error = BaseConfigurationError(brief="Boom")
+        self.mock_lxd_launch.side_effect = error
+        provider = LXDProvider()
+
+        with self.assertRaisesRegex(CommandError, r"Boom") as raised:
+            with provider.launched_environment(
+                project_name="my-project",
+                project_path=self.mock_path,
+                series="focal",
+                architecture="amd64",
+            ):
+                pass
+
+        self.assertIs(error, raised.exception.__cause__)
+
+    def test_launched_environment_launch_lxd_error(self):
+        error = LXDError(brief="Boom")
+        self.mock_lxd_launch.side_effect = error
+        provider = LXDProvider()
+
+        with self.assertRaisesRegex(CommandError, r"Boom") as raised:
+            with provider.launched_environment(
+                project_name="my-project",
+                project_path=self.mock_path,
+                series="focal",
+                architecture="amd64",
+            ):
+                pass
+
+        self.assertIs(error, raised.exception.__cause__)
+
+    def test_launched_environment_unmounts_and_stops_after_error(self):
+        provider = LXDProvider()
+
+        with self.assertRaisesRegex(RuntimeError, r"Boom"):
+            with provider.launched_environment(
+                project_name="my-project",
+                project_path=self.mock_path,
+                series="focal",
+                architecture="amd64",
+            ):
+                self.mock_lxd_launch.reset_mock()
+                raise RuntimeError("Boom")
+
+        self.assertEqual(
+            [call().unmount_all(), call().stop()],
+            self.mock_lxd_launch.mock_calls,
+        )
+
+    def test_launched_environment_unmount_all_error(self):
+        error = LXDError(brief="Boom")
+        self.mock_lxd_launch.return_value.unmount_all.side_effect = error
+        provider = LXDProvider()
+
+        with self.assertRaisesRegex(CommandError, r"Boom") as raised:
+            with provider.launched_environment(
+                project_name="my-project",
+                project_path=self.mock_path,
+                series="focal",
+                architecture="amd64",
+            ):
+                pass
+
+        self.assertIs(error, raised.exception.__cause__)
+
+    def test_launched_environment_stop_error(self):
+        error = LXDError(brief="Boom")
+        self.mock_lxd_launch.return_value.stop.side_effect = error
+        provider = LXDProvider()
+
+        with self.assertRaisesRegex(CommandError, r"Boom") as raised:
+            with provider.launched_environment(
+                project_name="my-project",
+                project_path=self.mock_path,
+                series="focal",
+                architecture="amd64",
+            ):
+                pass
+
+        self.assertIs(error, raised.exception.__cause__)
diff --git a/lpcraft/tests/fixtures.py b/lpcraft/tests/fixtures.py
new file mode 100644
index 0000000..4780db2
--- /dev/null
+++ b/lpcraft/tests/fixtures.py
@@ -0,0 +1,26 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from fixtures import Fixture, MockPatch
+
+
+class EmitterFixture(Fixture):
+    def _setUp(self):
+        # Temporarily mock these until craft-cli grows additional testing
+        # support.
+        self.useFixture(MockPatch("craft_cli.emit.init"))
+        self.emit_message = self.useFixture(
+            MockPatch("craft_cli.emit.message")
+        ).mock
+        self.emit_progress = self.useFixture(
+            MockPatch("craft_cli.emit.progress")
+        ).mock
+        self.emit_trace = self.useFixture(
+            MockPatch("craft_cli.emit.trace")
+        ).mock
+        self.emit_error = self.useFixture(
+            MockPatch("craft_cli.emit.error")
+        ).mock
+        self.emit_ended_ok = self.useFixture(
+            MockPatch("craft_cli.emit.ended_ok")
+        ).mock
diff --git a/lpcraft/tests/test_env.py b/lpcraft/tests/test_env.py
new file mode 100644
index 0000000..ee72190
--- /dev/null
+++ b/lpcraft/tests/test_env.py
@@ -0,0 +1,33 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from pathlib import Path
+
+from fixtures import EnvironmentVariable
+from testtools import TestCase
+
+from lpcraft import env
+
+
+class TestEnvironment(TestCase):
+    def test_get_managed_environment_home_path(self):
+        self.assertEqual(
+            Path("/root"), env.get_managed_environment_home_path()
+        )
+
+    def test_get_managed_environment_project_path(self):
+        self.assertEqual(
+            Path("/root/project"), env.get_managed_environment_project_path()
+        )
+
+    def test_is_managed_mode(self):
+        for mode, expected in (
+            (None, False),
+            ("y", True),
+            ("n", False),
+            ("1", True),
+            ("0", False),
+        ):
+            with self.subTest(mode=mode):
+                with EnvironmentVariable("LPCRAFT_MANAGED_MODE", mode):
+                    self.assertIs(expected, env.is_managed_mode())
diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
new file mode 100644
index 0000000..f4f13b3
--- /dev/null
+++ b/lpcraft/tests/test_utils.py
@@ -0,0 +1,59 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+from fixtures import EnvironmentVariable, MockPatch
+from testtools import TestCase
+
+from lpcraft.utils import ask_user
+
+
+class TestAskUser(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.mock_isatty = self.useFixture(MockPatch("sys.stdin.isatty")).mock
+        self.mock_input = self.useFixture(
+            MockPatch("lpcraft.utils.input")
+        ).mock
+
+    def test_defaults_with_tty(self):
+        self.mock_isatty.return_value = True
+        self.mock_input.return_value = ""
+
+        self.assertIs(True, ask_user("prompt", default=True))
+        self.mock_input.assert_called_once_with("prompt [Y/n]: ")
+        self.mock_input.reset_mock()
+
+        self.assertIs(False, ask_user("prompt", default=False))
+        self.mock_input.assert_called_once_with("prompt [y/N]: ")
+
+    def test_defaults_without_tty(self):
+        self.mock_isatty.return_value = False
+
+        self.assertIs(True, ask_user("prompt", default=True))
+        self.assertIs(False, ask_user("prompt", default=False))
+
+        self.mock_input.assert_not_called()
+
+    def test_handles_input(self):
+        for user_input, expected in (
+            ("y", True),
+            ("Y", True),
+            ("yes", True),
+            ("YES", True),
+            ("n", False),
+            ("N", False),
+            ("no", False),
+            ("NO", False),
+        ):
+            with self.subTest(user_input=user_input):
+                self.mock_input.return_value = user_input
+
+                self.assertIs(expected, ask_user("prompt"))
+
+                self.mock_input.assert_called_once_with("prompt [y/N]: ")
+                self.mock_input.reset_mock()
+
+    def test_errors_in_managed_mode(self):
+        self.useFixture(EnvironmentVariable("LPCRAFT_MANAGED_MODE", "y"))
+
+        self.assertRaises(RuntimeError, ask_user, "prompt")
diff --git a/lpcraft/utils.py b/lpcraft/utils.py
new file mode 100644
index 0000000..4e027d3
--- /dev/null
+++ b/lpcraft/utils.py
@@ -0,0 +1,35 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    "ask_user",
+]
+
+import sys
+
+from lpcraft.env import is_managed_mode
+
+
+def ask_user(prompt, default=False) -> bool:
+    """Ask user for a yes/no answer.
+
+    If stdin is not a tty, or if the user returns an empty answer, return
+    the default value.
+
+    :return: True if answer starts with [yY], False if answer starts with
+        [nN], otherwise the default.
+    """
+    if is_managed_mode():
+        raise RuntimeError("confirmation not yet supported in managed mode")
+
+    if not sys.stdin.isatty():
+        return default
+
+    choices = " [Y/n]: " if default else " [y/N]: "
+    reply = str(input(prompt + choices)).lower().strip()
+    if reply:
+        if reply[0] == "y":
+            return True
+        elif reply[0] == "n":
+            return False
+    return default
diff --git a/requirements.in b/requirements.in
index 2009015..86c5a80 100644
--- a/requirements.in
+++ b/requirements.in
@@ -1 +1,2 @@
+craft-providers
 git+git://github.com/canonical/craft-cli.git@4af19f9c0da733321dc754be1180aea28f3feeb1
diff --git a/requirements.txt b/requirements.txt
index c0820ba..4fb931e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,11 +6,31 @@
 #
 appdirs==1.4.4
     # via craft-cli
+certifi==2021.10.8
+    # via requests
+charset-normalizer==2.0.7
+    # via requests
 craft-cli @ git+git://github.com/canonical/craft-cli.git@4af19f9c0da733321dc754be1180aea28f3feeb1
     # via -r requirements.in
+craft-providers==1.0.3
+    # via -r requirements.in
+idna==3.3
+    # via requests
 pydantic==1.8.2
-    # via craft-cli
+    # via
+    #   craft-cli
+    #   craft-providers
 pyyaml==6.0
-    # via craft-cli
+    # via
+    #   craft-cli
+    #   craft-providers
+requests==2.26.0
+    # via requests-unixsocket
+requests-unixsocket==0.2.0
+    # via craft-providers
 typing-extensions==3.10.0.2
     # via pydantic
+urllib3==1.26.7
+    # via
+    #   requests
+    #   requests-unixsocket
diff --git a/setup.cfg b/setup.cfg
index 7481980..b6b72b2 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -23,6 +23,7 @@ classifiers =
 packages = find:
 install_requires =
     craft-cli
+    craft-providers
 python_requires = >=3.8
 
 [options.entry_points]