← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/lpcraft:run-command into lpcraft:main

 

Colin Watson has proposed merging ~cjwatson/lpcraft:run-command into lpcraft:main.

Commit message:
Add a "run" command

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This is still pretty basic, but it's enough to be able to launch containers and run simple test jobs inside them.

I had to refactor the way emitter code is tested (based very loosely on similar test code in charmcraft), since it was difficult to handle the `emit.open_stream` context manager otherwise.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:run-command into lpcraft:main.
diff --git a/.mypy.ini b/.mypy.ini
index 8a57f11..da98765 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -2,12 +2,16 @@
 plugins = pydantic.mypy
 python_version = 3.8
 
+[mypy-lpcraft.errors]
+# Temporary until https://github.com/canonical/craft-cli/pull/35 lands.
+disallow_subclassing_any = false
+
 [mypy-*.tests.*]
 disallow_subclassing_any = false
 disallow_untyped_calls = false
 disallow_untyped_defs = false
 
-[mypy-craft_cli.*,fixtures.*,testtools.*]
+[mypy-craft_cli.*,fixtures.*,systemfixtures.*,testtools.*]
 ignore_missing_imports = true
 
 [mypy-craft_providers.*]
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
new file mode 100644
index 0000000..b3917cf
--- /dev/null
+++ b/lpcraft/commands/run.py
@@ -0,0 +1,103 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import subprocess
+from argparse import Namespace
+from pathlib import Path
+
+from craft_cli import EmitterMode, emit
+
+from lpcraft import env
+from lpcraft.config import Config, Job, load
+from lpcraft.errors import CommandError
+from lpcraft.providers import get_provider, replay_logs
+from lpcraft.utils import get_host_architecture
+
+
+def _get_job(config: Config, job_name: str) -> Job:
+    try:
+        return config.jobs[job_name]
+    except KeyError:
+        raise CommandError(f"No job definition for {job_name!r}")
+
+
+def _run_job(args: Namespace) -> None:
+    """Run a single job in a managed environment."""
+    if args.series is None:
+        raise CommandError("Series is required in managed mode")
+    if args.job_name is None:
+        raise CommandError("Job name is required in managed mode")
+
+    config = load(".launchpad.yaml")
+    job = _get_job(config, args.job_name)
+    if job.run is None:
+        raise CommandError(f"'run' not set for job {args.job_name!r}")
+    proc = subprocess.run(["bash", "--noprofile", "--norc", "-ec", job.run])
+    if proc.returncode != 0:
+        raise CommandError(
+            f"Job {args.job_name!r} failed with exit status "
+            f"{proc.returncode}.",
+            retcode=proc.returncode,
+        )
+
+
+def _run_pipeline(args: Namespace) -> None:
+    """Run a pipeline, launching managed environments as needed."""
+    config = load(".launchpad.yaml")
+    host_architecture = get_host_architecture()
+    cwd = Path.cwd()
+
+    provider = get_provider()
+    provider.ensure_provider_is_available()
+
+    for job_name in config.pipeline:
+        job = _get_job(config, job_name)
+        if host_architecture not in job.architectures:
+            raise CommandError(
+                f"Job {job_name!r} not defined for {host_architecture}"
+            )
+
+        cmd = ["lpcraft"]
+        if emit.get_mode() == EmitterMode.QUIET:
+            cmd.append("--quiet")
+        elif emit.get_mode() == EmitterMode.VERBOSE:
+            cmd.append("--verbose")
+        elif emit.get_mode() == EmitterMode.TRACE:
+            cmd.append("--trace")
+        cmd.extend(["run", "--series", job.series, job_name])
+
+        emit.progress(
+            f"Launching environment for {job.series}/{host_architecture}"
+        )
+        with provider.launched_environment(
+            project_name=cwd.name,
+            project_path=cwd,
+            series=job.series,
+            architecture=host_architecture,
+        ) as instance:
+            emit.progress("Running the job")
+            with emit.open_stream(f"Running {cmd}") as stream:
+                proc = instance.execute_run(
+                    cmd,
+                    cwd=env.get_managed_environment_project_path(),
+                    stdout=stream,
+                    stderr=stream,
+                )
+            if proc.returncode != 0:
+                replay_logs(instance)
+                raise CommandError(
+                    f"Job {job_name!r} for {job.series}/{host_architecture} "
+                    f"failed with exit status {proc.returncode}.",
+                    retcode=proc.returncode,
+                )
+
+
+def run(args: Namespace) -> int:
+    """Run a job."""
+    if env.is_managed_mode():
+        # XXX cjwatson 2021-11-09: Perhaps it would be simpler to split this
+        # into a separate internal command instead?
+        _run_job(args)
+    else:
+        _run_pipeline(args)
+    return 0
diff --git a/lpcraft/commands/tests/__init__.py b/lpcraft/commands/tests/__init__.py
index 726a63b..ac9287c 100644
--- a/lpcraft/commands/tests/__init__.py
+++ b/lpcraft/commands/tests/__init__.py
@@ -3,13 +3,13 @@
 
 from dataclasses import dataclass
 from typing import List
-from unittest.mock import patch
+from unittest.mock import ANY, call, patch
 
 from craft_cli import CraftError
 from testtools import TestCase
 
 from lpcraft.main import main
-from lpcraft.tests.fixtures import EmitterFixture
+from lpcraft.tests.fixtures import RecordingEmitterFixture
 
 
 @dataclass
@@ -24,16 +24,18 @@ class _CommandResult:
 class CommandBaseTestCase(TestCase):
     def run_command(self, *args, **kwargs):
         with patch("sys.argv", ["lpcraft"] + list(args)):
-            with EmitterFixture() as emitter:
+            with RecordingEmitterFixture() as emitter:
                 exit_code = main()
                 return _CommandResult(
                     exit_code,
                     [
-                        call.args[0]
-                        for call in emitter.emit_message.call_args_list
+                        c.args[1]
+                        for c in emitter.recorder.interactions
+                        if c == call("message", ANY)
                     ],
                     [
-                        call.args[0]
-                        for call in emitter.emit_error.call_args_list
+                        c.args[1]
+                        for c in emitter.recorder.interactions
+                        if c == call("error", ANY)
                     ],
                 )
diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
new file mode 100644
index 0000000..ae74b83
--- /dev/null
+++ b/lpcraft/commands/tests/test_run.py
@@ -0,0 +1,395 @@
+# Copyright 2021 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import os
+import subprocess
+from pathlib import Path
+from textwrap import dedent
+from typing import Optional
+from unittest.mock import ANY, Mock, call, patch
+
+from craft_providers.lxd import LXC, launch
+from fixtures import EnvironmentVariable, TempDir
+from systemfixtures import FakeProcesses
+from testtools.matchers import MatchesStructure
+
+from lpcraft.commands.tests import CommandBaseTestCase
+from lpcraft.errors import CommandError, YAMLError
+from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
+from lpcraft.providers.tests import FakeLXDInstaller
+
+
+class RunJobTestCase(CommandBaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.useFixture(EnvironmentVariable("LPCRAFT_MANAGED_MODE", "1"))
+        cwd = os.getcwd()
+        os.chdir(self.useFixture(TempDir()).path)
+        self.addCleanup(os.chdir, cwd)
+
+    def test_no_series(self):
+        result = self.run_command("run")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("Series is required in managed mode")],
+            ),
+        )
+
+    def test_no_job_name(self):
+        result = self.run_command("run", "--series", "focal")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("Job name is required in managed mode")],
+            ),
+        )
+
+    def test_missing_config_file(self):
+        result = self.run_command("run", "--series", "focal", "test")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    YAMLError("Couldn't find config file '.launchpad.yaml'")
+                ],
+            ),
+        )
+
+    def test_no_matching_job(self):
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: tox
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run", "--series", "focal", "build")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("No job definition for 'build'")],
+            ),
+        )
+
+    def test_no_run_definition(self):
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run", "--series", "focal", "test")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("'run' not set for job 'test'")],
+            ),
+        )
+
+    def test_run_fails(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(lambda _: {"returncode": 2}, name="bash")
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: |
+                        exit 2
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run", "--series", "focal", "test")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=2,
+                errors=[
+                    CommandError(
+                        "Job 'test' failed with exit status 2.", retcode=2
+                    )
+                ],
+            ),
+        )
+        self.assertEqual(
+            [["bash", "--noprofile", "--norc", "-ec", "exit 2\n"]],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
+
+    def test_run_succeeds(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(lambda _: {"returncode": 0}, name="bash")
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: |
+                        echo hello
+                        tox
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run", "--series", "focal", "test")
+
+        self.assertEqual(0, result.exit_code)
+        self.assertEqual(
+            [["bash", "--noprofile", "--norc", "-ec", "echo hello\ntox\n"]],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
+
+
+class RunPipelineTestCase(CommandBaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.useFixture(EnvironmentVariable("LPCRAFT_MANAGED_MODE", None))
+        tmp_project_dir = self.useFixture(TempDir()).join("test-project")
+        os.mkdir(tmp_project_dir)
+        cwd = os.getcwd()
+        os.chdir(tmp_project_dir)
+        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,
+        )
+
+    def test_missing_config_file(self):
+        result = self.run_command("run")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    YAMLError("Couldn't find config file '.launchpad.yaml'")
+                ],
+            ),
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_lxd_not_ready(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        mock_get_provider.return_value = self.makeLXDProvider(is_ready=False)
+        config = dedent(
+            """
+            pipeline: []
+            jobs: []
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("LXD is broken")],
+            ),
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_job_not_defined(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        mock_get_provider.return_value = self.makeLXDProvider()
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs: []
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("No job definition for 'test'")],
+            ),
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="i386")
+    def test_job_not_defined_for_host_architecture(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        mock_get_provider.return_value = self.makeLXDProvider()
+        config = dedent(
+            """
+            pipeline:
+                - test
+
+            jobs:
+                test:
+                    series: focal
+                    architectures: [amd64, arm64]
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[CommandError("Job 'test' not defined for i386")],
+            ),
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_one_job_fails(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = self.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")
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=2,
+                errors=[
+                    CommandError(
+                        "Job 'test' for focal/amd64 failed with exit status "
+                        "2.",
+                        retcode=2,
+                    )
+                ],
+            ),
+        )
+        execute_run.assert_called_once_with(
+            ["lpcraft", "run", "--series", "focal", "test"],
+            cwd=Path("/root/project"),
+            stdout=ANY,
+            stderr=ANY,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_all_jobs_succeed(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = self.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:
+                    series: focal
+                    architectures: amd64
+                    run: tox
+                build-wheel:
+                    series: bionic
+                    architectures: amd64
+                    run: pyproject-build
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run")
+
+        self.assertEqual(0, result.exit_code)
+        self.assertEqual(
+            [
+                call(
+                    ["lpcraft", "run", "--series", "focal", "test"],
+                    cwd=Path("/root/project"),
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    ["lpcraft", "run", "--series", "bionic", "build-wheel"],
+                    cwd=Path("/root/project"),
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
diff --git a/lpcraft/env.py b/lpcraft/env.py
index a6c1b3c..0e4503d 100644
--- a/lpcraft/env.py
+++ b/lpcraft/env.py
@@ -12,6 +12,11 @@ def get_managed_environment_home_path() -> Path:
     return Path("/root")
 
 
+def get_managed_environment_log_path() -> Path:
+    """Path for log file when running in managed environment."""
+    return Path("/tmp/lpcraft.log")
+
+
 def get_managed_environment_project_path() -> Path:
     """Path for project when running in managed environment."""
     return get_managed_environment_home_path() / "project"
diff --git a/lpcraft/errors.py b/lpcraft/errors.py
index c812ba6..9f289b4 100644
--- a/lpcraft/errors.py
+++ b/lpcraft/errors.py
@@ -8,12 +8,21 @@ __all__ = [
     "YAMLError",
 ]
 
+from typing import Any
 
-class CommandError(Exception):
+from craft_cli import CraftError
+
+
+class CommandError(CraftError):
     """Base exception for all error commands."""
 
-    def __init__(self, message: str):
-        super().__init__(message)
+    def __init__(self, message: str, retcode: int = 1):
+        super().__init__(message, retcode=retcode)
+
+    def __eq__(self, other: Any) -> bool:
+        if type(self) != type(other):
+            return NotImplemented
+        return str(self) == str(other) and self.retcode == other.retcode
 
 
 class YAMLError(CommandError):
diff --git a/lpcraft/main.py b/lpcraft/main.py
index 13c786f..7b88a89 100644
--- a/lpcraft/main.py
+++ b/lpcraft/main.py
@@ -4,13 +4,15 @@
 """Main entry point."""
 
 import logging
-import sys
 from argparse import ArgumentParser
 
 from craft_cli import CraftError, EmitterMode, emit
 
+from lpcraft import env
 from lpcraft._version import version_description as lpcraft_version
+from lpcraft.commands.run import run
 from lpcraft.commands.version import version
+from lpcraft.errors import CommandError
 
 
 def _configure_logger(name: str) -> None:
@@ -48,9 +50,27 @@ def main() -> int:
         action="store_true",
         help="Show debug information and be more verbose.",
     )
+    verbosity_group.add_argument(
+        "--trace",
+        action="store_true",
+        help="Show all information needed to trace internal behaviour.",
+    )
 
     subparsers = parser.add_subparsers()
 
+    # XXX cjwatson 2021-11-15: Subcommand arguments should be defined
+    # alongside the individual subcommands rather than here.
+
+    parser_run = subparsers.add_parser("run", help=run.__doc__)
+    if env.is_managed_mode():
+        parser_run.add_argument(
+            "--series", help="Only run jobs for this series."
+        )
+        parser_run.add_argument(
+            "job_name", nargs="?", help="Only run this job name."
+        )
+    parser_run.set_defaults(func=run)
+
     parser_version = subparsers.add_parser("version", help=version.__doc__)
     parser_version.set_defaults(func=version)
 
@@ -66,19 +86,19 @@ def main() -> int:
         emit.set_mode(EmitterMode.QUIET)
     elif args.verbose:
         emit.set_mode(EmitterMode.VERBOSE)
+    elif args.trace:
+        emit.set_mode(EmitterMode.TRACE)
 
     if args.version:
         emit.message(lpcraft_version)
         emit.ended_ok()
         return 0
 
-    if getattr(args, "func", None) is None:
-        parser.print_usage(file=sys.stderr)
-        emit.ended_ok()
-        return 1
-
     try:
-        ret = int(args.func(args))
+        ret = int(getattr(args, "func", run)(args))
+    except CommandError as e:
+        emit.error(e)
+        ret = e.retcode
     except KeyboardInterrupt as e:
         error = CraftError("Interrupted.")
         error.__cause__ = e
diff --git a/lpcraft/providers/__init__.py b/lpcraft/providers/__init__.py
index cdc1577..1c2fb2c 100644
--- a/lpcraft/providers/__init__.py
+++ b/lpcraft/providers/__init__.py
@@ -3,8 +3,16 @@
 
 __all__ = [
     "get_provider",
+    "replay_logs",
 ]
 
+import tempfile
+from pathlib import Path
+
+from craft_cli import emit
+from craft_providers import Executor
+
+from lpcraft.env import get_managed_environment_log_path
 from lpcraft.providers._base import Provider
 from lpcraft.providers._lxd import LXDProvider
 
@@ -12,3 +20,27 @@ from lpcraft.providers._lxd import LXDProvider
 def get_provider() -> Provider:
     """Get the configured or appropriate provider for the host OS."""
     return LXDProvider()
+
+
+def replay_logs(instance: Executor) -> None:
+    """Capture and re-emit log files from a provider instance."""
+    tmp = tempfile.NamedTemporaryFile(delete=False, prefix="lpcraft-")
+    tmp.close()
+    local_log_path = Path(tmp.name)
+    try:
+        remote_log_path = get_managed_environment_log_path()
+
+        try:
+            instance.pull_file(
+                source=remote_log_path, destination=local_log_path
+            )
+        except FileNotFoundError:
+            emit.trace("No logs found in instance.")
+            return
+
+        emit.trace("Logs captured from managed instance:")
+        with open(local_log_path) as local_log:
+            for line in local_log:
+                emit.trace(f":: {line.rstrip()}")
+    finally:
+        local_log_path.unlink()
diff --git a/lpcraft/providers/tests/__init__.py b/lpcraft/providers/tests/__init__.py
index 68bf050..e0dd083 100644
--- a/lpcraft/providers/tests/__init__.py
+++ b/lpcraft/providers/tests/__init__.py
@@ -1,6 +1,9 @@
 # Copyright 2021 Canonical Ltd.  This software is licensed under the
 # GNU General Public License version 3 (see the file LICENSE).
 
+from dataclasses import dataclass
+
+from craft_providers.lxd import LXDError, LXDInstallationError
 from fixtures import MockPatch
 from testtools import TestCase
 
@@ -15,3 +18,25 @@ class ProviderBaseTestCase(TestCase):
                 lambda *args, **kwargs: None,
             )
         )
+
+
+@dataclass
+class FakeLXDInstaller:
+    """A fake LXD installer implementation for tests."""
+
+    can_install: bool = True
+    already_installed: bool = True
+    is_ready: bool = True
+
+    def install(self) -> str:
+        if self.can_install:
+            return "4.0"
+        else:
+            raise LXDInstallationError("Cannot install LXD")
+
+    def is_installed(self) -> bool:
+        return self.already_installed
+
+    def ensure_lxd_is_ready(self) -> None:
+        if not self.is_ready:
+            raise LXDError("LXD is broken")
diff --git a/lpcraft/providers/tests/test_lxd.py b/lpcraft/providers/tests/test_lxd.py
index 6dc8061..b1fd347 100644
--- a/lpcraft/providers/tests/test_lxd.py
+++ b/lpcraft/providers/tests/test_lxd.py
@@ -3,53 +3,30 @@
 
 import os
 import re
-from dataclasses import dataclass
 from pathlib import Path
 from typing import Optional
 from unittest.mock import Mock, call, patch
 
 from craft_providers.bases import BaseConfigurationError, BuilddBaseAlias
-from craft_providers.lxd import LXC, LXDError, LXDInstallationError, launch
+from craft_providers.lxd import LXC, LXDError, launch
 
 from lpcraft.errors import CommandError
 from lpcraft.providers._buildd import LPCraftBuilddBaseConfiguration
 from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
-from lpcraft.providers.tests import ProviderBaseTestCase
-from lpcraft.tests.fixtures import EmitterFixture
+from lpcraft.providers.tests import FakeLXDInstaller, ProviderBaseTestCase
+from lpcraft.tests.fixtures import RecordingEmitterFixture
 
 _base_path = (
     "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin"
 )
 
 
-@dataclass
-class FakeLXDInstaller:
-    """A fake LXD installer implementation for tests."""
-
-    can_install: bool = True
-    already_installed: bool = True
-    is_ready: bool = True
-
-    def install(self) -> str:
-        if self.can_install:
-            return "4.0"
-        else:
-            raise LXDInstallationError("Cannot install LXD")
-
-    def is_installed(self) -> bool:
-        return self.already_installed
-
-    def ensure_lxd_is_ready(self) -> None:
-        if not self.is_ready:
-            raise LXDError("LXD is broken")
-
-
 class TestLXDProvider(ProviderBaseTestCase):
     def setUp(self):
         super().setUp()
         self.mock_path = Mock(spec=Path)
         self.mock_path.stat.return_value.st_ino = 12345
-        self.emitter = self.useFixture(EmitterFixture())
+        self.useFixture(RecordingEmitterFixture())
 
     def makeLXDProvider(
         self,
diff --git a/lpcraft/tests/fixtures.py b/lpcraft/tests/fixtures.py
index 4780db2..6aa783b 100644
--- a/lpcraft/tests/fixtures.py
+++ b/lpcraft/tests/fixtures.py
@@ -1,26 +1,49 @@
 # 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
+import os
+import tempfile
+from pathlib import Path
+from unittest.mock import call
 
+from craft_cli import messages
+from fixtures import Fixture, MockPatchObject
 
-class EmitterFixture(Fixture):
+
+class RecordingEmitter:
+    """Record what is shown using the emitter."""
+
+    def __init__(self):
+        self.interactions = []
+
+    def record(self, method_name, args, kwargs):
+        """Record the method call and its specific parameters."""
+        self.interactions.append(call(method_name, *args, **kwargs))
+
+
+class RecordingEmitterFixture(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
+        fd, filename = tempfile.mkstemp(prefix="emitter-logs")
+        os.close(fd)
+        self.addCleanup(os.unlink, filename)
+
+        messages.TESTMODE = True
+        messages.emit.init(
+            messages.EmitterMode.QUIET,
+            "test-emitter",
+            "Hello",
+            log_filepath=Path(filename),
+        )
+        self.addCleanup(messages.emit.ended_ok)
+
+        self.recorder = recorder = RecordingEmitter()
+        for method_name in ("message", "progress", "trace", "error"):
+            self.useFixture(
+                MockPatchObject(
+                    messages.emit,
+                    method_name,
+                    lambda *a, method_name=method_name, **kw: recorder.record(
+                        method_name, a, kw
+                    ),
+                )
+            )
diff --git a/lpcraft/tests/test_env.py b/lpcraft/tests/test_env.py
index a208c61..64847fb 100644
--- a/lpcraft/tests/test_env.py
+++ b/lpcraft/tests/test_env.py
@@ -16,6 +16,11 @@ class TestEnvironment(TestCase):
             Path("/root"), env.get_managed_environment_home_path()
         )
 
+    def test_get_managed_environment_log_path(self):
+        self.assertEqual(
+            Path("/tmp/lpcraft.log"), env.get_managed_environment_log_path()
+        )
+
     def test_get_managed_environment_project_path(self):
         self.assertEqual(
             Path("/root/project"), env.get_managed_environment_project_path()
diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
index c4b080f..5b28469 100644
--- a/lpcraft/tests/test_utils.py
+++ b/lpcraft/tests/test_utils.py
@@ -1,16 +1,18 @@
 # Copyright 2021 Canonical Ltd.  This software is licensed under the
 # GNU General Public License version 3 (see the file LICENSE).
 
+import io
 import os
 import re
 from pathlib import Path
 from unittest.mock import patch
 
 from fixtures import TempDir
+from systemfixtures import FakeProcesses
 from testtools import TestCase
 
 from lpcraft.errors import YAMLError
-from lpcraft.utils import ask_user, load_yaml
+from lpcraft.utils import ask_user, get_host_architecture, load_yaml
 
 
 class TestLoadYAML(TestCase):
@@ -66,6 +68,37 @@ class TestLoadYAML(TestCase):
         )
 
 
+class TestGetHostArchitecture(TestCase):
+    def setUp(self):
+        super().setUp()
+        self.addCleanup(get_host_architecture.cache_clear)
+
+    def test_returns_dpkg_architecture(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.StringIO("ppc64el\n")}, name="dpkg"
+        )
+
+        self.assertEqual("ppc64el", get_host_architecture())
+
+        self.assertEqual(
+            [["dpkg", "--print-architecture"]],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
+
+    def test_caches(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.StringIO("amd64\n")}, name="dpkg"
+        )
+
+        self.assertEqual("amd64", get_host_architecture())
+        self.assertEqual(1, len(processes_fixture.procs))
+
+        self.assertEqual("amd64", get_host_architecture())
+        self.assertEqual(1, len(processes_fixture.procs))
+
+
 class TestAskUser(TestCase):
     @patch("lpcraft.utils.input")
     @patch("sys.stdin.isatty")
diff --git a/lpcraft/utils.py b/lpcraft/utils.py
index c1a73bd..22c866c 100644
--- a/lpcraft/utils.py
+++ b/lpcraft/utils.py
@@ -3,10 +3,13 @@
 
 __all__ = [
     "ask_user",
+    "get_host_architecture",
     "load_yaml",
 ]
 
+import subprocess
 import sys
+from functools import lru_cache
 from pathlib import Path
 from typing import Any, Dict
 
@@ -32,6 +35,19 @@ def load_yaml(path: Path) -> Dict[Any, Any]:
         raise YAMLError(f"Failed to read/parse config file {str(path)!r}: {e}")
 
 
+@lru_cache
+def get_host_architecture() -> str:
+    """Get the host architecture name, using dpkg's vocabulary."""
+    # We may need a more complex implementation at some point in order to
+    # run in non-dpkg-based environments.
+    return subprocess.run(
+        ["dpkg", "--print-architecture"],
+        capture_output=True,
+        check=True,
+        universal_newlines=True,
+    ).stdout.rstrip("\n")
+
+
 def ask_user(prompt: str, default: bool = False) -> bool:
     """Ask user for a yes/no answer.
 
diff --git a/setup.cfg b/setup.cfg
index 22e50d0..621e8f3 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -37,6 +37,7 @@ test =
     coverage
     fixtures
     pytest
+    systemfixtures
     testtools
 
 [isort]