Repository: osbuild/osbuild
Pull request #1138 (2022-10-10 15:29:42)

sources/ostree: pull from remote using rhsm mTLS certs
The consumer certs are used to uniquely identify a system against candlepin. These consumer certs can be used to identify the system when pulling from RH controlled ostree repositories.

Gundersanne: Idk what's up with the codecov check :/ 

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/osbuild/util/rhsm.py b/osbuild/util/rhsm.py
index 1b170c52..32872d4b 100644
--- a/osbuild/util/rhsm.py
+++ b/osbuild/util/rhsm.py
@@ -38,6 +38,20 @@ class Subscriptions:
 
         raise RuntimeError("no matching rhsm key and cert")
 
+    @staticmethod
+    def get_consumer_secrets():
+        """Returns the consumer identity certificate which uniquely identifies the system"""
+        key = "/etc/pki/consumer/cert.pem"
+        cert = "/etc/pki/consumer/key.pem"
+
+        if not (os.path.exists(key) and os.path.exists(cert)):
+            raise RuntimeError("rhsm consumer key and cert not found")
+
+        return {
+            'consumer_key': key,
+            'consumer_cert': cert
+        }
+
     @classmethod
     def from_host_system(cls):
         """Read redhat.repo file and process the list of repositories in there."""
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
diff --git a/sources/org.osbuild.ostree b/sources/org.osbuild.ostree
index 2c826c27..be8411c7 100755
--- a/sources/org.osbuild.ostree
+++ b/sources/org.osbuild.ostree
@@ -14,6 +14,7 @@ import uuid
 
 from osbuild import sources
 from osbuild.util.ostree import show
+from osbuild.util.rhsm import Subscriptions
 
 SCHEMA = """
 "additionalProperties": false,
@@ -43,6 +44,19 @@ SCHEMA = """
                   "type": "string",
                   "description": "GPG keys to verify the commits"
                 }
+              },
+              "secrets": {
+                "type": "object",
+                "additionalProperties": false,
+                "required": [
+                  "name"
+                ],
+                "properties": {
+                  "name": {
+                    "type": "string",
+                    "description": "Name of the secrets provider."
+                  }
+                }
               }
             }
           }
@@ -89,13 +103,18 @@ class OSTreeSource(sources.SourceService):
         gpg = remote.get("gpgkeys", [])
         uid = str(uuid.uuid4())
 
-        verify_args = []
+        remote_add_args = []
         if not gpg:
-            verify_args = ["--no-gpg-verify"]
+            remote_add_args = ["--no-gpg-verify"]
+
+        if remote.get("secrets", {}).get("name") == "org.osbuild.rhsm.consumer":
+            secrets = Subscriptions.get_consumer_secrets()
+            remote_add_args.append("--tls-client-key-path=%s", secrets['consumer_key'])
+            remote_add_args.append("--tls-client-cert-path=%s", secrets['consumer_cert'])
 
         ostree("remote", "add",
                uid, url,
-               *verify_args,
+               *remote_add_args,
                repo=self.repo)
 
         for key in gpg:


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)