← Back to team overview

launchpad-reviewers team mailing list archive

[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