← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/lpcraft:matrix into lpcraft:main

 

Colin Watson has proposed merging ~cjwatson/lpcraft:matrix into lpcraft:main.

Commit message:
Add job matrix support

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

I'm not completely happy with the way this is done at the `pydantic` level, but it works well enough for the moment.

I realized that my handling of the case where a job isn't defined for the host architecture as an error doesn't make a lot of sense, especially in the context of larger job matrices, so I revised that to skip such jobs instead.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:matrix into lpcraft:main.
diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
index b3917cf..6730e3d 100644
--- a/lpcraft/commands/run.py
+++ b/lpcraft/commands/run.py
@@ -4,6 +4,7 @@
 import subprocess
 from argparse import Namespace
 from pathlib import Path
+from typing import List, Optional
 
 from craft_cli import EmitterMode, emit
 
@@ -14,11 +15,19 @@ from lpcraft.providers import get_provider, replay_logs
 from lpcraft.utils import get_host_architecture
 
 
-def _get_job(config: Config, job_name: str) -> Job:
-    try:
-        return config.jobs[job_name]
-    except KeyError:
+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 _run_job(args: Namespace) -> None:
@@ -29,7 +38,13 @@ def _run_job(args: Namespace) -> None:
         raise CommandError("Job name is required in managed mode")
 
     config = load(".launchpad.yaml")
-    job = _get_job(config, args.job_name)
+    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}"
+        )
+    [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])
@@ -51,45 +66,45 @@ def _run_pipeline(args: Namespace) -> None:
     provider.ensure_provider_is_available()
 
     for job_name in config.pipeline:
-        job = _get_job(config, job_name)
-        if host_architecture not in job.architectures:
-            raise CommandError(
-                f"Job {job_name!r} not defined for {host_architecture}"
+        jobs = _get_jobs(config, job_name)
+        for job in jobs:
+            if host_architecture not in job.architectures:
+                continue
+
+            cmd = ["lpcraft"]
+            if emit.get_mode() == EmitterMode.QUIET:
+                cmd.append("--quiet")
+            elif emit.get_mode() == EmitterMode.VERBOSE:
+                cmd.append("--verbose")
+            elif emit.get_mode() == EmitterMode.TRACE:
+                cmd.append("--trace")
+            cmd.extend(["run", "--series", job.series, job_name])
+
+            emit.progress(
+                f"Launching environment for {job.series}/{host_architecture}"
             )
-
-        cmd = ["lpcraft"]
-        if emit.get_mode() == EmitterMode.QUIET:
-            cmd.append("--quiet")
-        elif emit.get_mode() == EmitterMode.VERBOSE:
-            cmd.append("--verbose")
-        elif emit.get_mode() == EmitterMode.TRACE:
-            cmd.append("--trace")
-        cmd.extend(["run", "--series", job.series, job_name])
-
-        emit.progress(
-            f"Launching environment for {job.series}/{host_architecture}"
-        )
-        with provider.launched_environment(
-            project_name=cwd.name,
-            project_path=cwd,
-            series=job.series,
-            architecture=host_architecture,
-        ) as instance:
-            emit.progress("Running the job")
-            with emit.open_stream(f"Running {cmd}") as stream:
-                proc = instance.execute_run(
-                    cmd,
-                    cwd=env.get_managed_environment_project_path(),
-                    stdout=stream,
-                    stderr=stream,
-                )
-            if proc.returncode != 0:
-                replay_logs(instance)
-                raise CommandError(
-                    f"Job {job_name!r} for {job.series}/{host_architecture} "
-                    f"failed with exit status {proc.returncode}.",
-                    retcode=proc.returncode,
-                )
+            with provider.launched_environment(
+                project_name=cwd.name,
+                project_path=cwd,
+                series=job.series,
+                architecture=host_architecture,
+            ) as instance:
+                emit.progress("Running the job")
+                with emit.open_stream(f"Running {cmd}") as stream:
+                    proc = instance.execute_run(
+                        cmd,
+                        cwd=env.get_managed_environment_project_path(),
+                        stdout=stream,
+                        stderr=stream,
+                    )
+                if proc.returncode != 0:
+                    replay_logs(instance)
+                    raise CommandError(
+                        f"Job {job_name!r} for "
+                        f"{job.series}/{host_architecture} failed with "
+                        f"exit status {proc.returncode}.",
+                        retcode=proc.returncode,
+                    )
 
 
 def run(args: Namespace) -> int:
diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
index ae74b83..669e26d 100644
--- a/lpcraft/commands/tests/test_run.py
+++ b/lpcraft/commands/tests/test_run.py
@@ -87,6 +87,64 @@ class RunJobTestCase(CommandBaseTestCase):
             ),
         )
 
+    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(
             """
@@ -223,7 +281,7 @@ class RunPipelineTestCase(CommandBaseTestCase):
         config = dedent(
             """
             pipeline: []
-            jobs: []
+            jobs: {}
             """
         )
         Path(".launchpad.yaml").write_text(config)
@@ -249,7 +307,7 @@ class RunPipelineTestCase(CommandBaseTestCase):
             pipeline:
                 - test
 
-            jobs: []
+            jobs: {}
             """
         )
         Path(".launchpad.yaml").write_text(config)
@@ -265,11 +323,18 @@ class RunPipelineTestCase(CommandBaseTestCase):
         )
 
     @patch("lpcraft.commands.run.get_provider")
-    @patch("lpcraft.commands.run.get_host_architecture", return_value="i386")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="arm64")
     def test_job_not_defined_for_host_architecture(
         self, mock_get_host_architecture, mock_get_provider
     ):
-        mock_get_provider.return_value = self.makeLXDProvider()
+        # Jobs not defined for the host architecture are skipped.  (It is
+        # assumed that the dispatcher won't dispatch anything for an
+        # architecture if it has no jobs at all.)
+        launcher = Mock(spec=launch)
+        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
         config = dedent(
             """
             pipeline:
@@ -279,18 +344,28 @@ class RunPipelineTestCase(CommandBaseTestCase):
                 test:
                     series: focal
                     architectures: [amd64, arm64]
+                    run: tox
+                build-wheel:
+                    series: focal
+                    architectures: amd64
+                    run: pyproject-build
             """
         )
         Path(".launchpad.yaml").write_text(config)
 
         result = self.run_command("run")
 
-        self.assertThat(
-            result,
-            MatchesStructure.byEquality(
-                exit_code=1,
-                errors=[CommandError("Job 'test' not defined for i386")],
-            ),
+        self.assertEqual(0, result.exit_code)
+        self.assertEqual(
+            [
+                call(
+                    ["lpcraft", "run", "--series", "focal", "test"],
+                    cwd=Path("/root/project"),
+                    stdout=ANY,
+                    stderr=ANY,
+                )
+            ],
+            execute_run.call_args_list,
         )
 
     @patch("lpcraft.commands.run.get_provider")
@@ -393,3 +468,62 @@ class RunPipelineTestCase(CommandBaseTestCase):
             ],
             execute_run.call_args_list,
         )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_expands_matrix(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = self.makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - test
+                - build-wheel
+
+            jobs:
+                test:
+                    matrix:
+                        - series: bionic
+                          architectures: amd64
+                        - series: focal
+                          architectures: [amd64, s390x]
+                    run: tox
+                build-wheel:
+                    series: bionic
+                    architectures: amd64
+                    run: pyproject-build
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        result = self.run_command("run")
+
+        self.assertEqual(0, result.exit_code)
+        self.assertEqual(
+            [
+                call(
+                    ["lpcraft", "run", "--series", "bionic", "test"],
+                    cwd=Path("/root/project"),
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    ["lpcraft", "run", "--series", "focal", "test"],
+                    cwd=Path("/root/project"),
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    ["lpcraft", "run", "--series", "bionic", "build-wheel"],
+                    cwd=Path("/root/project"),
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
diff --git a/lpcraft/config.py b/lpcraft/config.py
index cd6b128..e3b4f97 100644
--- a/lpcraft/config.py
+++ b/lpcraft/config.py
@@ -2,7 +2,7 @@
 # GNU General Public License version 3 (see the file LICENSE).
 
 from pathlib import Path
-from typing import Dict, List, Optional, Union
+from typing import Any, Dict, List, Optional, Union
 
 import pydantic
 from pydantic import StrictStr
@@ -15,6 +15,7 @@ class ModelConfigDefaults(
     extra=pydantic.Extra.forbid,
     frozen=True,
     alias_generator=lambda s: s.replace("_", "-"),
+    underscore_attrs_are_private=True,
 ):
     """Define lpcraft's model defaults."""
 
@@ -35,11 +36,41 @@ class Job(ModelConfigDefaults):
         return v
 
 
+def _expand_job_values(
+    values: Dict[StrictStr, Any]
+) -> List[Dict[StrictStr, Any]]:
+    expanded_values = []
+    if "matrix" in values:
+        base_values = values.copy()
+        del base_values["matrix"]
+        for variant in values["matrix"]:
+            variant_values = base_values.copy()
+            variant_values.update(variant)
+            expanded_values.append(variant_values)
+    else:
+        expanded_values.append(values)
+    return expanded_values
+
+
 class Config(ModelConfigDefaults):
     """A .launchpad.yaml configuration file."""
 
     pipeline: List[StrictStr]
-    jobs: Dict[StrictStr, Job]
+    jobs: Dict[StrictStr, List[Job]]
+
+    # XXX cjwatson 2021-11-17: This expansion strategy works, but it may
+    # produce suboptimal error messages, and doesn't have a good way to do
+    # things like limiting the keys that can be set in a matrix.
+    @pydantic.root_validator(pre=True)
+    def expand_matrix(
+        cls, values: Dict[StrictStr, Any]
+    ) -> Dict[StrictStr, Any]:
+        expanded_values = values.copy()
+        expanded_values["jobs"] = {
+            job_name: _expand_job_values(job_values)
+            for job_name, job_values in values["jobs"].items()
+        }
+        return expanded_values
 
 
 def load(filename: str) -> Config:
diff --git a/lpcraft/tests/test_config.py b/lpcraft/tests/test_config.py
index 9065665..6f69136 100644
--- a/lpcraft/tests/test_config.py
+++ b/lpcraft/tests/test_config.py
@@ -6,7 +6,12 @@ from textwrap import dedent
 
 from fixtures import TempDir
 from testtools import TestCase
-from testtools.matchers import Equals, MatchesDict, MatchesStructure
+from testtools.matchers import (
+    Equals,
+    MatchesDict,
+    MatchesListwise,
+    MatchesStructure,
+)
 
 from lpcraft.config import load
 
@@ -44,10 +49,14 @@ class TestConfig(TestCase):
                 pipeline=Equals(["test"]),
                 jobs=MatchesDict(
                     {
-                        "test": MatchesStructure.byEquality(
-                            series="focal",
-                            architectures=["amd64", "arm64"],
-                            run="tox\n",
+                        "test": MatchesListwise(
+                            [
+                                MatchesStructure.byEquality(
+                                    series="focal",
+                                    architectures=["amd64", "arm64"],
+                                    run="tox\n",
+                                )
+                            ]
                         )
                     }
                 ),
@@ -71,4 +80,50 @@ class TestConfig(TestCase):
             )
         )
         config = load(str(path))
-        self.assertEqual(["amd64"], config.jobs["test"].architectures)
+        self.assertEqual(["amd64"], config.jobs["test"][0].architectures)
+
+    def test_expands_matrix(self):
+        path = self.create_config(
+            dedent(
+                """
+                pipeline:
+                    - test
+
+                jobs:
+                    test:
+                        matrix:
+                            - series: bionic
+                              architectures: [amd64, arm64]
+
+                            - series: focal
+                              architectures: amd64
+                              run: tox -e py38
+                        run: tox
+                """
+            )
+        )
+        config = load(str(path))
+        self.assertThat(
+            config,
+            MatchesStructure(
+                pipeline=Equals(["test"]),
+                jobs=MatchesDict(
+                    {
+                        "test": MatchesListwise(
+                            [
+                                MatchesStructure.byEquality(
+                                    series="bionic",
+                                    architectures=["amd64", "arm64"],
+                                    run="tox",
+                                ),
+                                MatchesStructure.byEquality(
+                                    series="focal",
+                                    architectures=["amd64"],
+                                    run="tox -e py38",
+                                ),
+                            ]
+                        )
+                    }
+                ),
+            ),
+        )