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