← 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 with ~cjwatson/lpcraft:remove-managed-mode as a prerequisite.

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/412439

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/lpcraft/commands/run.py b/lpcraft/commands/run.py
index b1fe013..d8b8a18 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -1,18 +1,165 @@
 # Copyright 2021 Canonical Ltd.  This software is licensed under the
 # GNU General Public License version 3 (see the file LICENSE).
 
+import fnmatch
+import io
+import json
+import os
 from argparse import Namespace
-from pathlib import Path
+from pathlib import Path, PurePath
+from typing import List, Set
 
 from craft_cli import emit
+from craft_providers import Executor
+from dotenv import dotenv_values
 
 from lpcraft import env
-from lpcraft.config import Config
+from lpcraft.config import Config, Output
 from lpcraft.errors import CommandError
 from lpcraft.providers import get_provider
 from lpcraft.utils import get_host_architecture
 
 
+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,
+        )
+
+    remote_paths = sorted(_find_in_instance(instance, remote_cwd))
+    output_files = output_path / "files"
+
+    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,
+            )
+        )
+    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(args: Namespace) -> int:
     """Run a pipeline, launching managed environments as needed."""
     config = Config.load(Path(".launchpad.yaml"))
@@ -36,6 +183,7 @@ def run(args: Namespace) -> int:
                 )
 
             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}"
@@ -50,7 +198,7 @@ def run(args: Namespace) -> int:
                 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,
                     )
@@ -62,4 +210,16 @@ def run(args: Namespace) -> int:
                         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
+                    )
+
     return 0
diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
index 7d19987..3f35b75 100644
--- a/lpcraft/commands/tests/test_run.py
+++ b/lpcraft/commands/tests/test_run.py
@@ -1,16 +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 TempDir
-from testtools.matchers import MatchesStructure
+from testtools.matchers import (
+    AfterPreprocessing,
+    Contains,
+    Equals,
+    MatchesListwise,
+    MatchesStructure,
+)
 
 from lpcraft.commands.tests import CommandBaseTestCase
 from lpcraft.errors import CommandError, YAMLError
@@ -18,13 +25,51 @@ from lpcraft.providers._lxd import LXDProvider, _LXDLauncher
 from lpcraft.providers.tests import FakeLXDInstaller
 
 
+class LocalExecuteRun:
+    """A fake LXDInstance.execute_run that runs subprocesses locally.
+
+    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 __init__(self, override_cwd: Path):
+        super().__init__()
+        self.override_cwd = override_cwd
+        self.call_args_list: List[Any] = []
+
+    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()
-        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(
@@ -369,3 +414,558 @@ class TestRun(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/main.py b/lpcraft/main.py
index cf6dcf8..124be7e 100644
--- a/lpcraft/main.py
+++ b/lpcraft/main.py
@@ -5,6 +5,7 @@
 
 import logging
 from argparse import ArgumentParser
+from pathlib import Path
 
 from craft_cli import CraftError, EmitterMode, emit
 
@@ -61,6 +62,10 @@ def main() -> int:
     # alongside the individual subcommands rather than here.
 
     parser_run = subparsers.add_parser("run", help=run.__doc__)
+    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/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/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]