Repository: osbuild/osbuild
Pull request #1658 (2024-03-12 11:21:33)

stages: add support for  options to bootc-install-to-fs
To start using the `bootc.instal-to-filesystem` stage we need
to be able to test the generated images. This requires a login
on the disk. Traditionally we did that via the `users` stage.

But on a bootc system we do not want to modify the disk after
bootc did the install to avoid messing with things like the
selinux labels or (future) fsverity setups.

So for now we will use the `--root-ssh-authorized-keys` feature
to inject support for login.

This PR adds two options:
- support injecting the root ssh keys via --root-ssh-authorized-keys
- support adding kernel-options via  --kopt 

The last one is not strictly needed and we could do without it for now (can be a separate PR or YAGNI)

[edit: https://github.com/osbuild/images/pull/462 is updated for this too]


Patch:
diff --git a/.ruff.toml b/.ruff.toml
index 20122a7a..7b8950db 100644
--- a/.ruff.toml
+++ b/.ruff.toml
@@ -1,5 +1,10 @@
 line-length = 120
 
+extend-exclude = [
+    "*.json",
+]
+force-exclude = true
+
 [lint]
 # enabled the `isort` and `pylint` additional linters, see `ruff linter` output
 # or: https://docs.astral.sh/ruff/rules/
diff --git a/Schutzfile b/Schutzfile
index 349965ba..896106cf 100644
--- a/Schutzfile
+++ b/Schutzfile
@@ -120,14 +120,14 @@
           {
             "title": "updates",
             "name": "updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-x86_64-updates-released-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-x86_64-updates-released-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "updates",
             "name": "updates",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-aarch64-updates-released-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-aarch64-updates-released-20240313"
           }
         ]
       },
@@ -137,14 +137,14 @@
           {
             "title": "updates-modular",
             "name": "updates-modular",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-x86_64-updates-released-modular-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-x86_64-updates-released-modular-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "updates-modular",
             "name": "updates-modular",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-aarch64-updates-released-modular-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-aarch64-updates-released-modular-20240313"
           }
         ]
       }
@@ -179,14 +179,14 @@
           {
             "title": "fedora",
             "name": "fedora",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-x86_64-branched-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-x86_64-branched-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "fedora",
             "name": "fedora",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-aarch64-branched-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/f40/f40-aarch64-branched-20240313"
           }
         ]
       }
@@ -200,34 +200,34 @@
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-baseos-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-baseos-20240313"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-appstream-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-appstream-20240313"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-crb-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-x86_64-crb-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-baseos-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-baseos-20240313"
           },
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-appstream-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-appstream-20240313"
           },
           {
             "title": "crb",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-crb-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/cs9-aarch64-crb-20240313"
           }
         ]
       }
@@ -241,34 +241,34 @@
           {
             "title": "RHEL-8-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-baseos-n8.10-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-baseos-n8.10-20240313"
           },
           {
             "title": "RHEL-8-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-appstream-n8.10-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-appstream-n8.10-20240313"
           },
           {
             "title": "RHEL-8-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-crb-n8.10-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-crb-n8.10-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "RHEL-8-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-baseos-n8.10-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-baseos-n8.10-20240313"
           },
           {
             "title": "RHEL-8-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-appstream-n8.10-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-appstream-n8.10-20240313"
           },
           {
             "title": "RHEL-8-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-crb-n8.10-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-crb-n8.10-20240313"
           }
         ]
       }
@@ -282,34 +282,34 @@
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.4-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.4-20240313"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.4-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.4-20240313"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.4-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.4-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.4-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.4-20240313"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.4-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.4-20240313"
           },
           {
             "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
             "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.4-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.4-20240313"
           }
         ]
       }
@@ -323,14 +323,14 @@
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-x86_64-baseos-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-x86_64-baseos-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "baseos",
             "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-aarch64-baseos-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-aarch64-baseos-20240313"
           }
         ]
       },
@@ -340,14 +340,14 @@
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-x86_64-appstream-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-x86_64-appstream-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "appstream",
             "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-aarch64-appstream-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-aarch64-appstream-20240313"
           }
         ]
       },
@@ -357,14 +357,14 @@
           {
             "title": "powertools",
             "name": "powertools",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-x86_64-powertools-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-x86_64-powertools-20240313"
           }
         ],
         "aarch64": [
           {
             "title": "powertools",
             "name": "powertoosl",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-aarch64-powertools-20240301"
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/cs8-aarch64-powertools-20240313"
           }
         ]
       }
diff --git a/docs/osbuild.1.rst b/docs/osbuild.1.rst
index 82a2e0ad..bee04638 100644
--- a/docs/osbuild.1.rst
+++ b/docs/osbuild.1.rst
@@ -92,6 +92,42 @@ monitor that is used. Valid types are:
         each stage. The output is not machine-readable and is interspersed
         with the individual log messages of the stages.
         This is the default monitor if ``--json`` was **not** specified.
+``JSONSeqMonitor``
+        A json-sequence (RFC7464) based live monitor of the pipeline execution.
+	Each line contains valid json and a record-separator (0x1E) at the
+	end. The json consists of a `progress` object that contains a
+	`context` object describes the context of the progress and details
+	about the progress. Progress can be nested and a progress object
+	may contain sub-progress. In addition each progress can emit
+	`message` strings that contain detailed log information from
+	the stage. An example:
+
+	| {
+	|   "message": "Starting module org.osbuild.grub2.inst",
+	|   "context": {
+	|     "origin": "osbuild.monitor",
+	|     "pipeline": {
+	|       "name": "image",
+	|       "id": "b3e63a8ef3cd30be244ffe1749af05783014bf2c61b2e45f09bb6df0ef122ad8",
+	|       "stage": {
+	|         "name": "org.osbuild.grub2.inst",
+	|         "id": "b3e63a8ef3cd30be244ffe1749af05783014bf2c61b2e45f09bb6df0ef122ad8"
+	|       }
+	|     },
+	|     "id": "747c04fa9265e7de79ac5efae43c62375f4b6bb778cb24abd7963dc181139176"
+	|   },
+	|   "progress": {
+	|     "name": "pipelines/sources",
+	|     "total": 4,
+	|     "done": 3,
+	|     "progress": {
+	|       "name": "stages",
+	|       "total": 7,
+	|       "done": 6
+	|     }
+	|   },
+	|   "timestamp": 1710318022.3872378
+	| }
 
 Monitor output is written to the file-descriptor provided via
 ``--monitor-fd=NUM``. If none was specified, standard output is used.
diff --git a/osbuild.spec b/osbuild.spec
index 6763075f..24661ecb 100644
--- a/osbuild.spec
+++ b/osbuild.spec
@@ -1,7 +1,7 @@
 %global         forgeurl https://github.com/osbuild/osbuild
 %global         selinuxtype targeted
 
-Version:        112
+Version:        113
 
 %forgemeta
 
diff --git a/osbuild/__init__.py b/osbuild/__init__.py
index 6d298654..971d7463 100644
--- a/osbuild/__init__.py
+++ b/osbuild/__init__.py
@@ -10,7 +10,7 @@ independent of osbuild but used across the osbuild codebase.
 
 from .pipeline import Manifest, Pipeline, Stage
 
-__version__ = "112"
+__version__ = "113"
 
 __all__ = [
     "Manifest",
diff --git a/osbuild/buildroot.py b/osbuild/buildroot.py
index 9e16053b..1ceb06be 100644
--- a/osbuild/buildroot.py
+++ b/osbuild/buildroot.py
@@ -198,6 +198,7 @@ class BuildRoot(contextlib.AbstractContextManager):
         if not self._exitstack:
             raise RuntimeError("No active context")
 
+        stage_name = os.path.basename(argv[0])
         mounts = []
 
         # Import directories from the caller-provided root.
@@ -335,6 +336,8 @@ class BuildRoot(contextlib.AbstractContextManager):
         READ_ONLY = select.POLLIN | select.POLLPRI | select.POLLHUP | select.POLLERR
         poller = select.poll()
         poller.register(proc.stdout.fileno(), READ_ONLY)
+
+        stage_origin = os.path.join("stages", stage_name)
         while True:
             buf = self.read_with_timeout(proc, poller, start, timeout)
             if not buf:
@@ -342,12 +345,12 @@ class BuildRoot(contextlib.AbstractContextManager):
 
             txt = buf.decode("utf-8")
             data.write(txt)
-            monitor.log(txt)
+            monitor.log(txt, origin=stage_origin)
 
         poller.unregister(proc.stdout.fileno())
         buf, _ = proc.communicate()
         txt = buf.decode("utf-8")
-        monitor.log(txt)
+        monitor.log(txt, origin=stage_origin)
         data.write(txt)
         output = data.getvalue()
         data.close()
diff --git a/osbuild/loop.py b/osbuild/loop.py
index 7f602191..2e05638e 100644
--- a/osbuild/loop.py
+++ b/osbuild/loop.py
@@ -15,9 +15,6 @@ __all__ = [
     "UnexpectedDevice"
 ]
 
-# can be mocked in tests
-DEV_PATH = "/dev"
-
 
 class UnexpectedDevice(Exception):
     def __init__(self, expected_minor, rdev, mode):
@@ -127,14 +124,8 @@ class Loop:
 
         with contextlib.ExitStack() as stack:
             if not dir_fd:
-                dir_fd = os.open(DEV_PATH, os.O_DIRECTORY)
+                dir_fd = os.open("/dev", os.O_DIRECTORY)
                 stack.callback(lambda: os.close(dir_fd))
-            # ensure the device node is availale, in containers it may
-            # not get dynamically created
-            try:
-                self.mknod(dir_fd)
-            except FileExistsError:
-                pass
             self.fd = os.open(self.devname, os.O_RDWR, dir_fd=dir_fd)
 
         info = os.stat(self.fd)
@@ -543,7 +534,7 @@ class LoopControl:
 
         with contextlib.ExitStack() as stack:
             if not dir_fd:
-                dir_fd = os.open(DEV_PATH, os.O_DIRECTORY)
+                dir_fd = os.open("/dev", os.O_DIRECTORY)
                 stack.callback(lambda: os.close(dir_fd))
 
             self.fd = os.open("loop-control", os.O_RDWR, dir_fd=dir_fd)
diff --git a/osbuild/main_cli.py b/osbuild/main_cli.py
index 172afe8d..570e7a0c 100644
--- a/osbuild/main_cli.py
+++ b/osbuild/main_cli.py
@@ -163,7 +163,8 @@ def osbuild_cli() -> int:
     monitor_name = args.monitor
     if not monitor_name:
         monitor_name = "NullMonitor" if args.json else "LogMonitor"
-    monitor = osbuild.monitor.make(monitor_name, args.monitor_fd)
+    monitor = osbuild.monitor.make(monitor_name, args.monitor_fd, manifest)
+    monitor.log(f"starting {args.manifest_path}", origin="osbuild.main_cli")
 
     try:
         with ObjectStore(args.store) as object_store:
@@ -185,6 +186,11 @@ def osbuild_cli() -> int:
                 debug_break,
                 stage_timeout=stage_timeout
             )
+            if r["success"]:
+                monitor.log(f"manifest {args.manifest_path} finished successfully", origin="osbuild.main_cli")
+            else:
+                # if we had monitor.error() we could use that here
+                monitor.log(f"manifest {args.manifest_path} failed", origin="osbuild.main_cli")
 
             if r["success"] and exports:
                 for pid in exports:
diff --git a/osbuild/meta.py b/osbuild/meta.py
index 50c7c6cf..44cbe3cd 100644
--- a/osbuild/meta.py
+++ b/osbuild/meta.py
@@ -275,6 +275,54 @@ class Schema:
         return self.check().valid
 
 
+META_JSON_SCHEMA = {
+    "type": "object",
+    "additionalProperties": False,
+    "propertyNames": {
+        "not": {
+            "const": "description",
+        },
+    },
+    "required": ["summary", "description"],
+    "oneOf": [
+        {
+            "required": [
+                "schema"
+            ]
+        },
+        {
+            "required": [
+                "schema_2"
+            ]
+        },
+    ],
+    "properties": {
+        "summary": {
+            "type": "string",
+        },
+        "description": {
+            "type": "array",
+            "minItems": 1,
+            "items": {
+                "type": "string",
+            },
+        },
+        "capabilities": {
+            "type": "array",
+            "items": {
+                "type": "string",
+            },
+        },
+        "schema": {
+            "type": "object",
+        },
+        "schema_2": {
+            "type": "object",
+        }
+    }
+}
+
+
 class ModuleInfo:
     """Meta information about a stage
 
@@ -409,6 +457,54 @@ class ModuleInfo:
 
     @classmethod
     def load(cls, root, klass, name) -> Optional["ModuleInfo"]:
+        base = cls.MODULES.get(klass)
+        if not base:
+            raise ValueError(f"Unsupported type: {klass}")
+        path = os.path.join(root, base, name)
+
+        try:
+            return cls._load_from_json(path, klass, name)
+        except FileNotFoundError:
+            pass
+        return cls._load_from_py(path, klass, name)
+
+    @classmethod
+    def _load_from_json(cls, path, klass, name) -> Optional["ModuleInfo"]:
+        meta_json_suffix = ".meta.json"
+        with open(path + meta_json_suffix, encoding="utf-8") as fp:
+            try:
+                meta = json.load(fp)
+            except json.decoder.JSONDecodeError as e:
+                raise SyntaxError("Invalid schema: " + str(e)) from e
+
+        schema = Schema(META_JSON_SCHEMA, "meta.json validator")
+        res = schema.validate(meta)
+        if not res.valid:
+            # the python code is very leaniant with invalid schemas
+            # so just print a warning here for now to stay close to
+            # what the old code was doing
+            errs = res.as_dict()["errors"]
+            # it would be nice to have a proper logger here
+            print(f"WARNING: schema for {path} is invalid: {errs}", file=sys.stderr)
+            return None
+
+        long_description = meta.get("description", "no description provided")
+        if isinstance(long_description, list):
+            long_description = "\n".join(long_description)
+
+        info = {
+            "schema": {
+                "1": meta.get("schema", {}),
+                "2": meta.get("schema_2", {}),
+            },
+            "desc": meta.get("summary", "no summary provided"),
+            "info": long_description,
+            "caps": meta.get("capabilities", set()),
+        }
+        return cls(klass, name, path, info)
+
+    @classmethod
+    def _load_from_py(cls, path, klass, name) -> Optional["ModuleInfo"]:
         names = ["SCHEMA", "SCHEMA_2", "CAPABILITIES"]
 
         def filter_type(lst, target):
@@ -417,11 +513,6 @@ class ModuleInfo:
         def targets(a):
             return [t.id for t in filter_type(a.targets, ast.Name)]
 
-        base = cls.MODULES.get(klass)
-        if not base:
-            raise ValueError(f"Unsupported type: {klass}")
-
-        path = os.path.join(root, base, name)
         try:
             with open(path, encoding="utf8") as f:
                 data = f.read()
@@ -434,6 +525,8 @@ class ModuleInfo:
 
         docstring = ast.get_docstring(tree)
         doclist = docstring.split("\n") if docstring else []
+        summary = doclist[0] if len(doclist) > 0 else ""
+        long_description = "\n".join(doclist[1:]) if len(doclist) > 0 else ""
 
         assigns = filter_type(tree.body, ast.Assign)
         values = {
@@ -454,8 +547,8 @@ class ModuleInfo:
                 "1": parse_schema(values.get("SCHEMA")),
                 "2": parse_schema(values.get("SCHEMA_2")),
             },
-            'desc': doclist[0],
-            'info': "\n".join(doclist[1:]),
+            'desc': summary,
+            'info': long_description,
             'caps': parse_caps(values.get("CAPABILITIES"))
         }
         return cls(klass, name, path, info)
@@ -585,7 +678,7 @@ class Index:
             raise ValueError(f"Unsupported nodule class: {klass}")
 
         path = os.path.join(self.path, module_path)
-        modules = filter(lambda f: os.path.isfile(f"{path}/{f}"),
+        modules = filter(lambda f: os.path.isfile(f"{path}/{f}") and not path.endswith(".meta.json"),
                          os.listdir(path))
         return list(modules)
 
diff --git a/osbuild/monitor.py b/osbuild/monitor.py
index 9741a415..f0491914 100644
--- a/osbuild/monitor.py
+++ b/osbuild/monitor.py
@@ -9,17 +9,177 @@ are called on the monitor object at certain events. Consult the
 """
 
 import abc
+import copy
 import datetime
+import hashlib
 import json
 import os
 import sys
 import time
-from typing import Dict
+from typing import Dict, Optional, Set
 
 import osbuild
 from osbuild.util.term import fmt as vt
 
 
+def omitempty(d: dict):
+    """ Omit None and empty string ("") values from the given dict """
+    for k, v in list(d.items()):
+        if v is None or v == "":
+            del d[k]
+        elif isinstance(v, dict):
+            omitempty(v)
+    return d
+
+
+class Context:
+    """Context for a single log entry. Automatically calculates hash/id when read."""
+
+    def __init__(self,
+                 origin: Optional[str] = None,
+                 pipeline: Optional[osbuild.Pipeline] = None,
+                 stage: Optional[osbuild.Stage] = None):
+        self._origin = origin
+        self._pipeline_name = pipeline.name if pipeline else None
+        self._pipeline_id = pipeline.id if pipeline else None
+        self._stage_name = stage.name if stage else None
+        self._stage_id = stage.id if stage else None
+        self._id = None
+        self._id_history: Set[str] = set()
+
+    def __setattr__(self, name, value):
+        super().__setattr__(name, value)
+        # reset "_id" on any write so that the hash is automatically recalculated
+        if name != "_id":
+            super().__setattr__("_id", None)
+
+    def with_origin(self, origin: Optional[str]) -> "Context":
+        """
+        Return a Context with the given origin but otherwise identical.
+
+        Note that if the origin is empty or same it will return self.
+        """
+        if origin is None or origin == self._origin:
+            return self
+        ctx = copy.copy(self)
+        ctx.origin = origin
+        return ctx
+
+    @property
+    def origin(self):
+        return self._origin
+
+    @origin.setter
+    def origin(self, origin: str):
+        self._origin = origin
+
+    @property
+    def pipeline_name(self):
+        return self._pipeline_name
+
+    @property
+    def pipeline_id(self):
+        return self._pipeline_id
+
+    def set_pipeline(self, pipeline: osbuild.Pipeline):
+        self._pipeline_name = pipeline.name
+        self._pipeline_id = pipeline.id
+
+    @property
+    def stage_name(self):
+        return self._stage_name
+
+    @property
+    def stage_id(self):
+        return self._stage_id
+
+    def set_stage(self, stage: osbuild.Stage):
+        self._stage_name = stage.name
+        self._stage_id = stage.id
+
+    @property
+    def id(self):
+        if self._id is None:
+            self._id = hashlib.sha256(
+                json.dumps(self._dict(), sort_keys=True).encode()).hexdigest()
+        return self._id
+
+    def _dict(self):
+        return {
+            "origin": self._origin,
+            "pipeline": {
+                "name": self._pipeline_name,
+                "id": self._pipeline_id,
+                "stage": {
+                    "name": self._stage_name,
+                    "id": self._stage_id,
+                },
+            },
+        }
+
+    def as_dict(self):
+        d = self._dict()
+        ctxid = self.id
+        if ctxid in self._id_history:
+            return {"id": self.id}
+        d["id"] = self.id
+        self._id_history.add(self.id)
+        return d
+
+
+class Progress:
+    """Progress represents generic progress information.
+
+    A progress can contain a sub_progress to track more
+    nested progresses. Any increment of a parent progress
+    will the reset the sub_progress to None and a new
+    sub_progress needs to be provided.
+
+    Keyword arguments:
+    name  -- user visible name for the progress
+    total -- total steps required to finish the progress
+    """
+
+    def __init__(self, name: str, total: int):
+        self.name = name
+        self.total = total
+        self.done = 0
+        self.sub_progress: Optional[Progress] = None
+
+    def incr(self):
+        """Increment the "done" count"""
+        self.done += 1
+        if self.sub_progress:
+            self.sub_progress = None
+
+    def as_dict(self):
+        d = {
+            "name": self.name,
+            "total": self.total,
+            "done": self.done,
+        }
+        if self.sub_progress:
+            d["progress"] = self.sub_progress.as_dict()
+        return d
+
+
+def log_entry(message: Optional[str] = None,
+              context: Optional[Context] = None,
+              progress: Optional[Progress] = None) -> dict:
+    """
+    Create a single log entry dict with a given message, context, and progress objects.
+    All arguments are optional. A timestamp is added to the message.
+    """
+    # we probably want to add an (optional) error message here too once the
+    # monitors support that
+    return omitempty({
+        "message": message,
+        "context": context.as_dict() if context else None,
+        "progress": progress.as_dict() if progress else None,
+        "timestamp": time.time(),
+    })
+
+
 class TextWriter:
     """Helper class for writing text to file descriptors"""
 
@@ -51,9 +211,10 @@ class TextWriter:
 class BaseMonitor(abc.ABC):
     """Base class for all pipeline monitors"""
 
-    def __init__(self, fd: int):
+    def __init__(self, fd: int, manifest: Optional[osbuild.Manifest] = None) -> None:
         """Logging will be done to file descriptor `fd`"""
         self.out = TextWriter(fd)
+        self.manifest = manifest
 
     def begin(self, pipeline: osbuild.Pipeline):
         """Called once at the beginning of a pipeline"""
@@ -70,7 +231,7 @@ class BaseMonitor(abc.ABC):
     def result(self, result: osbuild.pipeline.BuildResult):
         """Called when a module (stage/assembler) is done with its result"""
 
-    def log(self, message: str):
+    def log(self, message: str, origin: Optional[str] = None):
         """Called for all module log outputs"""
 
 
@@ -90,8 +251,8 @@ class LogMonitor(BaseMonitor):
     sequences will be used to highlight sections of the log.
     """
 
-    def __init__(self, fd: int):
-        super().__init__(fd)
+    def __init__(self, fd: int, manifest: Optional[osbuild.Manifest] = None):
+        super().__init__(fd, manifest)
         self.timer_start = 0
 
     def result(self, result):
@@ -108,7 +269,8 @@ class LogMonitor(BaseMonitor):
             self.out.write(pipeline.build)
         else:
             self.out.write("<host>")
-        self.out.write(f"\n  runner: {pipeline.runner.name} ({pipeline.runner.exec})")
+        if pipeline.runner:
+            self.out.write(f"\n  runner: {pipeline.runner.name} ({pipeline.runner.exec})")
         source_epoch = pipeline.source_epoch
         if source_epoch is not None:
             timepoint = datetime.datetime.fromtimestamp(source_epoch).strftime('%c')
@@ -139,15 +301,63 @@ class LogMonitor(BaseMonitor):
 
         self.timer_start = time.time()
 
-    def log(self, message):
+    def log(self, message, origin: Optional[str] = None):
         self.out.write(message)
 
 
-def make(name, fd):
+class JSONSeqMonitor(BaseMonitor):
+    """Monitor that prints the log output of modules wrapped in json-seq objects with context and progress metadata"""
+
+    def __init__(self, fd: int, manifest: osbuild.Manifest):
+        super().__init__(fd, manifest)
+        self._ctx_ids: Set[str] = set()
+        self._progress = Progress("pipelines/sources", len(manifest.pipelines) + len(manifest.sources))
+        self._context = Context(origin="org.osbuild")
+
+    def begin(self, pipeline: osbuild.Pipeline):
+        self._context.set_pipeline(pipeline)
+        if pipeline.stages:
+            self._progress.sub_progress = Progress("stages", len(pipeline.stages))
+        self.log(f"Starting pipeline {pipeline.name}", origin="osbuild.monitor")
+
+    # finish is for pipelines
+    def finish(self, results: dict):
+        self._progress.incr()
+        self.log(f"Finished pipeline {results['name']}", origin="osbuild.monitor")
+
+    def stage(self, stage: osbuild.Stage):
+        self._module(stage)
+
+    def assembler(self, assembler: osbuild.Stage):
+        self._module(assembler)
+
+    def _module(self, module: osbuild.Stage):
+        self._context.set_stage(module)
+        self.log(f"Starting module {module.name}", origin="osbuild.monitor")
+
+    # result is for modules
+    def result(self, result: osbuild.pipeline.BuildResult):
+        # we may need to check pipeline ids here in the future
+        if self._progress.sub_progress:
+            self._progress.sub_progress.incr()
+        self.log(f"Finished module {result.name}", origin="osbuild.monitor")
+
+    def log(self, message, origin: Optional[str] = None):
+        entry = log_entry(message, self._context.with_origin(origin), self._progress)
+        self._jsonseq(entry)
+
+    def _jsonseq(self, entry):
+        # follow rfc7464 (application/json-seq)
+        self.out.write("\x1e")
+        json.dump(entry, self.out)
+        self.out.write("\n")
+
+
+def make(name, fd, manifest):
     module = sys.modules[__name__]
     monitor = getattr(module, name, None)
     if not monitor:
         raise ValueError(f"Unknown monitor: {name}")
     if not issubclass(monitor, BaseMonitor):
         raise ValueError(f"Invalid monitor: {name}")
-    return monitor(fd)
+    return monitor(fd, manifest)
diff --git a/osbuild/pipeline.py b/osbuild/pipeline.py
index af4c3944..af88142c 100644
--- a/osbuild/pipeline.py
+++ b/osbuild/pipeline.py
@@ -294,7 +294,7 @@ class Pipeline:
         return stage
 
     def build_stages(self, object_store, monitor, libdir, debug_break="", stage_timeout=None):
-        results = {"success": True}
+        results = {"success": True, "name": self.name}
 
         # If there are no stages, just return here
         if not self.stages:
@@ -412,7 +412,11 @@ class Manifest:
     def download(self, store, monitor, libdir):
         with host.ServiceManager(monitor=monitor) as mgr:
             for source in self.sources:
+                # Workaround for lack of progress from sources, this
+                # will need to be reworked later.
+                monitor.begin(source)
                 source.download(mgr, store, libdir)
+                monitor.finish({"name": source.info.name})
 
     def depsolve(self, store: ObjectStore, targets: Iterable[str]) -> List[str]:
         """Return the list of pipelines that need to be built
diff --git a/osbuild/sources.py b/osbuild/sources.py
index 95659100..491b986a 100644
--- a/osbuild/sources.py
+++ b/osbuild/sources.py
@@ -1,5 +1,6 @@
 import abc
 import contextlib
+import hashlib
 import json
 import os
 import tempfile
@@ -19,6 +20,10 @@ class Source:
         self.info = info
         self.items = items or {}
         self.options = options
+        # compat with pipeline
+        self.build = None
+        self.runner = None
+        self.source_epoch = None
 
     def download(self, mgr: host.ServiceManager, store: ObjectStore, libdir: PathLike):
         source = self.info.name
@@ -47,6 +52,32 @@ class Source:
             f.seek(0)
             yield f.fileno()
 
+    # "name", "id", "stages", "results" is only here to make it looks like a
+    # pipeline for the monitor. This should be revisited at some point
+    # and maybe the monitor should get first-class support for
+    # sources?
+    #
+    # In any case, sources can be represented only poorly right now
+    # by the monitor because the source is called with download()
+    # for all items and there is no way for a stage right now to
+    # report something structured back to the host that runs the
+    # source so it just downloads all sources without any user
+    # visible progress right now
+    @property
+    def name(self):
+        return f"source {self.info.name}"
+
+    @property
+    def id(self):
+        m = hashlib.sha256()
+        m.update(json.dumps(self.info.name, sort_keys=True).encode())
+        m.update(json.dumps(self.items, sort_keys=True).encode())
+        return m.hexdigest()
+
+    @property
+    def stages(self):
+        return []
+
 
 class SourceService(host.Service):
     """Source host service"""
diff --git a/setup.py b/setup.py
index 574bc6b6..c9a1eae4 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@ import setuptools
 
 setuptools.setup(
     name="osbuild",
-    version="112",
+    version="113",
     description="A build system for OS images",
     packages=["osbuild", "osbuild.formats", "osbuild.util"],
     license='Apache-2.0',
diff --git a/sources/org.osbuild.curl b/sources/org.osbuild.curl
index 5967867b..86ce1eae 100755
--- a/sources/org.osbuild.curl
+++ b/sources/org.osbuild.curl
@@ -192,6 +192,13 @@ class CurlSource(sources.SourceService):
                 os.rename(f"{tmpdir}/{checksum}", f"{self.cache}/{checksum}")
             except FileExistsError:
                 pass
+            # Workaround the lack of structured progress reporting from
+            # stages/sources. It generates messages of the form
+            #   "message": "source/org.osbuild.curl (org.osbuild.curl): Downloaded https://rpmrepo.osbuild.org/v2/mirror/public/f38/f38-x86_64-fedora-20230413/Packages/f/fonts-srpm-macros-2.0.5-11.fc38.noarch.rpm\n
+            #
+            # Without it just a long pause with no progress while curl
+            # downloads.
+            print(f"Downloaded {url}")
 
 
 def main():
diff --git a/stages/org.osbuild.bootc.install-to-filesystem b/stages/org.osbuild.bootc.install-to-filesystem
index 19ca4d69..2494b726 100755
--- a/stages/org.osbuild.bootc.install-to-filesystem
+++ b/stages/org.osbuild.bootc.install-to-filesystem
@@ -11,6 +11,7 @@ Buildhost commands used: bootc
 
 import subprocess
 import sys
+import tempfile
 
 import osbuild.api
 from osbuild.util import containers
@@ -30,7 +31,23 @@ SCHEMA_2 = r"""
   }
 },
 "options": {
-  "additionalProperties": false
+  "additionalProperties": false,
+  "properties": {
+    "root-ssh-authorized-keys": {
+      "description": "array of SSH Public Keys to add to roots authorized_keys",
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
+    "kernel-args": {
+      "description": "array of additional kernel arguments",
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    }
+  }
 },
 "devices": {
   "type": "object",
@@ -42,23 +59,33 @@ SCHEMA_2 = r"""
 """
 
 
-def main(inputs, paths):
+def main(options, inputs, paths):
     images = containers.parse_containers_input(inputs)
     assert len(images) == 1
     image = list(images.values())[0]
 
     with containers.container_source(image) as (_, source):
         dst = paths["mounts"]
-        subprocess.run(
-            ["bootc", "install", "to-filesystem",
-             "--source-imgref", source,
-             "--skip-fetch-check", "--generic-image",
-             dst],
-            check=True,
-        )
+        pargs = ["bootc", "install", "to-filesystem",
+                 "--source-imgref", source,
+                 "--skip-fetch-check", "--generic-image"]
+        # customize root
+        root_ssh_keys = options.get("root-ssh-authorized-keys", [])
+        if root_ssh_keys:
+            tmpf = tempfile.NamedTemporaryFile(prefix="bootc-ssh-auth-keys-")
+            for key in root_ssh_keys:
+                tmpf.write(key.encode("utf8") + b"\n")
+            tmpf.flush()
+            pargs.extend(["--root-ssh-authorized-keys", tmpf.name])
+        # customize kernel-args
+        for karg in options.get("kernel-args", []):
+            pargs.extend(["--karg", karg])
+        # add target and go
+        pargs.append(dst)
+        subprocess.run(pargs, check=True)
 
 
 if __name__ == "__main__":
     args = osbuild.api.arguments()
-    r = main(args["inputs"], args["paths"])
+    r = main(args["options"], args["inputs"], args["paths"])
     sys.exit(r)
diff --git a/stages/org.osbuild.noop b/stages/org.osbuild.noop
index c44e7606..85f04570 100755
--- a/stages/org.osbuild.noop
+++ b/stages/org.osbuild.noop
@@ -2,8 +2,7 @@
 """
 Do Nothing
 
-No-op stage. Prints a JSON dump of the options passed into this stage and
-leaves the tree untouched. Useful for testing, debugging, and wasting time.
+See "org.osbuild.noop.meta.json" for more details.
 """
 
 
@@ -12,21 +11,6 @@ import sys
 
 import osbuild.api
 
-SCHEMA_2 = """
-"options": {
-  "additionalProperties": true
-},
-"devices": {
-  "additionalProperties": true
-},
-"inputs": {
-  "additionalProperties": true
-},
-"mounts": {
-  "additionalProperties": true
-}
-"""
-
 
 def main(_tree, inputs, options):
     print("Not doing anything with these options:", json.dumps(options))
diff --git a/stages/org.osbuild.noop.meta.json b/stages/org.osbuild.noop.meta.json
new file mode 100644
index 00000000..aa9ab33a
--- /dev/null
+++ b/stages/org.osbuild.noop.meta.json
@@ -0,0 +1,23 @@
+{
+    "summary": "Do Nothing",
+    "description": [
+        "No-op stage.\n",
+        "Prints a JSON dump of the options passed into this stage and ",
+        "leaves the tree untouched. Useful for testing, debugging, and ",
+        "wasting time."
+    ],
+    "schema_2": {
+        "options": {
+            "additionalProperties": true
+        },
+        "devices": {
+            "additionalProperties": true
+        },
+        "inputs": {
+            "additionalProperties": true
+        },
+        "mounts": {
+            "additionalProperties": true
+        }
+    }
+}
diff --git a/tools/osbuild-json-seq-progress-example-renderer b/tools/osbuild-json-seq-progress-example-renderer
new file mode 100755
index 00000000..c1fc9e41
--- /dev/null
+++ b/tools/osbuild-json-seq-progress-example-renderer
@@ -0,0 +1,91 @@
+#!/usr/bin/python3
+#
+# example how to use the json-seq rendering
+#
+
+import json
+import sys
+
+import tqdm
+
+
+class TqdmProgressRenderer:
+    BAR_FMT = "{desc} ({n_fmt}/{total_fmt}): {percentage:3.0f}%|{bar}|{elapsed}"
+
+    def __init__(self, inf, outf):
+        self._pbar = None
+        self._sub_pbar = None
+        self._inf = inf
+        self._outf = outf
+        self._last_done = 0
+        self._last_sub_done = 0
+
+    def _read_json_seq_rec(self):
+        # *sigh* we really should be using a proper json-seq reader here
+        while True:
+            line = self._inf.readline()
+            if not line:
+                return None
+            try:
+                payload = json.loads(line.strip("\x1e"))
+            except json.JSONDecodeError:
+                self.warn(f"WARN: invalid json: {line}")
+                continue
+            return payload
+
+    def warn(self, warn):
+        if self._pbar:
+            self._pbar.write(warn)
+        else:
+            print(warn, file=self._outf)
+
+    def _init_pbar(self, pbar_name, total, pos):
+        pbar = getattr(self, pbar_name, None)
+        if pbar is not None:
+            return
+        pbar = tqdm.tqdm(total=total, position=pos, bar_format=self.BAR_FMT)
+        setattr(self, pbar_name, pbar)
+
+    def render(self):
+        while True:
+            js = self._read_json_seq_rec()
+            if js is None:
+                return
+
+            # main progress
+            main_progress = js.get("progress", {})
+            total = main_progress.get("total", 0)
+            self._init_pbar("_pbar", total, pos=0)
+
+            ctx = js["context"]
+            pipeline_name = ctx.get("pipeline", {}).get("name")
+            if pipeline_name:
+                self._pbar.set_description(f"Pipeline {pipeline_name}")
+            done = main_progress.get("done", 0)
+            if self._last_done < done:
+                self._pbar.update()
+                self._last_done = done
+                # reset sub-progress
+                self._last_sub_done = 0
+                self._sub_pbar = None
+
+            # sub progress
+            sub_progress = main_progress.get("progress")
+            if sub_progress:
+                total = sub_progress.get("total")
+                self._init_pbar("_sub_pbar", total, pos=1)
+                stage_name = ctx.get("pipeline", {}).get("stage", {}).get("name")
+                if stage_name:
+                    self._sub_pbar.set_description(f"Stage {stage_name}")
+                sub_done = sub_progress.get("done", 0)
+                if self._last_sub_done < sub_done:
+                    self._sub_pbar.update()
+                    self._last_sub_done = sub_done
+
+            # (naively) handle messages (could decorate with origin)
+            self._pbar.write(js.get("message", "").strip())
+
+
+if __name__ == "__main__":
+    prg = TqdmProgressRenderer(sys.stdin, sys.stdout)
+    prg.render()
diff --git a/tox.ini b/tox.ini
index eb07aed0..dbaf7a94 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,11 +1,11 @@
 [tox]
 env_list =
-    py{36,37,38,39,310,311}
+    py{36,37,38,39,310,311,312}
     lint
     type
 
 labels =
-    test = py{36,37,38,39,310,311}
+    test = py{36,37,38,39,310,311,312}
     lint = ruff, autopep8, pylint
     type = mypy,mypy-strict
 
@@ -22,10 +22,11 @@ deps =
     pykickstart
     # required by pykickstart but not pulled in automatically :/
     requests
-    tox-backticks
 
 setenv =
-    LINTABLES = osbuild/ assemblers/* devices/* inputs/*.* mounts/* runners/* sources/*.* stages/*.* inputs/test/*.py stages/test/*.py sources/test/*.py test/ `find ./tools ! -name "*.sh" -type f`
+    LINTABLES = osbuild/ assemblers/* devices/* inputs/*.* mounts/* runners/* sources/*.* stages/*.* inputs/test/*.py stages/test/*.py sources/test/*.py test/ tools/
+    LINTABLES_EXCLUDES = "*.json,*.sh"
+    LINTABLES_EXCLUDES_RE = ".*\.json$,.*\.sh"
     TYPEABLES = osbuild
     TYPEABLES_STRICT = ./osbuild/main_cli.py
 
@@ -47,18 +48,18 @@ commands =
 
 [testenv:autopep8]
 deps =
-    autopep8==2.0.2
-    pycodestyle==2.10.0
+    autopep8==2.0.4
+    pycodestyle==2.11.0
 
 commands =
-    bash -c 'python -m autopep8 --diff --max-line-length 120 -a -a -a -j0 -r --exit-code {env:LINTABLES}'
+    bash -c 'python -m autopep8 --diff --max-line-length 120 -a -a -a -j0 -r --exclude {env:LINTABLES_EXCLUDES} --exit-code {env:LINTABLES}'
 
 [testenv:pylint]
 deps =
     pylint==3.0.2
 
 commands =
-    bash -c 'python -m pylint {env:LINTABLES}'
+    bash -c 'python -m pylint --ignore-patterns {env:LINTABLES_EXCLUDES_RE} {env:LINTABLES}'
 
 [testenv:mypy]
 deps =


Test patch:
diff --git a/stages/test/test_bootc_install_to_fs.py b/stages/test/test_bootc_install_to_fs.py
index 4d424aa8..be9c48aa 100644
--- a/stages/test/test_bootc_install_to_fs.py
+++ b/stages/test/test_bootc_install_to_fs.py
@@ -1,17 +1,65 @@
 #!/usr/bin/python3
 
-import os.path
+import tempfile
 from contextlib import contextmanager
-from unittest.mock import call, patch
+from unittest.mock import Mock, call, patch
 
-from osbuild.testutil.imports import import_module_from_path
+import pytest
 
+STAGE_NAME = "org.osbuild.bootc.install-to-filesystem"
 
-@patch("subprocess.run")
-def test_bootc_install_to_fs(mock_run, tmp_path):
-    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.bootc.install-to-filesystem")
-    stage = import_module_from_path("bootc_install_to_fs_stage", stage_path)
 
+@pytest.fixture(name="mocked_named_tmp")
+def mocked_named_tmp_fixture():
+    fake_named_tmp = Mock()
+    fake_named_tmp.name = "/tmp/fake-named-tmpfile-name"
+    with patch("tempfile.NamedTemporaryFile", return_value=fake_named_tmp):
+        yield
+
+
+@pytest.fixture(name="mocked_temp_dir")
+def mocked_temp_dir_fixture(tmp_path):
+    @contextmanager
+    def _tmp_dir():
+        yield tmp_path
+    with patch("tempfile.TemporaryDirectory", side_effect=_tmp_dir):
+        yield tmp_path
+
+
+FAKE_INPUTS = {
+    "images": {
+        "path": "/input/images/path",
+        "data": {
+            "archives": {
+                "/input/images/path": {
+                    "format": "oci-archive",
+                    "name": "some-img-name",
+                },
+            },
+        },
+    }
+}
+
+
+@pytest.mark.parametrize("options,expected_args", [
+    ({}, []),
+    # root-ssh
+    ({"root-ssh-authorized-keys": []}, []),
+    ({"root-ssh-authorized-keys": ["ssh-key"]}, ["--root-ssh-authorized-keys", "/tmp/fake-named-tmpfile-name"]),
+    ({"root-ssh-authorized-keys": ["key1", "key2"]}, ["--root-ssh-authorized-keys", "/tmp/fake-named-tmpfile-name"]),
+    # kernel args
+    ({"kernel-args": []}, []),
+    ({"kernel-args": ["console=ttyS0"]}, ["--karg", "console=ttyS0"]),
+    ({"kernel-args": ["arg1", "arg2"]}, ["--karg", "arg1", "--karg", "arg2"]),
+    # all
+    ({"root-ssh-authorized-keys": ["key1", "key2"],
+      "kernel-args": ["arg1", "arg2"]},
+     ["--root-ssh-authorized-keys", "/tmp/fake-named-tmpfile-name",
+      "--karg", "arg1", "--karg", "arg2"],
+     ),
+])
+@patch("subprocess.run")
+def test_bootc_install_to_fs(mock_run, mocked_named_tmp, mocked_temp_dir, stage_module, options, expected_args):  # pylint: disable=unused-argument
     inputs = {
         "images": {
             "path": "/input/images/path",
@@ -29,16 +77,29 @@ def test_bootc_install_to_fs(mock_run, tmp_path):
         "mounts": "/path/to/mounts",
     }
 
-    @contextmanager
-    def faked_tmp_dir():
-        yield tmp_path
-    with patch("tempfile.TemporaryDirectory", side_effect=faked_tmp_dir):
-        stage.main(inputs, paths)
+    stage_module.main(options, inputs, paths)
 
     assert len(mock_run.call_args_list) == 1
     assert mock_run.call_args_list == [
         call(["bootc", "install", "to-filesystem",
-              "--source-imgref", f"oci-archive:{tmp_path}/image",
+              "--source-imgref", f"oci-archive:{mocked_temp_dir}/image",
               "--skip-fetch-check", "--generic-image",
-              "/path/to/mounts"], check=True)
+              ] + expected_args + ["/path/to/mounts"],
+             check=True)
     ]
+
+
+@patch("subprocess.run")
+def test_bootc_install_to_fs_write_root_ssh_keys(mock_run, stage_module):  # pylint: disable=unused-argument
+    paths = {
+        "mounts": "/path/to/mounts",
+    }
+    options = {
+        "root-ssh-authorized-keys": ["key1", "key2"],
+    }
+
+    named_tmp = tempfile.NamedTemporaryFile(delete=False)
+    with patch("tempfile.NamedTemporaryFile", return_value=named_tmp):
+        stage_module.main(options, FAKE_INPUTS, paths)
+        with open(named_tmp.name, encoding="utf8") as fp:
+            assert "key1\nkey2\n" == fp.read()
diff --git a/test/mod/test_loop.py b/test/mod/test_loop.py
index fb052122..a6684915 100644
--- a/test/mod/test_loop.py
+++ b/test/mod/test_loop.py
@@ -5,7 +5,6 @@
 import contextlib
 import fcntl
 import os
-import pathlib
 import threading
 import time
 from tempfile import TemporaryDirectory, TemporaryFile
@@ -261,13 +260,3 @@ def test_on_close(tempdir):
 def test_loop_handles_error_in_init(mocked_open):  # pylint: disable=unused-argument
     with pytest.raises(FileNotFoundError):
         loop.Loop(999)
-
-
-@pytest.mark.skipif(os.getuid() != 0, reason="root only")
-def test_loop_create_mknod():
-    # tmpdir must be /var/tmp because /tmp is usually mounted with "nodev"
-    with TemporaryDirectory(dir="/var/tmp") as tmpdir:
-        with patch.object(loop, "DEV_PATH", new=tmpdir):
-            lopo = loop.Loop(1337)
-            assert lopo.devname == "loop1337"
-            assert pathlib.Path(f"{tmpdir}/loop1337").is_block_device()
diff --git a/test/mod/test_meta.py b/test/mod/test_meta.py
index b7ced843..0294c501 100644
--- a/test/mod/test_meta.py
+++ b/test/mod/test_meta.py
@@ -1,4 +1,7 @@
 import os
+import pathlib
+import re
+import textwrap
 
 import pytest
 
@@ -168,3 +171,185 @@ def test_schema():
     assert not res
     res = schema.validate([1, 2, 3])
     assert res
+
+
+def make_fake_meta_json(tmp_path, name, version, invalid=""):
+    meta_json_path = pathlib.Path(f"{tmp_path}/stages/{name}.meta.json")
+    meta_json_path.parent.mkdir(exist_ok=True)
+    if version == 1:
+        schema_part = """
+        "schema": {
+          "properties": {
+            "json_filename": {
+              "type": "string"
+            }
+          }
+        }
+        """
+    elif version == 2:
+        schema_part = """
+        "schema_2": {
+          "json_devices": {
+            "type": "object"
+          }
+        }
+        """
+    else:
+        pytest.fail(f"unexpected schema version {version}")
+
+    if invalid == "json":
+        schema_part = """
+        "xxx": {not-json}"'
+        """
+    elif invalid == "schema":
+        schema_part = """
+        "not": "following schema"
+        """
+
+    meta_json_path.write_text("""
+    {
+      "summary": "some json summary",
+      "description": [
+        "long text",
+        "with newlines"
+      ],
+      "capabilities": ["CAP_MAC_ADMIN", "CAP_BIG_MAC"],
+      %s
+    }
+    """.replace("\n", " ") % schema_part, encoding="utf-8")
+    return meta_json_path
+
+
+def make_fake_py_module(tmp_path, name, invalid=""):
+    py_path = pathlib.Path(f"{tmp_path}/stages/{name}")
+    py_path.parent.mkdir(exist_ok=True)
+    fake_py = '"""some py summary\nlong description\nwith newline"""'
+    if invalid == "json":
+        fake_py += textwrap.dedent("""
+        SCHEMA = '"xxx": {not-json}"'
+        """)
+    elif invalid == "schema":
+        fake_py += textwrap.dedent("""
+        noT = "following schema"
+        """)
+    else:
+        fake_py += textwrap.dedent("""
+        SCHEMA = '"properties": {"py_filename":{"type": "string"}}'
+        SCHEMA_2 = '"py_devices": {"type":"object"}'
+        CAPABILITIES = ['CAP_MAC_ADMIN']
+    """)
+    py_path.write_text(fake_py, encoding="utf-8")
+
+
+# keep the tests consistent between loading from python and json
+def test_load_from_json(tmp_path):
+    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=1)
+    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
+    assert modinfo.desc == "some json summary"
+    assert modinfo.info == "long text\nwith newlines"
+    assert modinfo.caps == ["CAP_MAC_ADMIN", "CAP_BIG_MAC"]
+    assert modinfo.opts == {
+        "1": {"properties": {"json_filename": {"type": "string"}}},
+        "2": {},
+    }
+
+
+def test_load_from_json_invalid_json(tmp_path):
+    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=2, invalid="json")
+    with pytest.raises(SyntaxError) as exc:
+        osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
+    assert str(exc.value) == "Invalid schema: Expecting property name enclosed in double quotes: line 2 column 17 (char 201)"
+
+
+def test_load_from_json_invalid_schema(tmp_path, capsys):
+    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=2, invalid="schema")
+    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
+    assert modinfo is None
+    assert re.match(r"WARNING: schema for .* is invalid: ", capsys.readouterr().err)
+
+
+def test_load_from_py(tmp_path):
+    make_fake_py_module(tmp_path, "org.osbuild.noop")
+    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
+    assert modinfo.desc == "some py summary"
+    assert modinfo.info == "long description\nwith newline"
+    assert modinfo.caps == set(["CAP_MAC_ADMIN"])
+    assert modinfo.opts == {
+        "1": {"properties": {"py_filename": {"type": "string"}}},
+        "2": {"py_devices": {"type": "object"}},
+    }
+
+
+def test_load_from_py_invalid_json(tmp_path):
+    make_fake_py_module(tmp_path, "org.osbuild.noop", invalid="json")
+    with pytest.raises(SyntaxError) as exc:
+        osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
+    assert str(exc.value) == "Invalid schema: Expecting property name enclosed in double quotes (org.osbuild.noop, line 4)"
+
+
+def test_load_from_py_invalid_schema(tmp_path):
+    make_fake_py_module(tmp_path, "org.osbuild.noop", invalid="schema")
+    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
+    assert modinfo.desc == "some py summary"
+    assert modinfo.info == "long description\nwith newline"
+    assert modinfo.opts == {
+        "1": {},
+        "2": {},
+    }
+
+
+def test_load_from_json_prefered(tmp_path):
+    make_fake_meta_json(tmp_path, "org.osbuild.noop", version=2)
+    make_fake_py_module(tmp_path, "org.osbuild.noop")
+    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
+    assert modinfo.desc == "some json summary"
+    assert modinfo.info == "long text\nwith newlines"
+    assert modinfo.caps == ["CAP_MAC_ADMIN", "CAP_BIG_MAC"]
+    assert modinfo.opts == {
+        "1": {},
+        "2": {"json_devices": {"type": "object"}},
+    }
+
+
+@pytest.mark.parametrize("test_input,expected_err", [
+    # happy
+    (
+        {"summary": "some", "description": ["many", "strings"], "schema": {}}, ""), (
+        {"summary": "some", "description": ["many", "strings"], "schema_2": {}}, ""), (
+        {"summary": "some", "description": ["many", "strings"], "schema_2": {}, "capabilities": []}, ""), (
+        {"summary": "some", "description": ["many", "strings"], "schema_2": {}, "capabilities": ["cap1", "cap2"]}, ""),
+    # unhappy
+    (
+        # no description
+        {"summary": "some", "schema": {}},
+        "'description' is a required property",
+    ), (
+        # no summary
+        {"description": ["yes"], "schema": {}},
+        "'summary' is a required property",
+    ), (
+        # schema{,_2} missing
+        {"summary": "some", "description": ["many", "strings"]},
+        " is not valid",
+    ),
+    (
+        # both schema{,_2}
+        {"summary": "some", "description": ["many", "strings"],
+         "schema": {}, "schema_2": {}},
+        " is valid under each ",
+    ), (
+        # capabilities of wrong type
+        {"summary": "some", "description": ["many", "strings"], "schema": {},
+         "capabilities": [1, "cap1"]},
+        " is not of type 'string'",
+    ),
+])
+def test_meta_json_validation_schema(test_input, expected_err):
+    schema = osbuild.meta.Schema(osbuild.meta.META_JSON_SCHEMA, "meta.json validator")
+    res = schema.validate(test_input)
+    errs = [e.as_dict() for e in res.errors]
+    if expected_err:
+        assert len(errs) == 1, f"unexpected error {errs}"
+        assert expected_err in errs[0]["message"]
+    else:
+        assert not errs
diff --git a/test/mod/test_monitor.py b/test/mod/test_monitor.py
index 53c978ee..75fb9ac6 100644
--- a/test/mod/test_monitor.py
+++ b/test/mod/test_monitor.py
@@ -3,15 +3,17 @@
 #
 
 import io
+import json
 import os
 import sys
 import tempfile
+import time
 import unittest
 from collections import defaultdict
 
 import osbuild
 import osbuild.meta
-from osbuild.monitor import LogMonitor
+from osbuild.monitor import Context, JSONSeqMonitor, LogMonitor, Progress, log_entry
 from osbuild.objectstore import ObjectStore
 from osbuild.pipeline import Runner
 
@@ -45,7 +47,7 @@ class TapeMonitor(osbuild.monitor.BaseMonitor):
         self.counter["result"] += 1
         self.results.add(result.id)
 
-    def log(self, message: str):
+    def log(self, message: str, origin: str = None):
         self.counter["log"] += 1
         self.logger.write(message)
 
@@ -114,3 +116,205 @@ class TestMonitor(unittest.TestCase):
         self.assertIn(pipeline.stages[0].id, tape.stages)
         self.assertIn("isthisthereallife", tape.output)
         self.assertIn("isthisjustfantasy", tape.output)
+
+
+def test_context():
+    index = osbuild.meta.Index(os.curdir)
+
+    runner = Runner(index.detect_host_runner())
+    pipeline = osbuild.Pipeline(name="test-pipeline", runner=runner)
+    index = osbuild.meta.Index(os.curdir)
+    info = index.get_module_info("Stage", "org.osbuild.noop")
+    stage = osbuild.Stage(info, {}, None, None, {}, None)
+    ctx = Context("org.osbuild.test", pipeline, stage)
+    assert ctx.id == "75bf3feab3d5662744c3ac38406ba73142aeb67666b1180bc1006f913b18f792"
+
+    ctx_dict = ctx.as_dict()
+    # should be a full dict
+    assert "origin" in ctx_dict
+    assert ctx_dict["id"] == "75bf3feab3d5662744c3ac38406ba73142aeb67666b1180bc1006f913b18f792"
+    assert "pipeline" in ctx_dict
+    assert ctx_dict["pipeline"]["name"] == "test-pipeline"
+    assert ctx_dict["pipeline"]["stage"]["name"] == "org.osbuild.noop"
+
+    ctx_dict = ctx.as_dict()
+    # should only have id
+    assert ctx_dict["id"] == "75bf3feab3d5662744c3ac38406ba73142aeb67666b1180bc1006f913b18f792"
+    assert len(ctx_dict) == 1
+
+    ctx.origin = "org.osbuild.test-2"
+    ctx_dict = ctx.as_dict()
+    # should be a full dict again
+    assert "origin" in ctx_dict
+    assert "pipeline" in ctx_dict
+    assert ctx_dict["pipeline"]["name"] == "test-pipeline"
+    assert ctx_dict["pipeline"]["stage"]["name"] == "org.osbuild.noop"
+
+    ctx.origin = "org.osbuild.test"
+    ctx_dict = ctx.as_dict()
+    # should only have id again (old context ID)
+    assert ctx_dict["id"] == "75bf3feab3d5662744c3ac38406ba73142aeb67666b1180bc1006f913b18f792"
+    assert len(ctx_dict) == 1
+
+
+def test_progress():
+    prog = Progress("test", total=12)
+    prog.sub_progress = Progress("test-sub1", total=3)
+    # we start empty
+    progdict = prog.as_dict()
+    assert progdict["done"] == 0
+    assert progdict["progress"]["done"] == 0
+
+    # incr a sub_progress only affect sub_progress
+    prog.sub_progress.incr()
+    progdict = prog.as_dict()
+    assert progdict["done"] == 0
+    assert progdict["progress"]["done"] == 1
+
+    prog.sub_progress.incr()
+    progdict = prog.as_dict()
+    assert progdict["done"] == 0
+    assert progdict["progress"]["done"] == 2
+
+    prog.incr()
+    progdict = prog.as_dict()
+    assert progdict["done"] == 1
+    assert progdict.get("progress") is None, "sub-progress did not reset"
+
+
+# pylint: disable=too-many-statements
+def test_json_progress_monitor():
+    index = osbuild.meta.Index(os.curdir)
+    info = index.get_module_info("Stage", "org.osbuild.noop")
+    fake_noop_stage = osbuild.pipeline.Stage(info, None, None, None, None, None)
+
+    manifest = osbuild.Manifest()
+    pl1 = manifest.add_pipeline("test-pipeline-first", "", "")
+    first_stage = pl1.add_stage(info, {})
+    pl1.add_stage(info, {})
+
+    pl2 = manifest.add_pipeline("test-pipeline-second", "", "")
+    pl2.add_stage(info, {})
+    pl2.add_stage(info, {})
+    manifest.add_pipeline(pl2, "", "")
+
+    with tempfile.TemporaryFile() as tf:
+        mon = JSONSeqMonitor(tf.fileno(), manifest)
+        mon.log("test-message-1")
+        mon.log("test-message-2", origin="test.origin.override")
+        mon.begin(manifest.pipelines["test-pipeline-first"])
+        mon.log("pipeline 1 message 1")
+        mon.stage(first_stage)
+        mon.log("pipeline 1 message 2")
+        mon.log("pipeline 1 finished", origin="org.osbuild")
+        mon.result(osbuild.pipeline.BuildResult(
+            fake_noop_stage, returncode=0, output="output", error=None))
+        mon.finish({"success": True, "name": "test-pipeline-first"})
+        mon.begin(manifest.pipelines["test-pipeline-second"])
+        mon.log("pipeline 2 starting", origin="org.osbuild")
+        mon.log("pipeline 2 message 2")
+
+        tf.seek(0)
+        log = tf.read().decode().strip().split("\x1e")
+
+        expected_total = 12
+        assert len(log) == expected_total
+        i = 0
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "test-message-1"
+        assert logitem["context"]["origin"] == "org.osbuild"
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "test-message-2"
+        assert logitem["context"]["origin"] == "test.origin.override"
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "Starting pipeline test-pipeline-first"
+        assert logitem["context"]["pipeline"]["name"] == "test-pipeline-first"
+        # empty items are omited
+        assert "name" not in logitem["context"]["pipeline"]["stage"]
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "pipeline 1 message 1"
+        assert logitem["context"]["origin"] == "org.osbuild"
+        assert logitem["context"]["pipeline"]["name"] == "test-pipeline-first"
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "Starting module org.osbuild.noop"
+        assert logitem["context"]["origin"] == "osbuild.monitor"
+        assert logitem["context"]["pipeline"]["name"] == "test-pipeline-first"
+        assert logitem["context"]["pipeline"]["stage"]["name"] == "org.osbuild.noop"
+        id_start_module = logitem["context"]["id"]
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "pipeline 1 message 2"
+        assert logitem["context"]["origin"] == "org.osbuild"
+        assert logitem["context"]["pipeline"]["name"] == "test-pipeline-first"
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "pipeline 1 finished"
+        prev_ctx_id = json.loads(log[i - 1])["context"]["id"]
+        assert logitem["context"]["id"] == prev_ctx_id
+        assert len(logitem["context"]) == 1
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "Finished module org.osbuild.noop"
+        assert logitem["context"]["id"] == id_start_module
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "Finished pipeline test-pipeline-first"
+        assert logitem["context"]["id"] == id_start_module
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "Starting pipeline test-pipeline-second"
+        assert logitem["context"]["origin"] == "osbuild.monitor"
+        assert logitem["context"]["pipeline"]["name"] == "test-pipeline-second"
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "pipeline 2 starting"
+        assert logitem["context"]["origin"] == "org.osbuild"
+        assert logitem["context"]["pipeline"]["name"] == "test-pipeline-second"
+        i += 1
+
+        logitem = json.loads(log[i])
+        assert logitem["message"] == "pipeline 2 message 2"
+        prev_ctx_id = json.loads(log[i - 1])["context"]["id"]
+        assert logitem["context"]["id"] == prev_ctx_id
+        i += 1
+
+        assert i == expected_total
+
+
+def test_log_line_empty_is_fine():
+    empty = log_entry()
+    assert len(empty) == 1
+    assert empty["timestamp"] > time.time() - 60
+    assert empty["timestamp"] < time.time() + 60
+
+
+def test_log_line_with_entries():
+    ctx = Context("some-origin")
+    progress = Progress(name="foo", total=2)
+    entry = log_entry("some-msg", ctx, progress)
+    assert len(entry) == 4
+    assert entry["message"] == "some-msg"
+    assert isinstance(entry["context"], dict)
+    assert isinstance(entry["progress"], dict)
+    assert entry["timestamp"] > 0
+
+
+def test_context_id():
+    ctx = Context()
+    assert ctx.id == "20bf38c0723b15c2c9a52733c99814c298628526d8b8eabf7c378101cc9a9cf3"
+    ctx._origin = "foo"  # pylint: disable=protected-access
+    assert ctx.id != "00d202e4fc9d917def414d1c9f284b137287144087ec275f2d146d9d47b3c8bb"