← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/lpcraft:input-properties into lpcraft:main

 

Colin Watson has proposed merging ~cjwatson/lpcraft:input-properties into lpcraft:main.

Commit message:
Add input properties

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This allows jobs to use artifacts built by previous pipeline stages.

For now, this is restricted to only allowing input from jobs with a single instance (i.e. non-matrix jobs), since it isn't obvious how to specify input from matrix jobs in a comprehensible way.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:input-properties into lpcraft:main.
diff --git a/docs/configuration.rst b/docs/configuration.rst
index a020b07..f73c7a4 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -80,6 +80,9 @@ Job definitions
 ``output`` (optional)
     See the :ref:`output-properties` section below.
 
+``input`` (optional)
+    See the :ref:`input-properties` section below.
+
 ``matrix`` (optional)
     A list of mappings, each of which is a partial job definition.  The
     final list of concrete jobs to run for this job name is constructed by
@@ -138,6 +141,29 @@ Output properties
     <https://pydantic-docs.helpmanual.io/usage/types/#datetime-types>`_,
     restricted to non-negative timedeltas.
 
+.. _input-properties:
+
+Input properties
+----------------
+
+Input makes artifacts from previous pipeline stages available.  This only
+works if those artifacts were saved using the ``--output-directory`` option
+to ``lpcraft run``.
+
+``lpcraft`` copies artifact data to the ``files`` subdirectory of the
+designated target directory, and writes a ``properties`` file in the
+designated target directory with JSON-encoded properties of the copied
+artifacts.  (This mirrors the output file structure created by ``lpcraft run
+--output-directory``.)
+
+``job-name``
+    The name of a previously-executed job whose artifacts should be made
+    available.
+
+``target-directory``
+    A path, relative to the build tree of a project, identifying a directory
+    to which the artifacts of the chosen job will be copied; the directory
+    will be created if necessary.  Paths may not escape the build tree.
 
 .. _package-repositories:
 
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
index 131811f..231178a 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -21,7 +21,7 @@ from jinja2 import BaseLoader, Environment
 from pluggy import PluginManager
 
 from lpcraft import env
-from lpcraft.config import Config, Job, Output, PackageRepository
+from lpcraft.config import Config, Input, Job, Output, PackageRepository
 from lpcraft.errors import CommandError
 from lpcraft.plugin.manager import get_plugin_manager
 from lpcraft.plugins import PLUGINS
@@ -116,6 +116,55 @@ def _resolve_symlinks(
     return [PurePath(os.fsdecode(p)) for p in paths]
 
 
+def _copy_input_paths(
+    input: Input, remote_cwd: Path, instance: Executor, output_path: Path
+) -> None:
+    """Copy designated input artifacts into a job."""
+    source_parent_path = output_path / input.job_name
+    source_jobs = (
+        list(source_parent_path.iterdir())
+        if source_parent_path.exists()
+        else []
+    )
+    if not source_jobs:
+        raise CommandError(
+            f"Requested input from {input.job_name!r}, but that job was not "
+            f"previously executed or did not produce any output artifacts."
+        )
+    elif len(source_jobs) > 1:
+        raise CommandError(
+            f"Requested input from {input.job_name!r}, but more than one job "
+            f"with that name was previously executed and produced output "
+            f"artifacts."
+        )
+    source_path = source_jobs[0]
+
+    [target_path] = _resolve_symlinks(
+        instance, [remote_cwd / input.target_directory]
+    )
+    _check_relative_path(target_path, remote_cwd)
+    instance.execute_run(
+        ["mkdir", "-p", str(target_path / "files")], check=True
+    )
+
+    try:
+        for path in sorted((source_path / "files").iterdir()):
+            instance.push_file(
+                source=path,
+                # Path() here works around
+                # https://github.com/canonical/craft-providers/pull/135.
+                destination=Path(target_path / "files" / path.name),
+            )
+        instance.push_file(
+            source=source_path / "properties",
+            # Path() here works around
+            # https://github.com/canonical/craft-providers/pull/135.
+            destination=Path(target_path / "properties"),
+        )
+    except Exception as e:
+        raise CommandError(str(e), retcode=1)
+
+
 def _copy_output_paths(
     output: Output, remote_cwd: Path, instance: Executor, target_path: Path
 ) -> None:
@@ -442,6 +491,10 @@ def _run_job(
                 environment=environment,
                 secrets=secrets,
             )
+
+        if job.input is not None and output is not None:
+            _copy_input_paths(job.input, remote_cwd, instance, output)
+
         for cmd in (pre_run_command, run_command, post_run_command):
             if cmd:
                 _run_instance_command(
diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
index 3dc6bbc..8bf3030 100644
--- a/lpcraft/commands/tests/test_run.py
+++ b/lpcraft/commands/tests/test_run.py
@@ -5,6 +5,7 @@ import io
 import json
 import os
 import re
+import shutil
 import subprocess
 from pathlib import Path, PosixPath
 from textwrap import dedent
@@ -1408,6 +1409,374 @@ class TestRun(RunBaseTestCase):
         [error] = result.errors
         self.assertIn("/target", str(error))
 
+    @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_input_paths(
+        self,
+        mock_get_host_architecture,
+        mock_get_provider,
+        mock_get_project_path,
+    ):
+        def fake_pull_file(source: Path, destination: Path) -> None:
+            shutil.copy2(source, destination)
+
+        def fake_push_file(source: Path, destination: Path) -> None:
+            shutil.copy2(source, destination)
+
+        target_path = Path(self.useFixture(TempDir()).path)
+        launcher = Mock(spec=launch)
+        provider = 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
+        launcher.return_value.push_file.side_effect = fake_push_file
+        config = dedent(
+            """
+            pipeline:
+                - build
+                - test
+
+            jobs:
+                build:
+                    series: focal
+                    architectures: [amd64]
+                    run: "true"
+                    output:
+                        paths: [binary]
+
+                test:
+                    series: focal
+                    architectures: [amd64]
+                    run: "true"
+                    input:
+                        job-name: build
+                        target-directory: artifacts
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        Path("binary").write_bytes(b"binary")
+        result = self.run_command(
+            "run", "--output-directory", str(target_path)
+        )
+
+        self.assertEqual(0, result.exit_code)
+        build_job_output = target_path / "build" / "0"
+        artifacts_path = self.tmp_project_path / "artifacts"
+        self.assertEqual(
+            [
+                call(
+                    source=self.tmp_project_path / "binary",
+                    destination=build_job_output / "files" / "binary",
+                )
+            ],
+            launcher.return_value.pull_file.call_args_list,
+        )
+        self.assertEqual(
+            [
+                call(
+                    source=build_job_output / "files" / "binary",
+                    destination=artifacts_path / "files" / "binary",
+                ),
+                call(
+                    source=build_job_output / "properties",
+                    destination=artifacts_path / "properties",
+                ),
+            ],
+            launcher.return_value.push_file.call_args_list,
+        )
+        self.assertEqual(
+            ["files", "properties"],
+            sorted(path.name for path in artifacts_path.iterdir()),
+        )
+        self.assertEqual(
+            ["binary"],
+            sorted(path.name for path in (artifacts_path / "files").iterdir()),
+        )
+        self.assertEqual(
+            b"binary", (artifacts_path / "files" / "binary").read_bytes()
+        )
+
+    @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_input_target_directory_not_previously_executed(
+        self,
+        mock_get_host_architecture,
+        mock_get_provider,
+        mock_get_project_path,
+    ):
+        target_path = Path(self.useFixture(TempDir()).path)
+        launcher = Mock(spec=launch)
+        provider = 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"
+                    input:
+                        job-name: build
+                        target-directory: artifacts
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command(
+            "run", "--output-directory", str(target_path)
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        "Requested input from 'build', but that job was not "
+                        "previously executed or did not produce any output "
+                        "artifacts.",
+                        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_input_target_directory_multiple_jobs(
+        self,
+        mock_get_host_architecture,
+        mock_get_provider,
+        mock_get_project_path,
+    ):
+        target_path = Path(self.useFixture(TempDir()).path)
+        launcher = Mock(spec=launch)
+        provider = 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
+                - test
+
+            jobs:
+                build:
+                    matrix:
+                        - series: bionic
+                        - series: focal
+                    architectures: [amd64]
+                    run: "true"
+                    output:
+                        paths: [binary]
+
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: "true"
+                    input:
+                        job-name: build
+                        target-directory: artifacts
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        Path("binary").touch()
+
+        result = self.run_command(
+            "run", "--output-directory", str(target_path)
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        "Requested input from 'build', but more than one job "
+                        "with that name was previously executed and produced "
+                        "output artifacts.",
+                        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_input_target_directory_escapes_directly(
+        self,
+        mock_get_host_architecture,
+        mock_get_provider,
+        mock_get_project_path,
+    ):
+        target_path = Path(self.useFixture(TempDir()).path)
+        launcher = Mock(spec=launch)
+        provider = 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
+                - test
+
+            jobs:
+                build:
+                    series: focal
+                    architectures: [amd64]
+                    run: "true"
+                    output:
+                        paths: [binary]
+
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: "true"
+                    input:
+                        job-name: build
+                        target-directory: "../etc/secrets"
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        Path("binary").touch()
+
+        result = self.run_command(
+            "run", "--output-directory", str(target_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.assertEqual(1, result.exit_code)
+        [error] = result.errors
+        self.assertIn("/etc/secrets", str(error))
+
+    @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_input_target_directory_escapes_symlink(
+        self,
+        mock_get_host_architecture,
+        mock_get_provider,
+        mock_get_project_path,
+    ):
+        target_path = Path(self.useFixture(TempDir()).path)
+        launcher = Mock(spec=launch)
+        provider = 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
+                - test
+
+            jobs:
+                build:
+                    series: focal
+                    architectures: [amd64]
+                    run: "true"
+                    output:
+                        paths: [binary]
+
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: "true"
+                    input:
+                        job-name: build
+                        target-directory: artifacts
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        Path("binary").touch()
+        Path("artifacts").symlink_to("../secrets")
+
+        result = self.run_command(
+            "run", "--output-directory", str(target_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.assertEqual(1, result.exit_code)
+        [error] = result.errors
+        self.assertIn("/secrets", str(error))
+
+    @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_input_push_file_fails(
+        self,
+        mock_get_host_architecture,
+        mock_get_provider,
+        mock_get_project_path,
+    ):
+        target_path = Path(self.useFixture(TempDir()).path)
+        launcher = Mock(spec=launch)
+        provider = 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.push_file.side_effect = FileNotFoundError(
+            "File not found"
+        )
+        mock_get_project_path.return_value = self.tmp_project_path
+        config = dedent(
+            """
+            pipeline:
+                - build
+                - test
+
+            jobs:
+                build:
+                    series: focal
+                    architectures: [amd64]
+                    run: "true"
+                    output:
+                        paths: [binary]
+
+                test:
+                    series: focal
+                    architectures: amd64
+                    run: "true"
+                    input:
+                        job-name: build
+                        target-directory: artifacts
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+        Path("binary").touch()
+
+        result = self.run_command(
+            "run", "--output-directory", str(target_path)
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1, errors=[CommandError("File not found", retcode=1)]
+            ),
+        )
+
     @responses.activate
     @patch("lpcraft.commands.run.get_provider")
     @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
diff --git a/lpcraft/config.py b/lpcraft/config.py
index 328cd56..0a3b3dd 100644
--- a/lpcraft/config.py
+++ b/lpcraft/config.py
@@ -60,6 +60,13 @@ class Output(ModelConfigDefaults):
         return v
 
 
+class Input(ModelConfigDefaults):
+    """Job input properties."""
+
+    job_name: StrictStr
+    target_directory: StrictStr
+
+
 def _validate_plugin_config(
     plugin: Type[BasePlugin],
     values: Dict[StrictStr, Any],
@@ -157,6 +164,7 @@ class Job(ModelConfigDefaults):
     run_after: Optional[StrictStr]
     environment: Optional[Dict[str, Optional[str]]]
     output: Optional[Output]
+    input: Optional[Input]
     snaps: Optional[List[StrictStr]]
     packages: Optional[List[StrictStr]]
     package_repositories: Optional[List[PackageRepository]]
diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
index 575f284..64f5fa8 100644
--- a/lpcraft/tests/test_config.py
+++ b/lpcraft/tests/test_config.py
@@ -313,6 +313,41 @@ class TestConfig(TestCase):
             path,
         )
 
+    def test_input(self):
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - build
+                    - test
+
+                jobs:
+                    build:
+                        series: focal
+                        architectures: [amd64]
+                        packages: [make]
+                        run: make
+                        output:
+                            paths: [binary]
+
+                    test:
+                        series: focal
+                        architectures: [amd64]
+                        run: artifacts/binary
+                        input:
+                            job-name: build
+                            target-directory: artifacts
+                """
+            )
+        )
+        config = Config.load(path)
+        self.assertThat(
+            config.jobs["test"][0].input,
+            MatchesStructure.byEquality(
+                job_name="build", target_directory="artifacts"
+            ),
+        )
+
     def test_load_snaps(self):
         path = self.create_config(
             dedent(