Repository: osbuild/osbuild
Pull request #996 (2022-03-23 13:38:30)

runners: auto detection based on best matching distro+version
The way that runners were designed is the following: For each distro we have a specific runner. In case a new version of the distro can use the previous runner, we just create a symlink. In case a new distro version needs adjustments, the runner is copied and adjusted. This is a very clean and obvious design. There is one big drawback: For each new distribution a symlink must be created before it can be used. For Fedora that should ideally happen when it is branched; and this will, ipso facto, always be a symlink since at the time of the branching the new distro is the old distro. But at this very moment osbuild will be broken since it does not contain the new runner; the only way to prevent this is to create the corresponding new runner before the distro is branched, where it then must be a symlink too. This very much suggest that instead of the explicit symlink, which does not /that/ much clarity, the existing "old" runner should just work for the new distribution. This PR implements the logic to do just that: all existing runners are parsed into a distro and version tuple and then, given a specific requested distro, the best matching one is return.


gicmo: Uff, `mypy` is really annoying here :/

Patch:
diff --git a/osbuild/buildroot.py b/osbuild/buildroot.py
index 507e070b..0c33c5ee 100644
--- a/osbuild/buildroot.py
+++ b/osbuild/buildroot.py
@@ -271,6 +271,11 @@ class BuildRoot(contextlib.AbstractContextManager):
             api_path = "/run/osbuild/api/" + api.endpoint
             mounts += ["--bind", api.socket_address, api_path]
 
+        # Bind mount the runner into the container at a well known location
+        runner_name = os.path.basename(self._runner)
+        runner = f"/run/osbuild/runner/{runner_name}"
+        mounts += ["--ro-bind", self._runner, runner]
+
         cmd = [
             "bwrap",
             "--chdir", "/",
@@ -284,7 +289,7 @@ class BuildRoot(contextlib.AbstractContextManager):
         cmd += self.build_capabilities_args()
 
         cmd += mounts
-        cmd += ["--", f"/run/osbuild/lib/runners/{self._runner}"]
+        cmd += ["--", runner]
         cmd += argv
 
         # Setup a new environment for the container.
diff --git a/osbuild/formats/v1.py b/osbuild/formats/v1.py
index 198a708f..5943fa54 100644
--- a/osbuild/formats/v1.py
+++ b/osbuild/formats/v1.py
@@ -11,7 +11,7 @@ from typing import Any, Dict
 
 from osbuild.meta import Index, ValidationResult
 
-from ..pipeline import BuildResult, Manifest, Pipeline, detect_host_runner
+from ..pipeline import BuildResult, Manifest, Pipeline, Runner
 
 VERSION = "1"
 
@@ -32,7 +32,7 @@ def describe(manifest: Manifest, *, with_id=False) -> Dict[str, Any]:
             build = manifest[pipeline.build]
             description["build"] = {
                 "pipeline": describe_pipeline(build),
-                "runner": pipeline.runner
+                "runner": pipeline.runner.name
             }
 
         if pipeline.stages:
@@ -91,7 +91,10 @@ def load_build(description: Dict, index: Index, manifest: Manifest, n: int):
     else:
         build_pipeline = None
 
-    return build_pipeline, description["runner"]
+    runner_name = description["runner"]
+    runner_info = index.detect_runner(runner_name)
+
+    return build_pipeline, Runner(runner_info, runner_name)
 
 
 def load_stage(description: Dict, index: Index, pipeline: Pipeline):
@@ -146,7 +149,7 @@ def load_pipeline(description: Dict, index: Index, manifest: Manifest, n: int =
     if build:
         build_pipeline, runner = load_build(build, index, manifest, n)
     else:
-        build_pipeline, runner = None, detect_host_runner()
+        build_pipeline, runner = None, Runner(index.detect_host_runner())
 
     # the "main" pipeline is called `tree`, since it is building the
     # tree that will later be used by the `assembler`. Nested build
diff --git a/osbuild/formats/v2.py b/osbuild/formats/v2.py
index c0e07c57..a70d6297 100644
--- a/osbuild/formats/v2.py
+++ b/osbuild/formats/v2.py
@@ -7,7 +7,7 @@ from typing import Any, Dict
 from osbuild.meta import Index, ModuleInfo, ValidationResult
 
 from ..inputs import Input
-from ..pipeline import Manifest, Pipeline, Stage, detect_host_runner
+from ..pipeline import Manifest, Pipeline, Runner, Stage
 from ..sources import Source
 
 VERSION = "2"
@@ -130,7 +130,7 @@ def describe(manifest: Manifest, *, with_id=False) -> Dict:
 
         runner = runners.get(p.id)
         if runner:
-            desc["runner"] = runner
+            desc["runner"] = runner.name
 
         stages = [
             describe_stage(stage)
@@ -325,13 +325,22 @@ def load_stage(description: Dict, index: Index, pipeline: Pipeline, manifest: Ma
 def load_pipeline(description: Dict, index: Index, manifest: Manifest, source_refs: set):
     name = description["name"]
     build = description.get("build")
-    runner = description.get("runner")
     source_epoch = description.get("source-epoch")
 
     if build and build.startswith("name:"):
         target = resolve_ref(build, manifest)
         build = target
 
+    # NB: The runner mapping will later be changed in `load`.
+    # The host runner here is just to always have a Runner
+    # (instead of a Optional[Runner]) to make mypy happy
+    runner_name = description.get("runner")
+    runner = None
+    if runner_name:
+        runner = Runner(index.detect_runner(runner_name), runner_name)
+    else:
+        runner = Runner(index.detect_host_runner())
+
     pl = manifest.add_pipeline(name, runner, build, source_epoch)
 
     for desc in description.get("stages", []):
@@ -365,7 +374,7 @@ def load(description: Dict, index: Index) -> Manifest:
     # go through the pipelines and fix things up
     pipelines = manifest.pipelines.values()
 
-    host_runner = detect_host_runner()
+    host_runner = Runner(index.detect_host_runner())
     runners = {
         pl.id: pl.runner for pl in pipelines
     }
diff --git a/osbuild/meta.py b/osbuild/meta.py
index 6186416e..fb23d73e 100644
--- a/osbuild/meta.py
+++ b/osbuild/meta.py
@@ -34,6 +34,8 @@ from typing import (Any, Deque, Dict, List, Optional, Sequence, Set, Tuple,
 
 import jsonschema
 
+from .util import osrelease
+
 FAILED_TITLE = "JSON Schema validation failed"
 FAILED_TYPEURI = "https://osbuild.org/validation-error"
 
@@ -484,6 +486,46 @@ class FormatInfo:
         return cls(mod)
 
 
+class RunnerInfo:
+    """Information about a runner
+
+    Class that represents an actual available runner for a
+    specific distribution and version.
+    """
+
+    def __init__(self, distro: str, version: int, path: str) -> None:
+        self.distro = distro
+        self.version = version
+        self.path = path
+
+    @classmethod
+    def from_path(cls, path: str):
+        name = os.path.basename(path)
+        distro, version = cls.parse_name(name)
+        return cls(distro, version, path)
+
+    @staticmethod
+    def parse_name(name: str) -> Tuple[str, int]:
+        """Parses a runner name into a string & version tuple
+
+        The name is assumed to be "<name><version>" and version
+        to be a single integer. If the name does not contain a
+        version suffix it will default to 0.
+        """
+        version = 0
+
+        i = len(name) - 1
+
+        while i > 0 and name[i].isdigit():
+            i -= 1
+
+        vstr = name[i+1:]
+        if vstr:
+            version = int(vstr)
+
+        return name[:i+1], version
+
+
 class Index:
     """Index of modules and formats
 
@@ -492,10 +534,12 @@ class Index:
     """
 
     def __init__(self, path: str):
-        self.path = path
+        self.path = os.path.abspath(path)
         self._module_info: Dict[Tuple[str, Any], Any] = {}
         self._format_info: Dict[Tuple[str, Any], Any] = {}
         self._schemata: Dict[Tuple[str, Any, str], Schema] = {}
+        self._runners: List[RunnerInfo] = []
+        self._host_runner: Optional[RunnerInfo] = None
 
     @staticmethod
     def list_formats() -> List[str]:
@@ -585,3 +629,58 @@ class Index:
         self._schemata[(klass, name, version)] = schema
 
         return schema
+
+    def list_runners(self, distro: Optional[str] = None) -> List[RunnerInfo]:
+        """List all available runner modules
+
+        The list is sorted by distribution and version (ascending).
+        If `distro` is specified, only runners matching that distro
+        will be returned.
+        """
+        if not self._runners:
+            path = os.path.join(self.path, "runners")
+            names = filter(lambda f: os.path.isfile(f"{path}/{f}"),
+                           os.listdir(path))
+            paths = map(lambda n: os.path.join(path, n), names)
+            mapped = map(RunnerInfo.from_path, paths)
+            self._runners = sorted(mapped, key=lambda r: (r.distro, r.version))
+
+        runners = self._runners[:]
+        if distro:
+            runners = [r for r in runners if r.distro == distro]
+
+        return runners
+
+    def detect_runner(self, name) -> RunnerInfo:
+        """Detect the runner for the given name
+
+        Name here refers to the combination of distribution with an
+        optional version suffix, e.g. `org.osbuild.fedora30`.
+        This functions will then return the best existing runner,
+        i.e. a candidate with the highest version number that
+        fullfils the following criteria:
+          1) distribution of the candidate matches exactly
+          2) version of the candidate is smaller or equal
+        If no such candidate exists, a `ValueError` will be thrown.
+        """
+        name, version = RunnerInfo.parse_name(name)
+        candidate = None
+
+        # Get all candidates for the specified distro (1)
+        candidates = self.list_runners(name)
+
+        for candidate in reversed(candidates):
+            if candidate.version <= version:
+                return candidate
+
+        # candidate None or is too new for version (2)
+        raise ValueError(f"No suitable runner for {name}")
+
+    def detect_host_runner(self) -> RunnerInfo:
+        """Use os-release(5) to detect the runner for the host"""
+
+        if not self._host_runner:
+            osname = osrelease.describe_os(*osrelease.DEFAULT_PATHS)
+            self._host_runner = self.detect_runner("org.osbuild." + osname)
+
+        return self._host_runner
diff --git a/osbuild/monitor.py b/osbuild/monitor.py
index 93e59b73..f8f1f279 100644
--- a/osbuild/monitor.py
+++ b/osbuild/monitor.py
@@ -102,6 +102,13 @@ class LogMonitor(BaseMonitor):
         self.out.write(f"Pipeline {pipeline.name}: {pipeline.id}")
         self.out.term(vt.reset)
         self.out.write("\n")
+        self.out.write("Build\n  root: ")
+        if pipeline.build:
+            self.out.write(pipeline.build)
+        else:
+            self.out.write("<host>")
+        self.out.write(f"\n  runner: {pipeline.runner.name} ({pipeline.runner.exec})")
+        self.out.write("\n")
 
     def stage(self, stage):
         self.module(stage)
diff --git a/osbuild/pipeline.py b/osbuild/pipeline.py
index 97f0718c..538ff13b 100644
--- a/osbuild/pipeline.py
+++ b/osbuild/pipeline.py
@@ -145,7 +145,7 @@ class Stage:
     def run(self, tree, runner, build_tree, store, monitor, libdir, timeout=None):
         with contextlib.ExitStack() as cm:
 
-            build_root = buildroot.BuildRoot(build_tree, runner, libdir, store.tmp)
+            build_root = buildroot.BuildRoot(build_tree, runner.path, libdir, store.tmp)
             cm.enter_context(build_root)
 
             # if we have a build root, then also bind-mount the boot
@@ -239,8 +239,22 @@ class Stage:
         return BuildResult(self, r.returncode, r.output, api.metadata, api.error)
 
 
+class Runner:
+    def __init__(self, info, name: Optional[str] = None) -> None:
+        self.info = info  # `meta.RunnerInfo`
+        self.name = name or os.path.basename(info.path)
+
+    @property
+    def path(self):
+        return self.info.path
+
+    @property
+    def exec(self):
+        return os.path.basename(self.info.path)
+
+
 class Pipeline:
-    def __init__(self, name: str, runner=None, build=None, source_epoch=None):
+    def __init__(self, name: str, runner: Runner, build=None, source_epoch=None):
         self.name = name
         self.build = build
         self.runner = runner
@@ -371,7 +385,11 @@ class Manifest:
         self.sources: List[Source] = []
 
     def add_pipeline(
-            self, name: str, runner: Optional[str], build: Optional[str] = None, source_epoch: Optional[int] = None
+        self,
+        name: str,
+        runner: Runner,
+        build: Optional[str] = None,
+        source_epoch: Optional[int] = None
     ) -> Pipeline:
         pipeline = Pipeline(name, runner, build, source_epoch)
         if name in self.pipelines:
diff --git a/runners/org.osbuild.fedora31 b/runners/org.osbuild.fedora31
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora31
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.fedora32 b/runners/org.osbuild.fedora32
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora32
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.fedora33 b/runners/org.osbuild.fedora33
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora33
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.fedora34 b/runners/org.osbuild.fedora34
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora34
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.fedora35 b/runners/org.osbuild.fedora35
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora35
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.fedora36 b/runners/org.osbuild.fedora36
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora36
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.fedora37 b/runners/org.osbuild.fedora37
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora37
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.fedora38 b/runners/org.osbuild.fedora38
deleted file mode 120000
index d46777b8..00000000
--- a/runners/org.osbuild.fedora38
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.fedora30
\ No newline at end of file
diff --git a/runners/org.osbuild.rhel83 b/runners/org.osbuild.rhel83
deleted file mode 120000
index fe28a86c..00000000
--- a/runners/org.osbuild.rhel83
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.rhel82
\ No newline at end of file
diff --git a/runners/org.osbuild.rhel84 b/runners/org.osbuild.rhel84
deleted file mode 120000
index fe28a86c..00000000
--- a/runners/org.osbuild.rhel84
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.rhel82
\ No newline at end of file
diff --git a/runners/org.osbuild.rhel85 b/runners/org.osbuild.rhel85
deleted file mode 120000
index fe28a86c..00000000
--- a/runners/org.osbuild.rhel85
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.rhel82
\ No newline at end of file
diff --git a/runners/org.osbuild.rhel86 b/runners/org.osbuild.rhel86
deleted file mode 120000
index fe28a86c..00000000
--- a/runners/org.osbuild.rhel86
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.rhel82
\ No newline at end of file
diff --git a/runners/org.osbuild.rhel87 b/runners/org.osbuild.rhel87
deleted file mode 120000
index fe28a86c..00000000
--- a/runners/org.osbuild.rhel87
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.rhel82
\ No newline at end of file
diff --git a/runners/org.osbuild.rhel90 b/runners/org.osbuild.rhel90
deleted file mode 120000
index ffc70521..00000000
--- a/runners/org.osbuild.rhel90
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.centos9
\ No newline at end of file
diff --git a/runners/org.osbuild.rhel91 b/runners/org.osbuild.rhel91
deleted file mode 120000
index 70c2aaf6..00000000
--- a/runners/org.osbuild.rhel91
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.rhel90
\ No newline at end of file
diff --git a/runners/org.osbuild.ubuntu2004 b/runners/org.osbuild.ubuntu2004
deleted file mode 120000
index 317be187..00000000
--- a/runners/org.osbuild.ubuntu2004
+++ /dev/null
@@ -1,1 +0,0 @@
-org.osbuild.ubuntu1804
\ No newline at end of file


Test patch:
diff --git a/test/mod/test_buildroot.py b/test/mod/test_buildroot.py
index 3782000c..84e0c9ff 100644
--- a/test/mod/test_buildroot.py
+++ b/test/mod/test_buildroot.py
@@ -9,9 +9,9 @@ from tempfile import TemporaryDirectory
 
 import pytest
 
+import osbuild.meta
 from osbuild.buildroot import BuildRoot
 from osbuild.monitor import LogMonitor, NullMonitor
-from osbuild.pipeline import detect_host_runner
 from osbuild.util import linux
 
 from ..test import TestBase
@@ -23,9 +23,15 @@ def tempdir_fixture():
         yield tmp
 
 
+@pytest.fixture(name="runner")
+def runner_fixture():
+    meta = osbuild.meta.Index(os.curdir)
+    runner = meta.detect_host_runner()
+    return runner.path
+
+
 @pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only")
-def test_basic(tempdir):
-    runner = detect_host_runner()
+def test_basic(tempdir, runner):
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
@@ -69,8 +75,7 @@ def test_runner_fail(tempdir):
 
 
 @pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only")
-def test_output(tempdir):
-    runner = detect_host_runner()
+def test_output(tempdir, runner):
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
@@ -88,8 +93,7 @@ def test_output(tempdir):
 
 @pytest.mark.skipif(not TestBase.have_test_data(), reason="no test-data access")
 @pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only")
-def test_bind_mounts(tempdir):
-    runner = detect_host_runner()
+def test_bind_mounts(tempdir, runner):
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
@@ -122,12 +126,11 @@ def test_bind_mounts(tempdir):
 
 @pytest.mark.skipif(not TestBase.have_test_data(), reason="no test-data access")
 @pytest.mark.skipif(not os.path.exists("/sys/fs/selinux"), reason="no SELinux")
-def test_selinuxfs_ro(tempdir):
+def test_selinuxfs_ro(tempdir, runner):
     # /sys/fs/selinux must never be writable in the container
     # because RPM and other tools must not assume the policy
     # of the host is the valid policy
 
-    runner = detect_host_runner()
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
@@ -148,8 +151,7 @@ def test_selinuxfs_ro(tempdir):
 
 
 @pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only")
-def test_proc_overrides(tempdir):
-    runner = detect_host_runner()
+def test_proc_overrides(tempdir, runner):
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
@@ -167,8 +169,7 @@ def test_proc_overrides(tempdir):
 
 
 @pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only")
-def test_timeout(tempdir):
-    runner = detect_host_runner()
+def test_timeout(tempdir, runner):
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
@@ -187,8 +188,7 @@ def test_timeout(tempdir):
 
 
 @pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only")
-def test_env_isolation(tempdir):
-    runner = detect_host_runner()
+def test_env_isolation(tempdir, runner):
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
@@ -229,8 +229,7 @@ def test_env_isolation(tempdir):
 
 
 @pytest.mark.skipif(not TestBase.can_bind_mount(), reason="root only")
-def test_caps(tempdir):
-    runner = detect_host_runner()
+def test_caps(tempdir, runner):
     libdir = os.path.abspath(os.curdir)
     var = pathlib.Path(tempdir, "var")
     var.mkdir()
diff --git a/test/mod/test_fmt_v1.py b/test/mod/test_fmt_v1.py
index 20f7bee7..4e84af30 100644
--- a/test/mod/test_fmt_v1.py
+++ b/test/mod/test_fmt_v1.py
@@ -57,7 +57,7 @@ BASIC_PIPELINE = {
                     }
                 ]
             },
-            "runner": "org.osbuild.test"
+            "runner": "org.osbuild.linux"
         },
         "stages": [
             {
@@ -141,7 +141,7 @@ class TestFormatV1(unittest.TestCase):
         pl = manifest["build"]
         have = pl.stages[0]
         want = build["pipeline"]["stages"][0]
-        self.assertEqual(pl.runner, runner)
+        self.assertEqual(pl.runner.name, runner)
         check_stage(have, want)
 
         runner = build["runner"]
@@ -150,14 +150,14 @@ class TestFormatV1(unittest.TestCase):
         pl = manifest["tree"]
         have = pl.stages[0]
         want = description["pipeline"]["stages"][0]
-        self.assertEqual(pl.runner, runner)
+        self.assertEqual(pl.runner.name, runner)
         check_stage(have, want)
 
         # the assembler pipeline
         pl = manifest["assembler"]
         have = pl.stages[0]
         want = description["pipeline"]["assembler"]
-        self.assertEqual(pl.runner, runner)
+        self.assertEqual(pl.runner.name, runner)
         check_stage(have, want)
 
     def test_describe(self):
@@ -211,6 +211,7 @@ class TestFormatV1(unittest.TestCase):
 
         self.assertIsNotNone(res)
         result = fmt.output(manifest, res)
+        print(result)
         self.assertIsNotNone(result)
         self.assertIn("success", result)
         self.assertFalse(result["success"])
@@ -231,7 +232,7 @@ class TestFormatV1(unittest.TestCase):
                             }
                         ]
                     },
-                    "runner": "org.osbuild.test",
+                    "runner": "org.osbuild.linux",
                     "stages": [
                         {
                             "name": "org.osbuild.noop"
diff --git a/test/mod/test_fmt_v2.py b/test/mod/test_fmt_v2.py
index e9e01ace..de8e92ba 100644
--- a/test/mod/test_fmt_v2.py
+++ b/test/mod/test_fmt_v2.py
@@ -212,13 +212,13 @@ class TestFormatV2(unittest.TestCase):
         self.assertIsNotNone(tree)
         self.assertIsNotNone(tree.build)
         self.assertEqual(tree.build, build.id)
-        self.assertEqual(tree.runner, "org.osbuild.linux")
+        self.assertEqual(tree.runner.name, "org.osbuild.linux")
 
         assembler = manifest["assembler"]
         self.assertIsNotNone(assembler)
         self.assertIsNotNone(assembler.build)
         self.assertEqual(assembler.build, build.id)
-        self.assertEqual(assembler.runner, "org.osbuild.linux")
+        self.assertEqual(assembler.runner.name, "org.osbuild.linux")
 
     def test_format_info(self):
         index = self.index
diff --git a/test/mod/test_meta.py b/test/mod/test_meta.py
new file mode 100644
index 00000000..93c809bb
--- /dev/null
+++ b/test/mod/test_meta.py
@@ -0,0 +1,177 @@
+import os
+from tempfile import TemporaryDirectory
+
+import pytest
+
+import osbuild
+import osbuild.meta
+
+
+@pytest.fixture(name="tempdir")
+def tempdir_fixture():
+    with TemporaryDirectory(prefix="meta-") as tmp:
+        yield tmp
+
+
+def create_runners(path, distro: str, base: str, versions):
+    """Create runner"""
+    runners = []
+
+    basename = f"{distro}{base}"
+    basepath = os.path.join(path, basename)
+    with open(basepath, "x", encoding="utf-8") as f:
+        f.write("#!/bin/bash")
+        runners.append(basename)
+
+    for x in versions:
+        name = f"{distro}{x}"
+        link = os.path.join(path, name)
+        os.symlink(basepath, link)
+        runners.append(name)
+
+    return runners
+
+
+def test_parse_name():
+    table = {
+        "arch": {
+            "distro": "arch",
+            "version": 0,
+        },
+        "fedora30": {
+            "distro": "fedora",
+            "version": 30,
+        },
+        "rhel7": {
+            "distro": "rhel",
+            "version": 7,
+        },
+        "ubuntu1804": {
+            "distro": "ubuntu",
+            "version": 1804,
+        }
+    }
+
+    for name, want in table.items():
+        d, v = osbuild.meta.RunnerInfo.parse_name(name)
+
+        assert d == want["distro"]
+        assert v == want["version"]
+
+
+def test_runner_detection(tempdir):
+
+    runners = os.path.join(tempdir, "runners")
+    os.makedirs(runners)
+
+    table = {
+        "arch": {
+            "base": "",
+            "versions": [],
+            "check": {"": 0},
+            "fail": []
+        },
+        "fedora": {
+            "base": 30,
+            "versions": list(range(31, 40)),
+            "check": {40: 39, 31: 31, 35: 35},
+            "fail": [29],
+        },
+        "ubuntu": {
+            "base": 1810,
+            "versions": [1904, 1910, 2004],
+            "check": {2010: 2004, 1912: 1910},
+            "fail": [1804],
+        },
+        "rhel": {
+            "base": 90,
+            "versions":  [91, 92, 93],
+            "check": {94: 93},
+        },
+        "future": {
+            "base": 100,
+            "versions":  [101, 102, 103],
+            "check": {110: 103},
+        }
+    }
+
+    want_all = []
+    for distro, info in table.items():
+        base = info["base"] or 0
+        versions = info["versions"]
+        want = create_runners(runners, distro, str(base), versions)
+        meta = osbuild.meta.Index(tempdir)
+        have = meta.list_runners(distro)
+        assert len(want) == len(have)
+        want_all += want
+
+        for v in [base] + versions:
+            name = f"{distro}{v}"
+            runner = meta.detect_runner(name)
+            assert runner
+            assert runner.distro == distro
+            assert runner.version == v
+
+        for v, t in info["check"].items():
+            name = f"{distro}{v}"
+            runner = meta.detect_runner(name)
+            assert runner
+            assert runner.distro == distro
+            assert runner.version == t
+
+        for v in info.get("fail", []):
+            name = f"{distro}{v}"
+            with pytest.raises(ValueError):
+                runner = meta.detect_runner(name)
+
+    have = meta.list_runners()
+    assert len(have) == len(want_all)
+
+
+def test_runner_sorting(tempdir):
+
+    runners = os.path.join(tempdir, "runners")
+    os.makedirs(runners)
+
+    table = {
+        "A": {
+            "base": 1,
+            "versions": [2, 3]
+        },
+        "B": {
+            "base": 1,
+            "versions": [2, 3]
+        }
+    }
+
+    for distro, info in table.items():
+        base = info["base"] or 0
+        versions = info["versions"]
+        create_runners(runners, distro, str(base), versions)
+
+    meta = osbuild.meta.Index(tempdir)
+    have = meta.list_runners()
+
+    names = [
+        f"{i.distro}{i.version}" for i in have
+    ]
+
+    assert names == ["A1", "A2", "A3", "B1", "B2", "B3"]
+
+
+def test_schema():
+    schema = osbuild.meta.Schema(None)
+    assert not schema
+
+    schema = osbuild.meta.Schema({"type": "bool"})  # should be 'boolean'
+    assert not schema.check().valid
+    assert not schema
+
+    schema = osbuild.meta.Schema({"type": "array", "minItems": 3})
+    assert schema.check().valid
+    assert schema
+
+    res = schema.validate([1, 2])
+    assert not res
+    res = schema.validate([1, 2, 3])
+    assert res
diff --git a/test/mod/test_monitor.py b/test/mod/test_monitor.py
index 3d30c7ea..4aa44a87 100644
--- a/test/mod/test_monitor.py
+++ b/test/mod/test_monitor.py
@@ -13,7 +13,7 @@ import osbuild
 import osbuild.meta
 from osbuild.monitor import LogMonitor
 from osbuild.objectstore import ObjectStore
-from osbuild.pipeline import detect_host_runner
+from osbuild.pipeline import Runner
 
 from .. import test
 
@@ -56,7 +56,7 @@ class TestMonitor(unittest.TestCase):
         # Checks the basic functioning of the LogMonitor
         index = osbuild.meta.Index(os.curdir)
 
-        runner = detect_host_runner()
+        runner = Runner(index.detect_host_runner())
         pipeline = osbuild.Pipeline("pipeline", runner=runner)
         info = index.get_module_info("Stage", "org.osbuild.noop")
         pipeline.add_stage(info, {
@@ -84,8 +84,8 @@ class TestMonitor(unittest.TestCase):
     @unittest.skipUnless(test.TestBase.can_bind_mount(), "root-only")
     def test_monitor_integration(self):
         # Checks the monitoring API is called properly from the pipeline
-        runner = detect_host_runner()
         index = osbuild.meta.Index(os.curdir)
+        runner = Runner(index.detect_host_runner())
 
         pipeline = osbuild.Pipeline("pipeline", runner=runner)
         noop_info = index.get_module_info("Stage", "org.osbuild.noop")
diff --git a/test/mod/test_osbuild.py b/test/mod/test_osbuild.py
index 245e6267..89f81cff 100644
--- a/test/mod/test_osbuild.py
+++ b/test/mod/test_osbuild.py
@@ -13,7 +13,7 @@ import osbuild
 import osbuild.meta
 from osbuild.monitor import NullMonitor
 from osbuild.objectstore import ObjectStore
-from osbuild.pipeline import Manifest, detect_host_runner
+from osbuild.pipeline import Manifest, Runner
 
 from .. import test
 
@@ -43,7 +43,7 @@ class TestDescriptions(unittest.TestCase):
             data = pathlib.Path(tmpdir, "data")
             storedir = pathlib.Path(tmpdir, "store")
             root = pathlib.Path("/")
-            runner = detect_host_runner()
+            runner = Runner(index.detect_host_runner())
             monitor = NullMonitor(sys.stderr.fileno())
             libdir = os.path.abspath(os.curdir)
             store = ObjectStore(storedir)
@@ -124,19 +124,21 @@ class TestDescriptions(unittest.TestCase):
     # pylint: disable=too-many-statements
     def test_on_demand(self):
         index = osbuild.meta.Index(os.curdir)
+        host_runner = Runner(index.detect_host_runner())
+        runner = Runner(index.detect_runner("org.osbuild.linux"))
 
         manifest = Manifest()
         noop = index.get_module_info("Stage", "org.osbuild.noop")
         noip = index.get_module_info("Input", "org.osbuild.noop")
 
         # the shared build pipeline
-        build = manifest.add_pipeline("build", None, None)
+        build = manifest.add_pipeline("build", host_runner, None)
         build.add_stage(noop, {"option": 1})
 
         # a pipeline simulating some intermediate artefact
         # that other pipeline need as dependency
         dep = manifest.add_pipeline("dep",
-                                    "org.osbuild.linux",
+                                    runner,
                                     build.id)
 
         dep.add_stage(noop, {"option": 2})
@@ -146,7 +148,7 @@ class TestDescriptions(unittest.TestCase):
         # not be built unless explicitly requested
         # has an input that depends on `dep`
         ul = manifest.add_pipeline("unlinked",
-                                   "org.osbuild.linux",
+                                   runner,
                                    build.id)
 
         stage = ul.add_stage(noop, {"option": 3})
@@ -155,14 +157,14 @@ class TestDescriptions(unittest.TestCase):
 
         # the main os root file system
         rootfs = manifest.add_pipeline("rootfs",
-                                       "org.osbuild.inux",
+                                       runner,
                                        build.id)
         stage = rootfs.add_stage(noop, {"option": 4})
 
         # the main raw image artefact, depending on "dep" and
         # "rootfs"
         image = manifest.add_pipeline("image",
-                                      "org.osbuild.inux",
+                                      runner,
                                       build.id)
 
         stage = image.add_stage(noop, {"option": 5})
@@ -177,7 +179,7 @@ class TestDescriptions(unittest.TestCase):
 
         # some compression of the image, like a qcow2
         qcow2 = manifest.add_pipeline("qcow2",
-                                      "org.osbuild.inux",
+                                      runner,
                                       build.id)
         stage = qcow2.add_stage(noop, {"option": 7})
         ip = stage.add_input("image", noip, "org.osbuild.pipeline")
@@ -289,20 +291,3 @@ class TestDescriptions(unittest.TestCase):
         for version in ["1", "2"]:
             with self.subTest(version=version):
                 self.check_moduleinfo(version)
-
-    def test_schema(self):
-        schema = osbuild.meta.Schema(None)
-        self.assertFalse(schema)
-
-        schema = osbuild.meta.Schema({"type": "bool"})  # should be 'boolean'
-        self.assertFalse(schema.check().valid)
-        self.assertFalse(schema)
-
-        schema = osbuild.meta.Schema({"type": "array", "minItems": 3})
-        self.assertTrue(schema.check().valid)
-        self.assertTrue(schema)
-
-        res = schema.validate([1, 2])
-        self.assertFalse(res)
-        res = schema.validate([1, 2, 3])
-        self.assertTrue(res)