launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27669
[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]