Repository: osbuild/osbuild
Pull request #1657 (2024-03-11 19:52:22)

test: return container_id in `make_container`
The current `make_container()` helper is a bit silly (which is entirely my fault). It requires a container tag as input but all tests end up creating a random number for this input. So instead just remove the input and return the container_id from the podman build in the contextmanager and use that.


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/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/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/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/inputs/test/test_containers.py b/inputs/test/test_containers.py
index 72dfc45c..5417e69a 100644
--- a/inputs/test/test_containers.py
+++ b/inputs/test/test_containers.py
@@ -2,9 +2,7 @@
 
 import os
 import pathlib
-import random
 import socket
-import string
 import subprocess
 import tempfile
 
@@ -29,8 +27,7 @@ class FakeStoreClient:
 @pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
 @pytest.mark.skipif(os.getuid() != 0, reason="root only")
 def test_containers_local_inputs_integration(tmp_path, inputs_module):
-    base_tag = "container-" + "".join(random.choices(string.digits, k=12))
-    with make_container(tmp_path, base_tag, {"file1": "file1 content"}):
+    with make_container(tmp_path, {"file1": "file1 content"}) as base_tag:
         image_id = subprocess.check_output(
             ["podman", "inspect", "-f", "{{ .Id }}", base_tag],
             universal_newlines=True).strip()
diff --git a/osbuild/testutil/__init__.py b/osbuild/testutil/__init__.py
index 3c947804..f6c4eb6b 100644
--- a/osbuild/testutil/__init__.py
+++ b/osbuild/testutil/__init__.py
@@ -81,8 +81,9 @@ def mock_command(cmd_name: str, script: str):
 
 
 @contextlib.contextmanager
-def make_container(tmp_path, tag, fake_content, base="scratch"):
+def make_container(tmp_path, fake_content, base="scratch"):
     fake_container_src = tmp_path / "fake-container-src"
+    fake_container_src.mkdir(exist_ok=True)
     make_fake_tree(fake_container_src, fake_content)
     fake_containerfile_path = fake_container_src / "Containerfile"
     container_file_content = f"""
@@ -90,16 +91,22 @@ def make_container(tmp_path, tag, fake_content, base="scratch"):
     COPY . .
     """
     fake_containerfile_path.write_text(container_file_content, encoding="utf8")
-    subprocess.check_call([
+    p = subprocess.Popen([
         "podman", "build",
         "--no-cache",
         "-f", os.fspath(fake_containerfile_path),
-        "-t", tag,
-    ])
+    ], universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+    while True:
+        line = p.stdout.readline()
+        if line == "":
+            break
+        print(line)
+        container_id = line.strip()
+    p.wait()
     try:
-        yield
+        yield container_id
     finally:
-        subprocess.check_call(["podman", "image", "rm", tag])
+        subprocess.check_call(["podman", "image", "rm", container_id])
 
 
 @contextlib.contextmanager
diff --git a/sources/test/test_container_storage_source.py b/sources/test/test_container_storage_source.py
index 5acc7ba8..7eeec1ce 100644
--- a/sources/test/test_container_storage_source.py
+++ b/sources/test/test_container_storage_source.py
@@ -1,9 +1,7 @@
 #!/usr/bin/python3
 
 import os
-import random
 import socket
-import string
 import subprocess
 
 import pytest
@@ -16,10 +14,9 @@ SOURCES_NAME = "org.osbuild.containers-storage"
 @pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
 @pytest.mark.skipif(os.getuid() != 0, reason="root only")
 def test_containers_storage_integration(tmp_path, sources_module):
-    base_tag = "container-" + "".join(random.choices(string.digits, k=12))
-    with make_container(tmp_path, base_tag, {
+    with make_container(tmp_path, {
         "file1": "file1 content",
-    }):
+    }) as base_tag:
         image_id = subprocess.check_output(["podman", "inspect", "-f", "{{ .Id }}", base_tag],
                                            universal_newlines=True).strip()
         checksum = f"sha256:{image_id}"
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/stages/test/test_container_deploy.py b/stages/test/test_container_deploy.py
index fb18c438..02a11795 100644
--- a/stages/test/test_container_deploy.py
+++ b/stages/test/test_container_deploy.py
@@ -2,8 +2,6 @@
 
 import os
 import os.path
-import random
-import string
 import subprocess
 import textwrap
 from unittest.mock import call, patch
@@ -21,10 +19,8 @@ STAGE_NAME = "org.osbuild.container-deploy"
 def test_container_deploy_integration(tmp_path, stage_module):
     # build two containers and overlay files to test for
     # https://github.com/containers/storage/issues/1779
-    base_tag = "cont-base-" + "".join(random.choices(string.digits, k=12))
-    with make_container(tmp_path, base_tag, {"file1": "file1 from base"}):
-        cont_tag = "cont" + "".join(random.choices(string.digits, k=12))
-        with make_container(tmp_path, cont_tag, {"file1": "file1 from final layer"}, base_tag):
+    with make_container(tmp_path, {"file1": "file1 from base"}) as base_tag:
+        with make_container(tmp_path, {"file1": "file1 from final layer"}, base_tag) as cont_tag:
             # export for the container-deploy stage
             fake_container_dst = tmp_path / "fake-container"
             subprocess.check_call([
@@ -63,13 +59,12 @@ def test_container_deploy_integration(tmp_path, stage_module):
 @pytest.mark.skipif(os.getuid() != 0, reason="needs root")
 @pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
 def test_container_deploy_exclude(tmp_path, stage_module):
-    base_tag = "cont-base-" + "".join(random.choices(string.digits, k=12))
-    with make_container(tmp_path, base_tag, {
+    with make_container(tmp_path, {
         "file1": "file1 content",
         "file2": "file2 content",
         "dir1/file3": "dir1/file3 content",
         "dir2/file4": "dir2/file4 content",
-    }):
+    }) as base_tag:
         # export for the container-deploy stage
         fake_container_dst = tmp_path / "fake-container"
         subprocess.check_call([
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_testutil_make_container.py b/test/mod/test_testutil_make_container.py
new file mode 100644
index 00000000..2c8a7717
--- /dev/null
+++ b/test/mod/test_testutil_make_container.py
@@ -0,0 +1,29 @@
+#
+# Tests for the 'osbuild.util.testutil.make_container'.
+#
+import subprocess
+import textwrap
+
+import pytest
+
+from osbuild.testutil import has_executable, make_container, mock_command
+
+
+def test_make_container_bad_podman_prints_podman_output(tmp_path, capsys):
+    fake_broken_podman = textwrap.dedent("""\
+    #!/bin/sh
+    echo fake-broken-podman
+    exit 1
+    """)
+    with mock_command("podman", fake_broken_podman):
+        with pytest.raises(subprocess.CalledProcessError):
+            with make_container(tmp_path, {}) as _:
+                pass
+    assert "fake-broken-podman" in capsys.readouterr().out
+
+
+@pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
+def test_make_container_integration(tmp_path, capsys):
+    with make_container(tmp_path, {"/etc/foo": "foo-content"}) as cref:
+        assert len(cref) == 64
+    assert "COMMIT" in capsys.readouterr().out