Repository: osbuild/osbuild
Pull request #1543 (2024-01-17 13:14:15)

test: add new testutil.assert_jsonschema_error_contains() helper
This commit adds a new helper `assert_jsonschema_error_contains()` to `testutil` and uses it everywhere where we check errors from jsonschema.

Based on the excellent feedback from @supakeen in https://github.com/osbuild/osbuild/pull/1542#discussion_r1455353687


Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index fb31344d..a09f24c5 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -48,10 +48,10 @@ RPM:
           - aws/centos-stream-8-aarch64
           - aws/centos-stream-9-x86_64
           - aws/centos-stream-9-aarch64
-          - aws/rhel-8.8-ga-x86_64
-          - aws/rhel-8.8-ga-aarch64
-          - aws/rhel-9.2-ga-x86_64
-          - aws/rhel-9.2-ga-aarch64
+          - aws/rhel-8.9-ga-x86_64
+          - aws/rhel-8.9-ga-aarch64
+          - aws/rhel-9.3-ga-x86_64
+          - aws/rhel-9.3-ga-aarch64
       - RUNNER:
           - aws/rhel-8.10-nightly-x86_64
           - aws/rhel-8.10-nightly-aarch64
diff --git a/Schutzfile b/Schutzfile
index 7a39d8fc..3922bec2 100644
--- a/Schutzfile
+++ b/Schutzfile
@@ -233,47 +233,6 @@
       }
     ]
   },
-  "rhel-8.9": {
-    "repos": [
-      {
-        "file": "/etc/yum.repos.d/rhel8internal.repo",
-        "x86_64": [
-          {
-            "title": "RHEL-8-RPMREPO-NIGHTLY-BaseOS",
-            "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-baseos-n8.9-20240101"
-          },
-          {
-            "title": "RHEL-8-RPMREPO-NIGHTLY-AppStream",
-            "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-appstream-n8.9-20240101"
-          },
-          {
-            "title": "RHEL-8-RPMREPO-NIGHTLY-CRB",
-            "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-crb-n8.9-20240101"
-          }
-        ],
-        "aarch64": [
-          {
-            "title": "RHEL-8-RPMREPO-NIGHTLY-BaseOS",
-            "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-baseos-n8.9-20240101"
-          },
-          {
-            "title": "RHEL-8-RPMREPO-NIGHTLY-AppStream",
-            "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-appstream-n8.9-20240101"
-          },
-          {
-            "title": "RHEL-8-RPMREPO-NIGHTLY-CRB",
-            "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-aarch64-crb-n8.9-20240101"
-          }
-        ]
-      }
-    ]
-  },
   "rhel-8.10": {
     "repos": [
       {
@@ -315,47 +274,6 @@
       }
     ]
   },
-  "rhel-9.3": {
-    "repos": [
-      {
-        "file": "/etc/yum.repos.d/rhel9internal.repo",
-        "x86_64": [
-          {
-            "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
-            "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.3-20240101"
-          },
-          {
-            "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
-            "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.3-20240101"
-          },
-          {
-            "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
-            "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.3-20240101"
-          }
-        ],
-        "aarch64": [
-          {
-            "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
-            "name": "baseos",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.3-20240101"
-          },
-          {
-            "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
-            "name": "appstream",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.3-20240101"
-          },
-          {
-            "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
-            "name": "crb",
-            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.3-20240101"
-          }
-        ]
-      }
-    ]
-  },
   "rhel-9.4": {
     "repos": [
       {
@@ -452,4 +370,4 @@
       }
     ]
   }
-}
\ No newline at end of file
+}
diff --git a/osbuild/devices.py b/osbuild/devices.py
index 44541a72..8490f1e4 100644
--- a/osbuild/devices.py
+++ b/osbuild/devices.py
@@ -21,10 +21,11 @@ import stat
 from typing import Any, Dict, Optional
 
 from osbuild import host
+from osbuild.mixins import MixinImmutableID
 from osbuild.util import ctx
 
 
-class Device:
+class Device(MixinImmutableID):
     """
     A single device with its corresponding options
     """
diff --git a/osbuild/inputs.py b/osbuild/inputs.py
index 5e845df7..229c1264 100644
--- a/osbuild/inputs.py
+++ b/osbuild/inputs.py
@@ -48,7 +48,6 @@ class Input:
         self.id = self.calc_id()
 
     def calc_id(self):
-
         # NB: The input `name` is not included here on purpose since it
         # is either prescribed by the stage itself and thus not actual
         # parameter or arbitrary and chosen by the manifest generator
diff --git a/osbuild/mixins.py b/osbuild/mixins.py
new file mode 100644
index 00000000..24f09d35
--- /dev/null
+++ b/osbuild/mixins.py
@@ -0,0 +1,15 @@
+"""
+Mixin helper classes
+"""
+
+
+class MixinImmutableID:
+    """
+    Mixin to ensure that "self.id" attributes are immutable after id is set
+    """
+
+    def __setattr__(self, name, val):
+        if hasattr(self, "id"):
+            class_name = self.__class__.__name__
+            raise ValueError(f"cannot set '{name}': {class_name} cannot be changed after creation")
+        super().__setattr__(name, val)
diff --git a/osbuild/mounts.py b/osbuild/mounts.py
index 86576ad0..b938d21d 100644
--- a/osbuild/mounts.py
+++ b/osbuild/mounts.py
@@ -16,9 +16,10 @@ from typing import Dict, List
 
 from osbuild import host
 from osbuild.devices import DeviceManager
+from osbuild.mixins import MixinImmutableID
 
 
-class Mount:
+class Mount(MixinImmutableID):
     """
     A single mount with its corresponding options
     """
diff --git a/schutzbot/team_ssh_keys.txt b/schutzbot/team_ssh_keys.txt
index d3aaf29c..a2873e40 100644
--- a/schutzbot/team_ssh_keys.txt
+++ b/schutzbot/team_ssh_keys.txt
@@ -7,3 +7,4 @@ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDMk4+D+GkL/se9RYdQ6mmv0v7cG3gXPiZdLvsbRFk+
 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDY/ylCrPBzil4TnZR4tWULpz3QgfBMQyEnMOHDAJNp/FK70hD+PUiRm3UY96pmGXonQvqiDoyPuVh025FkWshPK91Dyq8QD8h25q5C5Cg6kMgBpdGzbX44ksms1KyOHmSZ48MpWw3PFOrlNP1vysr6Imjz9Jixmx4sOZvqKnrbsbOW04gowVzpZM8m048lvf6/KhqeImfeSRc9Rtpos8GqEQVlwRevE1qBON963V1QtFOrm9weoQgb369SdqRRdxaGNAymNh3d78DneOWXmEyBflLSpIDx5I2s/1NB1Dp95Bp3VvlV3CH1HC7LAFKYi+xsz3/KHdgtvgShX6LFSdsp rvykydal@dhcp-lab-144.englab.brq.redhat.com
 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDjNynFZPCEPVDyOB2yzrww5kxwK6MAb1D0GN5yP8y/iw+gtx+Hj3CqojHMTa/9r3q3R1TMgCITdvzAiKylbx/owV8bgXS1p8je2KirWx3o/Dy80AYsas2F+sodm5/FOz6LvcUZw2vZiVs1wp8dz7ak+pm6Xg7xa7511xO4T/HStzNUE/XSPYmC9LNJ+uVWTiCjTWlZxp1JcDVfO7k69F60u8D42e1Ty60IeNeJItX/o8FUjB/rMAAJRpjFpd/uyfPTWamjNoVzrB7chFxaemg2Nf8na6PHLAx8Gcxz2fdnnsg+M5vr6z0yVYz1cc8VOhYynQm9iISvTt6bDVEbWc2T thozza@redhat.com
 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOSn7aXhxrfFHWDyAYZSGHJIyfq1NIClqibdtL4H5cB8 jrusz@fedora
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHUHIBlCKLUnlc4rVEnNAX913iEzVCFkN49yntwXi5Ye dustymabe@redhat.com dustymabe-ed25519-ci-osbuild
diff --git a/schutzbot/terraform b/schutzbot/terraform
index 125e1d86..e88c9f01 100644
--- a/schutzbot/terraform
+++ b/schutzbot/terraform
@@ -1,1 +1,1 @@
-40e5f0c5d7da48ef4521e051bcf0ceaab429d02f
+0caf5afd068c94b7c39419bb1a2249d1e1dae6b2
diff --git a/stages/org.osbuild.mkfs.ext4 b/stages/org.osbuild.mkfs.ext4
index d88ae23a..697691ad 100755
--- a/stages/org.osbuild.mkfs.ext4
+++ b/stages/org.osbuild.mkfs.ext4
@@ -38,6 +38,14 @@ SCHEMA_2 = r"""
       "type": "string",
       "maxLength": 16
     },
+    "metadata_csum_seed": {
+      "description": "Enable metadata_csum_seed support",
+      "type": "boolean"
+    },
+    "orphan_file": {
+      "description": "Enable orphan_file support",
+      "type": "boolean"
+    },
     "verity": {
       "description": "Enable fs-verity support",
       "type": "boolean"
@@ -52,17 +60,18 @@ def main(devices, options):
 
     uuid = options["uuid"]
     label = options.get("label")
-    verity = options.get("verity")
     opts = []
 
     if label:
         opts = ["-L", label]
 
-    if verity is not None:
-        if verity:
-            opts += ["-O", "verity"]
-        else:
-            opts += ["-O", "^verity"]
+    for fsopt in ["verity", "orphan_file", "metadata_csum_seed"]:
+        val = options.get(fsopt)
+        if val is not None:
+            if val:
+                opts += ["-O", fsopt]
+            else:
+                opts += ["-O", f"^{fsopt}"]
 
     subprocess.run(["mkfs.ext4", "-U", uuid] + opts + [device],
                    encoding='utf8', check=True)
diff --git a/stages/org.osbuild.ostree.aleph b/stages/org.osbuild.ostree.aleph
index 302465fc..6baedeb0 100755
--- a/stages/org.osbuild.ostree.aleph
+++ b/stages/org.osbuild.ostree.aleph
@@ -78,11 +78,9 @@ def aleph_container(tree, imgref):
         raise ValueError(f"Image ref {imgref} has unsupported type (supported: 'ostree-remote-registry', \
                          'ostree-remote-image', 'ostree-image-signed', or 'ostree-unverified-registry')")
 
-    img_name = img_name.removeprefix('docker://')
-
     extra_args = []
     extra_args.append(f"--repo={tree}/ostree/repo")
-    extra_args.append(f"registry:{img_name}")
+    extra_args.append(img_name)
 
     container_data_json = ostree.cli("container", "image", "metadata", *extra_args).stdout.rstrip()
     container_data = json.loads(container_data_json)


Test patch:
diff --git a/osbuild/testutil/__init__.py b/osbuild/testutil/__init__.py
index 5b47219f..3d9e2bc0 100644
--- a/osbuild/testutil/__init__.py
+++ b/osbuild/testutil/__init__.py
@@ -3,6 +3,7 @@ Test related utilities
 """
 import os
 import pathlib
+import re
 import shutil
 
 
@@ -39,3 +40,20 @@ def make_fake_input_tree(tmpdir: pathlib.Path, fake_content: dict) -> str:
     basedir = tmpdir / "tree"
     make_fake_tree(basedir, fake_content)
     return os.fspath(basedir)
+
+
+def assert_jsonschema_error_contains(res, expected_err, expected_num_errs=None):
+    err_msgs = [e.as_dict()["message"] for e in res.errors]
+    if expected_num_errs is not None:
+        assert len(err_msgs) == expected_num_errs, \
+            f"expected exactly {expected_num_errs} errors in {[e.as_dict() for e in res.errors]}"
+    re_typ = getattr(re, 'Pattern', None)
+    # this can be removed once we no longer support py3.6 (re.Pattern is modern)
+    if not re_typ:
+        re_typ = getattr(re, '_pattern_type')
+    if isinstance(expected_err, re_typ):
+        finder = expected_err.search
+    else:
+        def finder(s): return expected_err in s  # pylint: disable=C0321
+    assert any(finder(err_msg)
+               for err_msg in err_msgs), f"{expected_err} not found in {err_msgs}"
diff --git a/stages/test/test_autotailor.py b/stages/test/test_autotailor.py
index c321391a..a42d0282 100644
--- a/stages/test/test_autotailor.py
+++ b/stages/test/test_autotailor.py
@@ -7,6 +7,7 @@ from unittest.mock import call, patch
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil.imports import import_module_from_path
 
 TEST_INPUT = [
@@ -112,6 +113,4 @@ def test_schema_validation_oscap_autotailor(fake_input, test_data, expected_err)
     res = schema_validate_stage_oscap_autotailor(fake_input, test_data)
 
     assert res.valid is False
-    err_msgs = [e.as_dict()["message"] for e in res.errors]
-    assert len(res.errors) == 1, err_msgs
-    assert expected_err in err_msgs[0]
+    testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
diff --git a/stages/test/test_bootupd.py b/stages/test/test_bootupd.py
index 9df845d3..f9d3bfd3 100644
--- a/stages/test/test_bootupd.py
+++ b/stages/test/test_bootupd.py
@@ -6,6 +6,7 @@ from unittest.mock import call, patch
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil.imports import import_module_from_path
 
 
@@ -54,9 +55,7 @@ def test_bootupd_schema_validation(test_data, expected_err):
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert expected_err in err_msgs[0]
+        testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
 @pytest.mark.parametrize("test_options,expected_bootupd_opts", [
diff --git a/stages/test/test_containers_storage_conf.py b/stages/test/test_containers_storage_conf.py
index 7fcf030a..36f322c6 100644
--- a/stages/test/test_containers_storage_conf.py
+++ b/stages/test/test_containers_storage_conf.py
@@ -11,7 +11,7 @@ except ModuleNotFoundError:
     import pytoml as toml
 
 import osbuild.meta
-from osbuild.testutil import assert_dict_has
+from osbuild import testutil
 from osbuild.testutil.imports import import_module_from_path
 
 TEST_INPUT = [
@@ -56,7 +56,7 @@ def test_containers_storage_conf_integration(tmp_path, test_filename, test_stora
     assert conf is not None
 
     for (key, value) in expected:
-        assert_dict_has(conf, key, value)
+        testutil.assert_dict_has(conf, key, value)
 
 
 @pytest.mark.parametrize(
@@ -64,7 +64,7 @@ def test_containers_storage_conf_integration(tmp_path, test_filename, test_stora
     [
         # None, note that starting from jsonschema 4.21.0 the error changes
         # so we need a regexp here
-        ({}, {}, r"does not have enough properties|should be non-empty"),
+        ({}, {}, re.compile("does not have enough properties|should be non-empty")),
         # All options
         ({
             "filename": "/etc/containers/storage.conf",
@@ -120,6 +120,4 @@ def test_schema_validation_containers_storage_conf(test_data, storage_test_data,
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert any(re.search(expected_err, err_msg)
-                   for err_msg in err_msgs), f"{expected_err} not found in {err_msgs}"
+        testutil.assert_jsonschema_error_contains(res, expected_err)
diff --git a/stages/test/test_erofs.py b/stages/test/test_erofs.py
index bcda4313..45ddfd2f 100644
--- a/stages/test/test_erofs.py
+++ b/stages/test/test_erofs.py
@@ -7,6 +7,7 @@ from unittest import mock
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil import has_executable, make_fake_input_tree
 from osbuild.testutil.imports import import_module_from_path
 
@@ -109,6 +110,4 @@ def test_schema_validation_erofs(test_data, expected_err):
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert expected_err in err_msgs[0]
+        testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
diff --git a/stages/test/test_kickstart.py b/stages/test/test_kickstart.py
index d2ca7340..4a7957e9 100644
--- a/stages/test/test_kickstart.py
+++ b/stages/test/test_kickstart.py
@@ -6,6 +6,7 @@ import subprocess
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil import has_executable
 from osbuild.testutil.imports import import_module_from_path
 
@@ -365,6 +366,4 @@ def test_schema_validation_bad_apples(test_data, expected_err):
     res = schema_validate_kickstart_stage(test_data)
 
     assert res.valid is False
-    assert len(res.errors) == 1
-    err_msgs = [e.as_dict()["message"] for e in res.errors]
-    assert expected_err in err_msgs[0]
+    testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
diff --git a/stages/test/test_machine-id.py b/stages/test/test_machine-id.py
index efe98a75..b5ad2bda 100644
--- a/stages/test/test_machine-id.py
+++ b/stages/test/test_machine-id.py
@@ -7,6 +7,7 @@ import unittest.mock
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil.imports import import_module_from_path
 
 
@@ -72,9 +73,7 @@ def test_machine_id_schema_validation(test_data, expected_err):
     res = schema.validate(test_input)
 
     assert res.valid is False
-    assert len(res.errors) == 1
-    err_msgs = [e.as_dict()["message"] for e in res.errors]
-    assert expected_err in err_msgs[0]
+    testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
 def test_machine_id_first_boot_unknown(tmp_path):
diff --git a/stages/test/test_mkfs_ext4.py b/stages/test/test_mkfs_ext4.py
index 0c0ea2f0..bb3433e4 100644
--- a/stages/test/test_mkfs_ext4.py
+++ b/stages/test/test_mkfs_ext4.py
@@ -8,6 +8,7 @@ from unittest import mock
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil import has_executable
 from osbuild.testutil.imports import import_module_from_path
 
@@ -48,9 +49,7 @@ def test_schema_validation_mkfs_ext4(test_data, expected_err):
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert expected_err in err_msgs[0]
+        testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
 @pytest.mark.skipif(not has_executable("mkfs.ext4"), reason="need mkfs.ext4")
@@ -82,6 +81,11 @@ def test_mkfs_ext4_integration(tmp_path):
     ({}, []),
     ({"verity": True}, ["-O", "verity"]),
     ({"verity": False}, ["-O", "^verity"]),
+    ({"orphan_file": True}, ["-O", "orphan_file"]),
+    ({"orphan_file": False}, ["-O", "^orphan_file"]),
+    ({"metadata_csum_seed": True}, ["-O", "metadata_csum_seed"]),
+    ({"metadata_csum_seed": False}, ["-O", "^metadata_csum_seed"]),
+    ({"verity": True, "orphan_file": True, "metadata_csum_seed": True}, ["-O", "verity", "-O", "orphan_file", "-O", "metadata_csum_seed"]),
 ])
 @mock.patch("subprocess.run")
 def test_mkfs_ext4_cmdline(mock_run, test_input, expected):
diff --git a/stages/test/test_ostree_post_copy.py b/stages/test/test_ostree_post_copy.py
index 2c349d02..8e7a1fbc 100644
--- a/stages/test/test_ostree_post_copy.py
+++ b/stages/test/test_ostree_post_copy.py
@@ -6,6 +6,7 @@ from unittest.mock import call, patch
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil.imports import import_module_from_path
 
 
@@ -56,6 +57,4 @@ def test_schema_validation_ostree_post_copy(test_data, expected_err):
     res = schema_validate_stage_ostree_post_copy(test_data)
 
     assert res.valid is False
-    err_msgs = [e.as_dict()["message"] for e in res.errors]
-    assert len(res.errors) == 1, err_msgs
-    assert expected_err in err_msgs[0]
+    testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
diff --git a/stages/test/test_selinux.py b/stages/test/test_selinux.py
index 507d2e99..81360ce0 100644
--- a/stages/test/test_selinux.py
+++ b/stages/test/test_selinux.py
@@ -6,7 +6,7 @@ from unittest.mock import call, patch
 import pytest
 
 import osbuild.meta
-import osbuild.testutil
+from osbuild import testutil
 from osbuild.testutil.imports import import_module_from_path
 
 
@@ -42,18 +42,15 @@ def test_schema_validation_selinux(test_data, expected_err):
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert expected_err in err_msgs[0]
+        testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
 def test_schema_validation_selinux_file_context_required():
     test_data = {}
     res = schema_validation_selinux(test_data, implicit_file_contexts=False)
     assert res.valid is False
-    assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-    err_msgs = [e.as_dict()["message"] for e in res.errors]
-    assert "'file_contexts' is a required property" in err_msgs[0]
+    expected_err = "'file_contexts' is a required property"
+    testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
 @patch("osbuild.util.selinux.setfiles")
diff --git a/stages/test/test_skopeo.py b/stages/test/test_skopeo.py
index 6e84ef62..907bba83 100644
--- a/stages/test/test_skopeo.py
+++ b/stages/test/test_skopeo.py
@@ -5,6 +5,7 @@ import os.path
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 
 
 @pytest.mark.parametrize("test_data,expected_err", [
@@ -37,6 +38,4 @@ def test_schema_validation_skopeo(test_data, expected_err):
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert expected_err in err_msgs[0]
+        testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
diff --git a/stages/test/test_xz.py b/stages/test/test_xz.py
index c69a9c15..79bbf74b 100644
--- a/stages/test/test_xz.py
+++ b/stages/test/test_xz.py
@@ -7,6 +7,7 @@ from unittest import mock
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil import has_executable, make_fake_input_tree
 from osbuild.testutil.imports import import_module_from_path
 
@@ -35,9 +36,7 @@ def test_schema_validation_xz(test_data, expected_err):
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert expected_err in err_msgs[0]
+        testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
 @pytest.fixture(name="fake_input_tree")
diff --git a/stages/test/test_zstd.py b/stages/test/test_zstd.py
index 665601e9..ec702cd9 100644
--- a/stages/test/test_zstd.py
+++ b/stages/test/test_zstd.py
@@ -7,6 +7,7 @@ from unittest import mock
 import pytest
 
 import osbuild.meta
+from osbuild import testutil
 from osbuild.testutil import has_executable, make_fake_input_tree
 from osbuild.testutil.imports import import_module_from_path
 
@@ -35,9 +36,7 @@ def test_schema_validation_zstd(test_data, expected_err):
         assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
     else:
         assert res.valid is False
-        assert len(res.errors) == 1, [e.as_dict() for e in res.errors]
-        err_msgs = [e.as_dict()["message"] for e in res.errors]
-        assert expected_err in err_msgs[0]
+        testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
 @pytest.fixture(name="fake_input_tree")
diff --git a/test/data/manifests/fedora-coreos-container.mpp.yaml b/test/data/manifests/fedora-coreos-container.mpp.yaml
index 254af58b..c50ab964 100644
--- a/test/data/manifests/fedora-coreos-container.mpp.yaml
+++ b/test/data/manifests/fedora-coreos-container.mpp.yaml
@@ -9,6 +9,8 @@ mpp-vars:
   four_k_sector_size: 4096
   # The name to use for deployment stateroot
   osname: fedora-coreos
+  # The container image ref to follow for updates
+  container_imgref: ostree-remote-registry:fedora:quay.io/fedora/fedora-coreos:stable
   # Filesystem UUID and label definitions. These UUIDs
   # are looked for on boot and if found replaced with
   # a new random UUID to make each install unique.
@@ -102,7 +104,8 @@ pipelines:
         options:
           osname:
             mpp-format-string: '{osname}'
-          target_imgref: ostree-remote-registry:fedora:quay.io/fedora/fedora-coreos:stable
+          target_imgref:
+            mpp-format-string: '{container_imgref}'
           mounts:
             - /boot
             - /boot/efi
diff --git a/test/mod/test_mixins.py b/test/mod/test_mixins.py
new file mode 100644
index 00000000..e793ad7e
--- /dev/null
+++ b/test/mod/test_mixins.py
@@ -0,0 +1,32 @@
+from unittest.mock import Mock
+
+import pytest
+
+from osbuild.devices import Device
+from osbuild.meta import ModuleInfo
+from osbuild.mounts import Mount
+
+
+def test_mount_immutable_mixin():
+    info = Mock(spec=ModuleInfo)
+    info.name = "some-name"
+    device = Mock(spec=ModuleInfo)
+    device.id = "some-id"
+    partition = 1
+    target = "/"
+    opts = {"opt1": 1}
+    mnt = Mount("name", info, device, partition, target, opts)
+    with pytest.raises(ValueError) as e:
+        mnt.name = "new-name"
+    assert str(e.value) == "cannot set 'name': Mount cannot be changed after creation"
+
+
+def test_device_immutable_mixins():
+    info = Mock(spec=ModuleInfo)
+    info.name = "some-name"
+    parent = None
+    opts = {"opt1": 1}
+    dev = Device("name", info, parent, opts)
+    with pytest.raises(ValueError) as e:
+        dev.name = "new-name"
+    assert str(e.value) == "cannot set 'name': Device cannot be changed after creation"
diff --git a/test/mod/test_testutil_jsonschema.py b/test/mod/test_testutil_jsonschema.py
new file mode 100644
index 00000000..53bc8104
--- /dev/null
+++ b/test/mod/test_testutil_jsonschema.py
@@ -0,0 +1,51 @@
+import re
+
+import pytest
+
+import osbuild.meta
+from osbuild import testutil
+
+fake_schema = {
+    "type": "object",
+    "required": ["name"],
+}
+
+
+@pytest.fixture(name="validation_error")
+def validation_error_fixture():
+    schema = osbuild.meta.Schema(fake_schema, "fake-schema")
+    res = schema.validate({"not": "name"})
+    assert res.valid is False
+    return res
+
+
+def test_assert_jsonschema_error_contains(validation_error):
+    expected_err = "'name' is a required property"
+    testutil.assert_jsonschema_error_contains(validation_error, expected_err)
+
+
+def test_assert_jsonschema_error_regex(validation_error):
+    expected_err = re.compile("'.*' is a required property")
+    testutil.assert_jsonschema_error_contains(validation_error, expected_err)
+
+
+def test_assert_jsonschema_error_not_contains(validation_error):
+    with pytest.raises(AssertionError, match=r'not-in-errs not found in \['):
+        testutil.assert_jsonschema_error_contains(validation_error, "not-in-errs")
+
+
+def test_assert_jsonschema_error_not_found_re(validation_error):
+    expected_err_re = re.compile("not-in-errs")
+    with pytest.raises(AssertionError, match=r"re.*not found in"):
+        testutil.assert_jsonschema_error_contains(validation_error, expected_err_re)
+
+
+def test_assert_jsonschema_error_num_errs(validation_error):
+    expected_err = "'name' is a required property"
+    testutil.assert_jsonschema_error_contains(validation_error, expected_err, expected_num_errs=1)
+
+
+def test_assert_jsonschema_error_num_errs_wrong(validation_error):
+    expected_err = "'name' is a required property"
+    with pytest.raises(AssertionError, match=r'expected exactly 99 errors in'):
+        testutil.assert_jsonschema_error_contains(validation_error, expected_err, expected_num_errs=99)
diff --git a/test/mod/test_util_linux.py b/test/mod/test_util_linux.py
index be31723d..518a4bfa 100644
--- a/test/mod/test_util_linux.py
+++ b/test/mod/test_util_linux.py
@@ -6,6 +6,7 @@ import ctypes
 import os
 import subprocess
 import tempfile
+import time
 
 import pytest
 
@@ -255,10 +256,11 @@ def test_libc_futimes_works(tmpdir):
     with open(stamp_file, "wb") as fp:
         fp.write(b"meep")
     mtime1 = os.stat(stamp_file).st_mtime
-    with open(stamp_file, "wb") as fp:
+    time.sleep(0.1)
+    with open(stamp_file, "rb") as fp:
         libc.futimens(fp.fileno(), ctypes.byref(linux.c_timespec_times2(
             atime=linux.c_timespec(tv_sec=3, tv_nsec=300 * 1000 * 1000),
             mtime=linux.c_timespec(tv_sec=0, tv_nsec=libc.UTIME_OMIT),
         )))
     assert os.stat(stamp_file).st_atime == 3.3
-    assert os.stat(stamp_file).st_mtime == mtime1
+    assert round(os.stat(stamp_file).st_mtime, 3) == round(mtime1, 3)
diff --git a/test/run/test_exports.py b/test/run/test_exports.py
index 12b4cfe7..d8909649 100644
--- a/test/run/test_exports.py
+++ b/test/run/test_exports.py
@@ -58,7 +58,7 @@ def testing_libdir_fixture(tmpdir_factory):
     # in buildroot.py
     (fake_libdir_path / "osbuild").mkdir()
     # construct minimal viable libdir from current checkout
-    for d in ["stages", "runners", ]:
+    for d in ["stages", "runners", "schemas", "assemblers"]:
         subprocess.run(
             ["cp", "-a", os.fspath(project_path / d), f"{fake_libdir_path}"],
             check=True)
diff --git a/test/run/test_stages.py b/test/run/test_stages.py
index bc6d8b31..f6156369 100644
--- a/test/run/test_stages.py
+++ b/test/run/test_stages.py
@@ -573,6 +573,7 @@ class TestStages(test.TestBase):
                 assert "path home" in subvols[1]
 
     @unittest.skipUnless(test.TestBase.has_filesystem_support("fat"), "FAT needed")
+    @unittest.skipUnless("-g GEOM" in subprocess.getoutput("mkfs.fat"), "mkfs.fat -g GEOM missing")
     def test_fat(self):
         def _get_file_fields(image: str) -> List[str]:
             r = subprocess.run(