launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #27776
[Merge] ~cjwatson/lpcraft:output into lpcraft:main
Colin Watson has proposed merging ~cjwatson/lpcraft:output into lpcraft:main.
Commit message:
Handle job output paths and properties
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/lpcraft/+git/lpcraft/+merge/412438
The main complexity here is in ensuring that paths extracted from completed jobs can't escape the build tree, either directly or via symlinks.
I added a `LocalExecuteRun` gadget because we now need to do some work in the container via carefully-crafted `find` and `readlink` subprocesses, and mocking those meant that we weren't really testing the interesting bits. Now we at least do real subprocess execution in those tests, albeit without a container.
`distribute`, `channels`, and `expires` will be implemented on the Launchpad side.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:output into lpcraft:main.
diff --git a/README.rst b/README.rst
index b28f79c..ad7a4da 100644
--- a/README.rst
+++ b/README.rst
@@ -15,13 +15,10 @@ the CLI design is based on both those tools.
Running
=======
-``lpcraft`` is mainly intended to be consumed as a snap, and it currently
-needs to be a snap in order to be able to inject itself into the containers
-it starts (though this may be made more flexible in future). Use
-``snapcraft`` to build the snap, which you can then install using ``snap
-install --classic --dangerous lpcraft_<version>_<architecture>.snap``.
-(Once ``lpcraft`` is more complete and stable, it will be made available
-from the snap store.)
+``lpcraft`` is mainly intended to be consumed as a snap. Use ``snapcraft``
+to build the snap, which you can then install using ``snap install --classic
+--dangerous lpcraft_<version>_<architecture>.snap``. (Once ``lpcraft`` is
+more complete and stable, it will be made available from the snap store.)
You can run ``lpcraft`` from a directory containing ``.launchpad.yaml``,
although it won't do very much useful yet.
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
index e5a5fcc..d8b8a18 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -1,62 +1,166 @@
# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
-import subprocess
+import fnmatch
+import io
+import json
+import os
from argparse import Namespace
-from pathlib import Path
-from typing import List, Optional
+from pathlib import Path, PurePath
+from typing import List, Set
-from craft_cli import EmitterMode, emit
+from craft_cli import emit
+from craft_providers import Executor
+from dotenv import dotenv_values
from lpcraft import env
-from lpcraft.config import Config, Job
+from lpcraft.config import Config, Output
from lpcraft.errors import CommandError
-from lpcraft.providers import get_provider, replay_logs
+from lpcraft.providers import get_provider
from lpcraft.utils import get_host_architecture
-def _get_jobs(
- config: Config, job_name: str, series: Optional[str] = None
-) -> List[Job]:
- jobs = config.jobs.get(job_name, [])
- if not jobs:
- raise CommandError(f"No job definition for {job_name!r}")
- if series is not None:
- jobs = [job for job in jobs if job.series == series]
- if not jobs:
- raise CommandError(
- f"No job definition for {job_name!r} for {series}"
- )
- return jobs
-
+def _check_path_escape(path: PurePath, container: PurePath) -> PurePath:
+ """Check that `path` does not escape `container`.
+
+ Any symlinks in `path` must already have been resolved within the
+ context of the container.
+
+ :raises CommandError: if `path` is outside `container` when fully
+ resolved.
+ :return: A version of `path` relative to `container`.
+ """
+ try:
+ return path.relative_to(container)
+ except ValueError as e:
+ raise CommandError(str(e), retcode=1)
+
+
+def _find_in_instance(instance: Executor, path: Path) -> List[PurePath]:
+ """Find entries in `path` on `instance`.
+
+ :param instance: Provider instance to search.
+ :param path: Path to directory to search.
+ :return: List of non-directory paths found, relative to `path`.
+ """
+ cmd = ["find", str(path), "-mindepth", "1"]
+ # Exclude directories.
+ cmd.extend(["!", "-type", "d"])
+ # Produce unambiguous output: file name relative to the starting path,
+ # terminated by NUL.
+ cmd.extend(["-printf", "%P\\0"])
+ paths = (
+ instance.execute_run(cmd, capture_output=True, check=True)
+ .stdout.rstrip(b"\0")
+ .split(b"\0")
+ )
+ return [PurePath(os.fsdecode(p)) for p in paths]
+
+
+def _resolve_symlinks(
+ instance: Executor, paths: List[PurePath]
+) -> List[PurePath]:
+ """Resolve symlinks in each of `paths` on `instance`.
+
+ Similar to `Path.resolve`, but doesn't require a Python process on
+ `instance`.
+
+ :param instance: Provider instance to inspect.
+ :param paths: Paths to dereference.
+ :return: Dereferenced version of each of the input paths.
+ """
+ paths = (
+ instance.execute_run(
+ ["readlink", "-f", "-z", "--"] + [str(path) for path in paths],
+ capture_output=True,
+ check=True,
+ )
+ .stdout.rstrip(b"\0")
+ .split(b"\0")
+ )
+ return [PurePath(os.fsdecode(p)) for p in paths]
+
+
+def _copy_output_paths(
+ output: Output, remote_cwd: Path, instance: Executor, output_path: Path
+) -> None:
+ """Copy designated output paths from a completed job."""
+ if output.paths is None:
+ return
+
+ for path_pattern in output.paths:
+ # We'll also check individual glob expansions, but checking the
+ # pattern as a whole first produces clearer error messages. We have
+ # to use os.path for this, as pathlib doesn't expose any equivalent
+ # of normpath.
+ _check_path_escape(
+ PurePath(os.path.normpath(remote_cwd / path_pattern)),
+ remote_cwd,
+ )
-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")
+ remote_paths = sorted(_find_in_instance(instance, remote_cwd))
+ output_files = output_path / "files"
- config = Config.load(Path(".launchpad.yaml"))
- jobs = _get_jobs(config, args.job_name, series=args.series)
- if len(jobs) > 1:
- raise CommandError(
- f"Ambiguous job definitions for {args.job_name!r} for "
- f"{args.series}"
+ filtered_paths: Set[PurePath] = set()
+ for path_pattern in output.paths:
+ filtered_paths.update(
+ PurePath(name)
+ for name in fnmatch.filter(
+ [str(path) for path in remote_paths],
+ path_pattern,
+ )
)
- [job] = jobs
- 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,
+ resolved_paths = _resolve_symlinks(
+ instance,
+ [remote_cwd / path for path in sorted(filtered_paths)],
+ )
+
+ for path in sorted(resolved_paths):
+ relative_path = _check_path_escape(path, remote_cwd)
+ destination = output_files / relative_path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ # Path() here works around
+ # https://github.com/canonical/craft-providers/pull/83.
+ instance.pull_file(source=Path(path), destination=destination)
+ except Exception as e:
+ raise CommandError(str(e), retcode=1)
+
+
+def _copy_output_properties(
+ output: Output, remote_cwd: Path, instance: Executor, output_path: Path
+) -> None:
+ """Copy designated output properties from a completed job."""
+ properties = dict(output.properties or {})
+
+ if output.dynamic_properties:
+ [path] = _resolve_symlinks(
+ instance,
+ [remote_cwd / output.dynamic_properties],
+ )
+ _check_path_escape(path, remote_cwd)
+ dynamic_properties = dotenv_values(
+ stream=io.StringIO(
+ instance.execute_run(
+ ["cat", str(path)],
+ capture_output=True,
+ text=True,
+ ).stdout
+ )
)
+ properties.update(
+ {
+ key: value
+ for key, value in dynamic_properties.items()
+ if value is not None
+ }
+ )
+
+ with open(output_path / "properties", "w") as f:
+ json.dump(properties, f)
-def _run_pipeline(args: Namespace) -> None:
+def run(args: Namespace) -> int:
"""Run a pipeline, launching managed environments as needed."""
config = Config.load(Path(".launchpad.yaml"))
host_architecture = get_host_architecture()
@@ -66,21 +170,20 @@ def _run_pipeline(args: Namespace) -> None:
provider.ensure_provider_is_available()
for job_name in config.pipeline:
- jobs = _get_jobs(config, job_name)
+ jobs = config.jobs.get(job_name, [])
+ if not jobs:
+ raise CommandError(f"No job definition for {job_name!r}")
for job in jobs:
if host_architecture not in job.architectures:
continue
+ if job.run is None:
+ raise CommandError(
+ f"Job {job_name!r} for {job.series}/{host_architecture} "
+ f"does not set 'run'"
+ )
- cmd = ["lpcraft"]
- # XXX jugmac00 2021-11-25: coverage ignored for now
- # but should be tested in future
- if emit.get_mode() == EmitterMode.QUIET:
- cmd.append("--quiet") # pragma: no cover
- elif emit.get_mode() == EmitterMode.VERBOSE:
- cmd.append("--verbose") # pragma: no cover
- elif emit.get_mode() == EmitterMode.TRACE:
- cmd.append("--trace") # pragma: no cover
- cmd.extend(["run", "--series", job.series, job_name])
+ cmd = ["bash", "--noprofile", "--norc", "-ec", job.run]
+ remote_cwd = env.get_managed_environment_project_path()
emit.progress(
f"Launching environment for {job.series}/{host_architecture}"
@@ -95,12 +198,11 @@ def _run_pipeline(args: Namespace) -> None:
with emit.open_stream(f"Running {cmd}") as stream:
proc = instance.execute_run(
cmd,
- cwd=env.get_managed_environment_project_path(),
+ cwd=remote_cwd,
stdout=stream,
stderr=stream,
)
if proc.returncode != 0:
- replay_logs(instance)
raise CommandError(
f"Job {job_name!r} for "
f"{job.series}/{host_architecture} failed with "
@@ -108,13 +210,16 @@ def _run_pipeline(args: Namespace) -> None:
retcode=proc.returncode,
)
+ if job.output is not None and args.output is not None:
+ output_path = (
+ args.output / job_name / job.series / host_architecture
+ )
+ output_path.mkdir(parents=True, exist_ok=True)
+ _copy_output_paths(
+ job.output, remote_cwd, instance, output_path
+ )
+ _copy_output_properties(
+ job.output, remote_cwd, instance, output_path
+ )
-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/test_run.py b/lpcraft/commands/tests/test_run.py
index b31b75c..3f35b75 100644
--- a/lpcraft/commands/tests/test_run.py
+++ b/lpcraft/commands/tests/test_run.py
@@ -1,17 +1,23 @@
# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
+import json
import os
import subprocess
from pathlib import Path
from textwrap import dedent
-from typing import Optional
+from typing import Any, AnyStr, Dict, List, 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 fixtures import TempDir
+from testtools.matchers import (
+ AfterPreprocessing,
+ Contains,
+ Equals,
+ MatchesListwise,
+ MatchesStructure,
+)
from lpcraft.commands.tests import CommandBaseTestCase
from lpcraft.errors import CommandError, YAMLError
@@ -19,228 +25,51 @@ 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_matching_job_for_series(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", "bionic", "test")
-
- self.assertThat(
- result,
- MatchesStructure.byEquality(
- exit_code=1,
- errors=[
- CommandError("No job definition for 'test' for bionic")
- ],
- ),
- )
-
- def test_ambiguous_job_definitions(self):
- config = dedent(
- """
- pipeline:
- - test
-
- jobs:
- test:
- matrix:
- - run: make
- - run: tox
- 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(
- "Ambiguous job definitions for 'test' for focal"
- )
- ],
- ),
- )
-
- 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")
+class LocalExecuteRun:
+ """A fake LXDInstance.execute_run that runs subprocesses locally.
- 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],
- )
+ This allows us to set up a temporary directory with the expected
+ contents and then run processes in it more or less normally. Don't run
+ complicated build commands using this, but ordinary system utilities are
+ fine.
+ """
- def test_run_succeeds(self):
- processes_fixture = self.useFixture(FakeProcesses())
- processes_fixture.add(lambda _: {"returncode": 0}, name="bash")
- config = dedent(
- """
- pipeline:
- - test
+ def __init__(self, override_cwd: Path):
+ super().__init__()
+ self.override_cwd = override_cwd
+ self.call_args_list: List[Any] = []
- 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 __call__(
+ self,
+ command: List[str],
+ *,
+ cwd: Optional[Path] = None,
+ env: Optional[Dict[str, Optional[str]]] = None,
+ **kwargs: Any
+ ) -> "subprocess.CompletedProcess[AnyStr]":
+ run_kwargs = kwargs.copy()
+ run_kwargs["cwd"] = self.override_cwd
+ if env is not None: # pragma: no cover
+ full_env = os.environ.copy()
+ for key, value in env.items():
+ if value is None:
+ full_env.pop(key, None)
+ else:
+ full_env[key] = value
+ run_kwargs["env"] = full_env
+ self.call_args_list.append(call(command, **run_kwargs))
+ return subprocess.run(command, **run_kwargs)
+
+
+class TestRun(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.tmp_project_path = Path(
+ self.useFixture(TempDir()).join("test-project")
+ )
+ self.tmp_project_path.mkdir()
+ cwd = Path.cwd()
+ os.chdir(self.tmp_project_path)
self.addCleanup(os.chdir, cwd)
def makeLXDProvider(
@@ -358,9 +187,13 @@ class RunPipelineTestCase(CommandBaseTestCase):
self.assertEqual(0, result.exit_code)
self.assertEqual(
+ ["focal"],
+ [c.kwargs["image_name"] for c in launcher.call_args_list],
+ )
+ self.assertEqual(
[
call(
- ["lpcraft", "run", "--series", "focal", "test"],
+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
cwd=Path("/root/project"),
stdout=ANY,
stderr=ANY,
@@ -371,6 +204,39 @@ class RunPipelineTestCase(CommandBaseTestCase):
@patch("lpcraft.commands.run.get_provider")
@patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_no_run_definition(
+ 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
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+
+ result = self.run_command("run")
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1,
+ errors=[
+ CommandError(
+ "Job 'test' for focal/amd64 does not set 'run'"
+ )
+ ],
+ ),
+ )
+
+ @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
):
@@ -414,7 +280,7 @@ class RunPipelineTestCase(CommandBaseTestCase):
),
)
execute_run.assert_called_once_with(
- ["lpcraft", "run", "--series", "focal", "test"],
+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
cwd=Path("/root/project"),
stdout=ANY,
stderr=ANY,
@@ -453,15 +319,25 @@ class RunPipelineTestCase(CommandBaseTestCase):
self.assertEqual(0, result.exit_code)
self.assertEqual(
+ ["focal", "bionic"],
+ [c.kwargs["image_name"] for c in launcher.call_args_list],
+ )
+ self.assertEqual(
[
call(
- ["lpcraft", "run", "--series", "focal", "test"],
+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
cwd=Path("/root/project"),
stdout=ANY,
stderr=ANY,
),
call(
- ["lpcraft", "run", "--series", "bionic", "build-wheel"],
+ [
+ "bash",
+ "--noprofile",
+ "--norc",
+ "-ec",
+ "pyproject-build",
+ ],
cwd=Path("/root/project"),
stdout=ANY,
stderr=ANY,
@@ -506,21 +382,31 @@ class RunPipelineTestCase(CommandBaseTestCase):
self.assertEqual(0, result.exit_code)
self.assertEqual(
+ ["bionic", "focal", "bionic"],
+ [c.kwargs["image_name"] for c in launcher.call_args_list],
+ )
+ self.assertEqual(
[
call(
- ["lpcraft", "run", "--series", "bionic", "test"],
+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
cwd=Path("/root/project"),
stdout=ANY,
stderr=ANY,
),
call(
- ["lpcraft", "run", "--series", "focal", "test"],
+ ["bash", "--noprofile", "--norc", "-ec", "tox"],
cwd=Path("/root/project"),
stdout=ANY,
stderr=ANY,
),
call(
- ["lpcraft", "run", "--series", "bionic", "build-wheel"],
+ [
+ "bash",
+ "--noprofile",
+ "--norc",
+ "-ec",
+ "pyproject-build",
+ ],
cwd=Path("/root/project"),
stdout=ANY,
stderr=ANY,
@@ -528,3 +414,558 @@ class RunPipelineTestCase(CommandBaseTestCase):
],
execute_run.call_args_list,
)
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_copies_output_paths(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ def fake_pull_file(source: Path, destination: Path) -> None:
+ destination.touch()
+
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ launcher.return_value.pull_file.side_effect = fake_pull_file
+ config = dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ paths: ["*.tar.gz", "*.whl"]
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+ Path("test_1.0.tar.gz").write_bytes(b"")
+ Path("test_1.0.whl").write_bytes(b"")
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ self.assertEqual(0, result.exit_code)
+ self.assertEqual(
+ [
+ call(
+ ["bash", "--noprofile", "--norc", "-ec", "true\n"],
+ cwd=self.tmp_project_path,
+ stdout=ANY,
+ stderr=ANY,
+ ),
+ call(
+ [
+ "find",
+ str(self.tmp_project_path),
+ "-mindepth",
+ "1",
+ "!",
+ "-type",
+ "d",
+ "-printf",
+ "%P\\0",
+ ],
+ cwd=ANY,
+ capture_output=True,
+ check=True,
+ ),
+ call(
+ [
+ "readlink",
+ "-f",
+ "-z",
+ "--",
+ str(self.tmp_project_path / "test_1.0.tar.gz"),
+ str(self.tmp_project_path / "test_1.0.whl"),
+ ],
+ cwd=ANY,
+ capture_output=True,
+ check=True,
+ ),
+ ],
+ execute_run.call_args_list,
+ )
+ local_output = output_path / "build" / "focal" / "amd64"
+ self.assertEqual(
+ [
+ call(
+ source=self.tmp_project_path / "test_1.0.tar.gz",
+ destination=local_output / "files" / "test_1.0.tar.gz",
+ ),
+ call(
+ source=self.tmp_project_path / "test_1.0.whl",
+ destination=local_output / "files" / "test_1.0.whl",
+ ),
+ ],
+ launcher.return_value.pull_file.call_args_list,
+ )
+ self.assertEqual(
+ ["files", "properties"],
+ sorted(path.name for path in local_output.iterdir()),
+ )
+ self.assertEqual(
+ ["test_1.0.tar.gz", "test_1.0.whl"],
+ sorted(path.name for path in (local_output / "files").iterdir()),
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_output_path_escapes_directly(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ paths: ["../../etc/shadow"]
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ # The exact error message differs between Python 3.8 and 3.9, so
+ # don't test it in detail, but make sure it includes the offending
+ # path.
+ self.assertThat(
+ result,
+ MatchesStructure(
+ exit_code=Equals(1),
+ errors=MatchesListwise(
+ [AfterPreprocessing(str, Contains("/etc/shadow"))]
+ ),
+ ),
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_output_path_escapes_symlink(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ paths: ["*.txt"]
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+ Path("symlink.txt").symlink_to("../target.txt")
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ # The exact error message differs between Python 3.8 and 3.9, so
+ # don't test it in detail, but make sure it includes the offending
+ # path.
+ self.assertThat(
+ result,
+ MatchesStructure(
+ exit_code=Equals(1),
+ errors=MatchesListwise(
+ [AfterPreprocessing(str, Contains("/target.txt"))]
+ ),
+ ),
+ )
+ self.assertEqual(
+ [
+ call(
+ ["bash", "--noprofile", "--norc", "-ec", "true\n"],
+ cwd=self.tmp_project_path,
+ stdout=ANY,
+ stderr=ANY,
+ ),
+ call(
+ [
+ "find",
+ str(self.tmp_project_path),
+ "-mindepth",
+ "1",
+ "!",
+ "-type",
+ "d",
+ "-printf",
+ "%P\\0",
+ ],
+ cwd=ANY,
+ capture_output=True,
+ check=True,
+ ),
+ call(
+ [
+ "readlink",
+ "-f",
+ "-z",
+ "--",
+ str(self.tmp_project_path / "symlink.txt"),
+ ],
+ cwd=ANY,
+ capture_output=True,
+ check=True,
+ ),
+ ],
+ execute_run.call_args_list,
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_output_path_pull_file_fails(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ launcher.return_value.pull_file.side_effect = FileNotFoundError(
+ "File not found"
+ )
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ paths: ["*.whl"]
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+ Path("test_1.0.whl").write_bytes(b"")
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ self.assertThat(
+ result,
+ MatchesStructure.byEquality(
+ exit_code=1, errors=[CommandError("File not found", retcode=1)]
+ ),
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_reads_properties(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ properties:
+ foo: bar
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ self.assertEqual(0, result.exit_code)
+ self.assertEqual(
+ [
+ call(
+ ["bash", "--noprofile", "--norc", "-ec", "true\n"],
+ cwd=self.tmp_project_path,
+ stdout=ANY,
+ stderr=ANY,
+ ),
+ ],
+ execute_run.call_args_list,
+ )
+ local_output = output_path / "build" / "focal" / "amd64"
+ self.assertEqual(
+ {"foo": "bar"},
+ json.loads((local_output / "properties").read_text()),
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_reads_dynamic_properties(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - test
+
+ jobs:
+ test:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ dynamic-properties: properties
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+ Path("properties").write_text("version=0.1\n")
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ self.assertEqual(0, result.exit_code)
+ self.assertEqual(
+ [
+ call(
+ ["bash", "--noprofile", "--norc", "-ec", "true\n"],
+ cwd=self.tmp_project_path,
+ stdout=ANY,
+ stderr=ANY,
+ ),
+ call(
+ [
+ "readlink",
+ "-f",
+ "-z",
+ "--",
+ str(self.tmp_project_path / "properties"),
+ ],
+ cwd=ANY,
+ capture_output=True,
+ check=True,
+ ),
+ call(
+ ["cat", str(self.tmp_project_path / "properties")],
+ cwd=ANY,
+ capture_output=True,
+ text=True,
+ ),
+ ],
+ execute_run.call_args_list,
+ )
+ local_output = output_path / "test" / "focal" / "amd64"
+ self.assertEqual(
+ {"version": "0.1"},
+ json.loads((local_output / "properties").read_text()),
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_dynamic_properties_override_properties(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - test
+
+ jobs:
+ test:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ properties:
+ version: "0.1"
+ dynamic-properties: properties
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+ Path("properties").write_text("version=0.2\n")
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ self.assertEqual(0, result.exit_code)
+ local_output = output_path / "test" / "focal" / "amd64"
+ self.assertEqual(
+ {"version": "0.2"},
+ json.loads((local_output / "properties").read_text()),
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_run_dynamic_properties_escapes_directly(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - test
+
+ jobs:
+ test:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ dynamic-properties: ../properties
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ # The exact error message differs between Python 3.8 and 3.9, so
+ # don't test it in detail, but make sure it includes the offending
+ # path.
+ self.assertThat(
+ result,
+ MatchesStructure(
+ exit_code=Equals(1),
+ errors=MatchesListwise(
+ [AfterPreprocessing(str, Contains("/properties"))]
+ ),
+ ),
+ )
+
+ @patch("lpcraft.env.get_managed_environment_project_path")
+ @patch("lpcraft.commands.run.get_provider")
+ @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+ def test_run_dynamic_properties_escapes_symlink(
+ self,
+ mock_get_host_architecture,
+ mock_get_provider,
+ mock_get_project_path,
+ ):
+ output_path = Path(self.useFixture(TempDir()).path)
+ launcher = Mock(spec=launch)
+ provider = self.makeLXDProvider(lxd_launcher=launcher)
+ mock_get_provider.return_value = provider
+ execute_run = LocalExecuteRun(self.tmp_project_path)
+ launcher.return_value.execute_run = execute_run
+ mock_get_project_path.return_value = self.tmp_project_path
+ config = dedent(
+ """
+ pipeline:
+ - test
+
+ jobs:
+ test:
+ series: focal
+ architectures: amd64
+ run: |
+ true
+ output:
+ dynamic-properties: properties
+ """
+ )
+ Path(".launchpad.yaml").write_text(config)
+ Path("properties").symlink_to("../target")
+
+ result = self.run_command("run", "--output", str(output_path))
+
+ # The exact error message differs between Python 3.8 and 3.9, so
+ # don't test it in detail, but make sure it includes the offending
+ # path.
+ self.assertThat(
+ result,
+ MatchesStructure(
+ exit_code=Equals(1),
+ errors=MatchesListwise(
+ [AfterPreprocessing(str, Contains("/target"))]
+ ),
+ ),
+ )
diff --git a/lpcraft/config.py b/lpcraft/config.py
index 0a6d0af..780398f 100644
--- a/lpcraft/config.py
+++ b/lpcraft/config.py
@@ -1,6 +1,8 @@
# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
+from datetime import timedelta
+from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
@@ -20,12 +22,36 @@ class ModelConfigDefaults(
"""Define lpcraft's model defaults."""
+class OutputDistributeEnum(Enum):
+ """Valid values for `output.distribute.`"""
+
+ artifactory = "artifactory"
+
+
+class Output(ModelConfigDefaults):
+ """Job output properties."""
+
+ paths: Optional[List[StrictStr]]
+ distribute: Optional[OutputDistributeEnum]
+ channels: Optional[List[StrictStr]]
+ properties: Optional[Dict[StrictStr, StrictStr]]
+ dynamic_properties: Optional[Path]
+ expires: Optional[timedelta]
+
+ @pydantic.validator("expires")
+ def validate_expires(cls, v: timedelta) -> timedelta:
+ if v < timedelta(0):
+ raise ValueError("non-negative duration expected")
+ return v
+
+
class Job(ModelConfigDefaults):
"""A job definition."""
series: StrictStr
architectures: List[StrictStr]
run: Optional[StrictStr]
+ output: Optional[Output]
@pydantic.validator("architectures", pre=True)
def validate_architectures(
diff --git a/lpcraft/env.py b/lpcraft/env.py
index 0e4503d..2e9a0be 100644
--- a/lpcraft/env.py
+++ b/lpcraft/env.py
@@ -3,7 +3,6 @@
"""lpcraft environment utilities."""
-import os
from pathlib import Path
@@ -12,15 +11,6 @@ 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"
-
-
-def is_managed_mode() -> bool:
- return os.environ.get("LPCRAFT_MANAGED_MODE", "0") == "1"
diff --git a/lpcraft/main.py b/lpcraft/main.py
index 7b88a89..124be7e 100644
--- a/lpcraft/main.py
+++ b/lpcraft/main.py
@@ -5,10 +5,10 @@
import logging
from argparse import ArgumentParser
+from pathlib import Path
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
@@ -62,13 +62,10 @@ def main() -> int:
# 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.add_argument(
+ "--output", type=Path, help="Write output files to this directory."
+ )
+
parser_run.set_defaults(func=run)
parser_version = subparsers.add_parser("version", help=version.__doc__)
diff --git a/lpcraft/providers/__init__.py b/lpcraft/providers/__init__.py
index 1c2fb2c..cdc1577 100644
--- a/lpcraft/providers/__init__.py
+++ b/lpcraft/providers/__init__.py
@@ -3,16 +3,8 @@
__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
@@ -20,27 +12,3 @@ 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/_base.py b/lpcraft/providers/_base.py
index 398246c..559143b 100644
--- a/lpcraft/providers/_base.py
+++ b/lpcraft/providers/_base.py
@@ -68,7 +68,6 @@ class Provider(ABC):
def get_command_environment(self) -> Dict[str, Optional[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"):
diff --git a/lpcraft/providers/_buildd.py b/lpcraft/providers/_buildd.py
index 9acbc3a..f5b93b6 100644
--- a/lpcraft/providers/_buildd.py
+++ b/lpcraft/providers/_buildd.py
@@ -8,10 +8,9 @@ __all__ = [
"SERIES_TO_BUILDD_IMAGE_ALIAS",
]
-from typing import Any, Optional
+from typing import Any
-from craft_providers import Executor, bases
-from craft_providers.actions import snap_installer
+from craft_providers import bases
# Why can't we just pass a series name and be done with it?
SERIES_TO_BUILDD_IMAGE_ALIAS = {
@@ -34,51 +33,6 @@ class LPCraftBuilddBaseConfiguration(bases.BuilddBase):
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)
-
def __eq__(self, other: Any) -> bool:
if not isinstance(other, LPCraftBuilddBaseConfiguration):
raise TypeError
diff --git a/lpcraft/providers/tests/__init__.py b/lpcraft/providers/tests/__init__.py
index f9e3072..c568294 100644
--- a/lpcraft/providers/tests/__init__.py
+++ b/lpcraft/providers/tests/__init__.py
@@ -4,20 +4,6 @@
from dataclasses import dataclass
from craft_providers.lxd import LXDError, LXDInstallationError
-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,
- )
- )
@dataclass
diff --git a/lpcraft/providers/tests/test_buildd.py b/lpcraft/providers/tests/test_buildd.py
index 03898c8..e1226e8 100644
--- a/lpcraft/providers/tests/test_buildd.py
+++ b/lpcraft/providers/tests/test_buildd.py
@@ -1,53 +1,14 @@
# 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, patch
-
import pytest
-from craft_providers import Executor, bases
-from craft_providers.actions import snap_installer
from craft_providers.bases.buildd import BuilddBaseAlias
+from testtools import TestCase
-from lpcraft.providers._buildd import (
- SERIES_TO_BUILDD_IMAGE_ALIAS,
- LPCraftBuilddBaseConfiguration,
-)
-from lpcraft.providers.tests import ProviderBaseTestCase
-
-
-class TestLPCraftBuilddBaseConfiguration(ProviderBaseTestCase):
- @patch("craft_providers.actions.snap_installer.inject_from_host")
- def test_setup_inject_from_host(self, mock_inject):
- mock_instance = Mock(spec=Executor)
- config = LPCraftBuilddBaseConfiguration(
- alias=SERIES_TO_BUILDD_IMAGE_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
- )
-
- @patch("craft_providers.actions.snap_installer.inject_from_host")
- def test_setup_inject_from_host_error(self, mock_inject):
- mock_instance = Mock(spec=Executor)
- mock_inject.side_effect = snap_installer.SnapInstallationError(
- brief="Boom"
- )
- config = LPCraftBuilddBaseConfiguration(
- alias=SERIES_TO_BUILDD_IMAGE_ALIAS["focal"]
- )
-
- with self.assertRaisesRegex(
- bases.BaseConfigurationError,
- r"^Failed to inject host lpcraft snap into target environment\.$",
- ) as raised:
- config.setup(executor=mock_instance)
+from lpcraft.providers._buildd import LPCraftBuilddBaseConfiguration
- self.assertIsNotNone(raised.exception.__cause__)
+class TestLPCraftBuilddBaseConfiguration(TestCase):
def test_compare_configuration_with_other_type(self):
"""The configuration should only be comparable to its own type"""
with pytest.raises(TypeError):
diff --git a/lpcraft/providers/tests/test_lxd.py b/lpcraft/providers/tests/test_lxd.py
index 0e111b7..408f15a 100644
--- a/lpcraft/providers/tests/test_lxd.py
+++ b/lpcraft/providers/tests/test_lxd.py
@@ -9,11 +9,12 @@ from unittest.mock import Mock, call, patch
from craft_providers.bases import BaseConfigurationError, BuilddBaseAlias
from craft_providers.lxd import LXC, LXDError, launch
+from testtools import TestCase
from lpcraft.errors import CommandError
from lpcraft.providers._buildd import LPCraftBuilddBaseConfiguration
from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
-from lpcraft.providers.tests import FakeLXDInstaller, ProviderBaseTestCase
+from lpcraft.providers.tests import FakeLXDInstaller
from lpcraft.tests.fixtures import RecordingEmitterFixture
_base_path = (
@@ -21,7 +22,7 @@ _base_path = (
)
-class TestLXDProvider(ProviderBaseTestCase):
+class TestLXDProvider(TestCase):
def setUp(self):
super().setUp()
self.mock_path = Mock(spec=Path)
@@ -242,13 +243,7 @@ class TestLXDProvider(ProviderBaseTestCase):
env = provider.get_command_environment()
- self.assertEqual(
- {
- "LPCRAFT_MANAGED_MODE": "1",
- "PATH": _base_path,
- },
- env,
- )
+ self.assertEqual({"PATH": _base_path}, env)
@patch.dict(
os.environ,
@@ -267,7 +262,6 @@ class TestLXDProvider(ProviderBaseTestCase):
self.assertEqual(
{
- "LPCRAFT_MANAGED_MODE": "1",
"PATH": _base_path,
"http_proxy": "test-http-proxy",
"https_proxy": "test-https-proxy",
@@ -299,10 +293,7 @@ class TestLXDProvider(ProviderBaseTestCase):
name=expected_instance_name,
base_configuration=LPCraftBuilddBaseConfiguration(
alias=BuilddBaseAlias.FOCAL,
- environment={
- "LPCRAFT_MANAGED_MODE": "1",
- "PATH": _base_path,
- },
+ environment={"PATH": _base_path},
hostname=expected_instance_name,
),
image_name="focal",
diff --git a/lpcraft/providers/tests/test_replay_logs.py b/lpcraft/providers/tests/test_replay_logs.py
deleted file mode 100644
index 0a7e9c6..0000000
--- a/lpcraft/providers/tests/test_replay_logs.py
+++ /dev/null
@@ -1,59 +0,0 @@
-# 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 shutil import copyfile
-from unittest.mock import Mock
-
-from craft_providers import Executor
-from fixtures import TempDir
-
-from lpcraft.commands.tests import CommandBaseTestCase
-from lpcraft.providers import replay_logs
-from lpcraft.tests.fixtures import RecordingEmitterFixture
-
-
-class TestReplayLogs(CommandBaseTestCase):
- def test_cannot_pull_file(self):
- mock_instance = Mock(spec=Executor)
- mock_instance.pull_file.side_effect = FileNotFoundError()
-
- with RecordingEmitterFixture() as emitter:
- replay_logs(mock_instance)
-
- self.assertEqual(
- ("trace", "No logs found in instance."),
- emitter.recorder.interactions[0].args,
- )
-
- def test_replay_logs(self):
- self.tempdir = Path(self.useFixture(TempDir()).path)
- path = self.tempdir / "stub_remote_log_file"
- path.write_text("line1\nline2\nline3")
-
- def fake_pull_file(source, destination):
- # use injected `path` rather than source, which would be a
- # lpcraft.env.get_managed_environment_log_path, which is not
- # available in a test
- self.assertEqual(Path("/tmp/lpcraft.log"), Path(source))
- copyfile(path, destination)
-
- mock_instance = Mock(spec=Executor)
- mock_instance.pull_file = fake_pull_file
-
- with RecordingEmitterFixture() as emitter:
- replay_logs(mock_instance)
-
- self.assertEqual(
- ("trace", "Logs captured from managed instance:"),
- emitter.recorder.interactions[0].args,
- )
- self.assertEqual(
- ("trace", ":: line1"), emitter.recorder.interactions[1].args
- )
- self.assertEqual(
- ("trace", ":: line2"), emitter.recorder.interactions[2].args
- )
- self.assertEqual(
- ("trace", ":: line3"), emitter.recorder.interactions[3].args
- )
diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
index 6fb98f9..f36ce69 100644
--- a/lpcraft/tests/test_config.py
+++ b/lpcraft/tests/test_config.py
@@ -1,10 +1,12 @@
# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
+from datetime import timedelta
from pathlib import Path
from textwrap import dedent
from fixtures import TempDir
+from pydantic import ValidationError
from testtools import TestCase
from testtools.matchers import (
Equals,
@@ -13,7 +15,7 @@ from testtools.matchers import (
MatchesStructure,
)
-from lpcraft.config import Config
+from lpcraft.config import Config, OutputDistributeEnum
class TestConfig(TestCase):
@@ -127,3 +129,63 @@ class TestConfig(TestCase):
),
),
)
+
+ def test_output(self):
+ path = self.create_config(
+ dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: [amd64]
+ run: pyproject-build
+ output:
+ paths: ["*.whl"]
+ distribute: artifactory
+ channels: [edge]
+ properties:
+ foo: bar
+ dynamic-properties: properties
+ expires: 1:00:00
+ """
+ )
+ )
+ config = Config.load(path)
+ self.assertThat(
+ config.jobs["build"][0].output,
+ MatchesStructure.byEquality(
+ paths=["*.whl"],
+ distribute=OutputDistributeEnum.artifactory,
+ channels=["edge"],
+ properties={"foo": "bar"},
+ dynamic_properties=Path("properties"),
+ expires=timedelta(hours=1),
+ ),
+ )
+
+ def test_output_negative_expires(self):
+ path = self.create_config(
+ dedent(
+ """
+ pipeline:
+ - build
+
+ jobs:
+ build:
+ series: focal
+ architectures: [amd64]
+ run: pyproject-build
+ output:
+ expires: -1:00:00
+ """
+ )
+ )
+ self.assertRaisesRegex(
+ ValidationError,
+ r"non-negative duration expected",
+ Config.load,
+ path,
+ )
diff --git a/lpcraft/tests/test_env.py b/lpcraft/tests/test_env.py
index 64847fb..54a8018 100644
--- a/lpcraft/tests/test_env.py
+++ b/lpcraft/tests/test_env.py
@@ -1,9 +1,7 @@
# Copyright 2021 Canonical Ltd. This software is licensed under the
# GNU General Public License version 3 (see the file LICENSE).
-import os
from pathlib import Path
-from unittest.mock import patch
from testtools import TestCase
@@ -16,24 +14,7 @@ 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()
)
-
- @patch.dict(os.environ, {})
- def test_is_managed_mode_unset(self):
- self.assertIs(False, env.is_managed_mode())
-
- @patch.dict(os.environ, {"LPCRAFT_MANAGED_MODE": "0"})
- def test_is_managed_mode_0(self):
- self.assertIs(False, env.is_managed_mode())
-
- @patch.dict(os.environ, {"LPCRAFT_MANAGED_MODE": "1"})
- def test_is_managed_mode_1(self):
- self.assertIs(True, env.is_managed_mode())
diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
index 9ebc83b..e33a40c 100644
--- a/lpcraft/tests/test_utils.py
+++ b/lpcraft/tests/test_utils.py
@@ -2,7 +2,6 @@
# 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
@@ -146,7 +145,3 @@ class TestAskUser(TestCase):
mock_input.assert_called_once_with("prompt [y/N]: ")
mock_input.reset_mock()
-
- @patch.dict(os.environ, {"LPCRAFT_MANAGED_MODE": "1"})
- def test_errors_in_managed_mode(self):
- self.assertRaises(RuntimeError, ask_user, "prompt")
diff --git a/lpcraft/utils.py b/lpcraft/utils.py
index 22c866c..afddfdb 100644
--- a/lpcraft/utils.py
+++ b/lpcraft/utils.py
@@ -15,7 +15,6 @@ from typing import Any, Dict
import yaml
-from lpcraft.env import is_managed_mode
from lpcraft.errors import YAMLError
@@ -57,9 +56,6 @@ def ask_user(prompt: str, default: bool = False) -> bool:
: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
diff --git a/requirements.in b/requirements.in
index 69dca66..a5db10c 100644
--- a/requirements.in
+++ b/requirements.in
@@ -1,4 +1,5 @@
craft-providers
pydantic
PyYAML
+python-dotenv
git+git://github.com/canonical/craft-cli.git@c172fa00f61dff8e510116d1c23258e79f710e38
diff --git a/requirements.txt b/requirements.txt
index 21ecf3d..107582d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,6 +21,8 @@ pydantic==1.8.2
# -r requirements.in
# craft-cli
# craft-providers
+python-dotenv==0.19.2
+ # via -r requirements.in
pyyaml==6.0
# via
# -r requirements.in
diff --git a/setup.cfg b/setup.cfg
index 621e8f3..4d14610 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -26,6 +26,7 @@ install_requires =
craft-cli
craft-providers
pydantic
+ python-dotenv
python_requires = >=3.8
[options.entry_points]
References