Repository: osbuild/osbuild
Pull request #1733 (2024-04-16 07:05:32)

stages: remove redundant entries of devices/mounts in schemas
With PR#1727 merged `devices` are now automatically added to all
stage schemas. This makes the existing schema entries of the form:
```json
...
    },
    "devices": {
      "type": "object",
      "additionalProperties": true
    },
    "mounts": {
      "type": "array"
    }
...
```
redundant. This commit removes them. There are still some left
that are more specific (e.g. for filesystems) than this generic
one.

Note that `mounts` was already part of the stages schema automatically
since https://github.com/osbuild/osbuild/pull/847 (2021).

---

It also removes the stage specific tests for checking that our required bootc devices/mounts get accepted by the schema and moves that into the `test/mod` as it's now a generic module functionality.

mvo5: > Is there any harm in leaving them? I think having them as documentation of what is allowed is valuable.

Sure, that works for me (I have no super strong opinion either way), I removed this part of the PR. I still think there is value to have the test that checks that stages accept mounts/devices so I kept that but removed the other part that removed the definitions (cc @ondrejbudai too).


Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1a43717d..600e0abb 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -41,6 +41,8 @@ RPM:
           - aws/fedora-38-aarch64
           - aws/fedora-39-x86_64
           - aws/fedora-39-aarch64
+          - aws/fedora-40-x86_64
+          - aws/fedora-40-aarch64
           - aws/centos-stream-8-x86_64
           - aws/centos-stream-8-aarch64
           - aws/centos-stream-9-x86_64
@@ -54,6 +56,8 @@ RPM:
           - aws/rhel-8.10-nightly-aarch64
           - aws/rhel-9.4-nightly-x86_64
           - aws/rhel-9.4-nightly-aarch64
+          - aws/rhel-9.5-nightly-x86_64
+          - aws/rhel-9.5-nightly-aarch64
         INTERNAL_NETWORK: "true"
 
 OSTree Images:
diff --git a/Schutzfile b/Schutzfile
index 80bdd682..ec3afda8 100644
--- a/Schutzfile
+++ b/Schutzfile
@@ -315,6 +315,47 @@
       }
     ]
   },
+  "rhel-9.5": {
+    "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.5-20240501"
+          },
+          {
+            "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
+            "name": "appstream",
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.5-20240501"
+          },
+          {
+            "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
+            "name": "crb",
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-crb-n9.5-20240501"
+          }
+        ],
+        "aarch64": [
+          {
+            "title": "RHEL-9-RPMREPO-NIGHTLY-BaseOS",
+            "name": "baseos",
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-baseos-n9.5-20240501"
+          },
+          {
+            "title": "RHEL-9-RPMREPO-NIGHTLY-AppStream",
+            "name": "appstream",
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-appstream-n9.5-20240501"
+          },
+          {
+            "title": "RHEL-9-RPMREPO-NIGHTLY-CRB",
+            "name": "crb",
+            "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-aarch64-crb-n9.5-20240501"
+          }
+        ]
+      }
+    ]
+  },
   "centos-stream-8": {
     "repos": [
       {
diff --git a/osbuild.spec b/osbuild.spec
index 8e9c4a35..c94bb8a9 100644
--- a/osbuild.spec
+++ b/osbuild.spec
@@ -137,12 +137,7 @@ manifests and osbuild.
 Summary:        Dependency solving support for DNF
 Requires:       %{name} = %{version}-%{release}
 
-# Fedora 40 and later use libdnf5, RHEL and Fedora < 40 use libdnf
-%if 0%{?fedora} >= 40
-Requires: python3-libdnf5 >= 5.1.1
-%else
 Requires: python3-libdnf
-%endif
 
 %description    depsolve-dnf
 Contains depsolving capabilities for package managers.
@@ -213,12 +208,7 @@ install -p -m 0755 data/10-osbuild-inhibitor.rules %{buildroot}%{_udevrulesdir}
 
 # Install `osbuild-depsolve-dnf` into libexec
 mkdir -p %{buildroot}%{_libexecdir}
-# Fedora 40 and later use dnf5-json, RHEL and Fedora < 40 use dnf-json
-%if 0%{?fedora} >= 40
-install -p -m 0755 tools/osbuild-depsolve-dnf5 %{buildroot}%{_libexecdir}/osbuild-depsolve-dnf
-%else
 install -p -m 0755 tools/osbuild-depsolve-dnf %{buildroot}%{_libexecdir}/osbuild-depsolve-dnf
-%endif
 
 %check
 exit 0
diff --git a/schutzbot/terraform b/schutzbot/terraform
index a663e490..aab677b8 100644
--- a/schutzbot/terraform
+++ b/schutzbot/terraform
@@ -1,1 +1,1 @@
-ac4c95f6e60b34458682845ea07a32d99ec91899
+db213ee9af376144749b5e69301ee6147e67e522
diff --git a/sources/org.osbuild.curl b/sources/org.osbuild.curl
index 670717a4..6a8293f2 100755
--- a/sources/org.osbuild.curl
+++ b/sources/org.osbuild.curl
@@ -20,6 +20,7 @@ up the download.
 
 import concurrent.futures
 import os
+import platform
 import subprocess
 import sys
 import tempfile
@@ -153,6 +154,7 @@ class CurlSource(sources.SourceService):
             # some mirrors are sometimes broken. retry manually, because we could be
             # redirected to a different, working, one on retry.
             return_code = 0
+            arch = platform.machine()
             for _ in range(10):
                 curl_command = [
                     "curl",
@@ -161,6 +163,7 @@ class CurlSource(sources.SourceService):
                     "--connect-timeout", "30",
                     "--fail",
                     "--location",
+                    "--header", f"User-Agent: osbuild (Linux.{arch}; https://osbuild.org/)",
                     "--output", checksum,
                 ]
                 if proxy:
diff --git a/stages/org.osbuild.kickstart.meta.json b/stages/org.osbuild.kickstart.meta.json
index 21ad84b3..77952f9b 100644
--- a/stages/org.osbuild.kickstart.meta.json
+++ b/stages/org.osbuild.kickstart.meta.json
@@ -187,7 +187,8 @@
             "enum": [
               "registry",
               "oci",
-              "oci-archive"
+              "oci-archive",
+              "dir"
             ],
             "description": "Use the given transport, Anaconda's default is 'registry'"
           },
diff --git a/stages/org.osbuild.skopeo b/stages/org.osbuild.skopeo
index f50935d5..45bb98db 100755
--- a/stages/org.osbuild.skopeo
+++ b/stages/org.osbuild.skopeo
@@ -1,5 +1,6 @@
 #!/usr/bin/python3
 import os
+import pathlib
 import subprocess
 import sys
 
@@ -12,6 +13,7 @@ def main(inputs, output, options):
 
     destination = options["destination"]
     dest_type = destination["type"]
+    remove_signatures = options.get("remove-signatures")
 
     for image in images.values():
         with containers.container_source(image) as (image_name, image_source):
@@ -19,13 +21,19 @@ def main(inputs, output, options):
                 storage_root = destination.get("storage-path", "/var/lib/containers/storage")
                 storage_driver = destination.get("storage-driver", "overlay")
                 dest = f"containers-storage:[{storage_driver}@{output}{storage_root}+/run/containers/storage]{image_name}"
-            elif dest_type == "oci":
+            elif dest_type in ("oci", "oci-archive", "dir"):
                 path = destination["path"]
-                dest = f"oci:{output}{path}"
+                dest = f"{dest_type}:{output}{path}"
+                pathlib.Path(f"{output}{path}").parent.mkdir(parents=True, exist_ok=True)
             else:
                 raise ValueError(f"Unknown destination type '{dest_type}'")
 
-            subprocess.run(["skopeo", "copy", image_source, dest], check=True)
+            cmd = ["skopeo", "copy"]
+            if remove_signatures:
+                cmd.append("--remove-signatures")
+            cmd.extend([image_source, dest])
+
+            subprocess.run(cmd, check=True)
 
     if dest_type == "containers-storage" and storage_driver == "overlay":
         # Each time the overlay backend runs on an xfs fs it creates this file:
diff --git a/stages/org.osbuild.skopeo.meta.json b/stages/org.osbuild.skopeo.meta.json
index 777ebe0a..95812a90 100644
--- a/stages/org.osbuild.skopeo.meta.json
+++ b/stages/org.osbuild.skopeo.meta.json
@@ -47,6 +47,44 @@
             "type": "string"
           }
         }
+      },
+      "destination-oci-archive": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": [
+          "type",
+          "path"
+        ],
+        "properties": {
+          "type": {
+            "enum": [
+              "oci-archive"
+            ]
+          },
+          "path": {
+            "description": "Location of a tar archive compliant with 'Open Container Image Layout Specification'",
+            "type": "string"
+          }
+        }
+      },
+      "destination-dir": {
+        "type": "object",
+        "additionalProperties": false,
+        "required": [
+          "type",
+          "path"
+        ],
+        "properties": {
+          "type": {
+            "enum": [
+              "dir"
+            ]
+          },
+          "path": {
+            "description": "Location of a directory storing the manifest, layer tarballs and signatures as individual files. This is a non-standardized format, primarily useful for debugging or noninvasive container inspection.",
+            "type": "string"
+          }
+        }
       }
     },
     "inputs": {
@@ -80,8 +118,19 @@
             },
             {
               "$ref": "#/definitions/destination-oci"
+            },
+            {
+              "$ref": "#/definitions/destination-oci-archive"
+            },
+            {
+              "$ref": "#/definitions/destination-dir"
             }
           ]
+        },
+        "remove-signatures": {
+          "type": "boolean",
+          "default": false,
+          "description": "Do not copy signatures, if any, from source-image. Necessary when copying a signed image to a destination which does not support signatures."
         }
       }
     }
diff --git a/tools/osbuild-depsolve-dnf5 b/tools/osbuild-depsolve-dnf5
index 64afd199..029d6033 100755
--- a/tools/osbuild-depsolve-dnf5
+++ b/tools/osbuild-depsolve-dnf5
@@ -223,7 +223,7 @@ class Solver():
         else:
             raise ValueError("missing either `baseurl`, `metalink`, or `mirrorlist` in repo")
 
-        repo.sslverify = desc.get("sslverify", True)
+        conf.sslverify = desc.get("sslverify", True)
         if "sslcacert" in desc:
             conf.sslcacert = desc["sslcacert"]
         if "sslclientkey" in desc:


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ad74c524..6ab1b51a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -30,7 +30,7 @@ jobs:
     - name: "Run"
       uses: osbuild/containers/src/actions/privdocker@552e30cf1b4ed19c6ddaa57f96c342b3dff4227b
       with:
-        image: ghcr.io/osbuild/osbuild-ci:latest-202404161303
+        image: ghcr.io/osbuild/osbuild-ci:latest-202404252004
         run: |
           # Hacky replacement of container storage driver:
           # The default overlayfs doesn't work in the runner, so let's change
@@ -46,7 +46,7 @@ jobs:
               TEST_CATEGORY="not test_stages.py and not test_assemblers.py"
           fi
           OSBUILD_TEST_STORE="${{ env.OSBUILD_TEST_STORE }}" \
-          tox -e "${{ matrix.environment }}" -- $TEST_WORKERS -k "$TEST_CATEGORY"
+          tox -e "${{ matrix.environment }}" -- -rs $TEST_WORKERS -k "$TEST_CATEGORY"
 
   v1_manifests:
     name: "Assembler test (legacy)"
@@ -60,7 +60,7 @@ jobs:
           # Using 4 workers is a bit arbitrary, "auto" is probably too aggressive.
           TEST_WORKERS: "-n 4"
         with:
-          image: ghcr.io/osbuild/osbuild-ci:latest-202404161303
+          image: ghcr.io/osbuild/osbuild-ci:latest-202404252004
           run: |
             OSBUILD_TEST_STORE="${{ env.OSBUILD_TEST_STORE }}" \
             tox -e "py36" -- ${{ env.TEST_WORKERS }} test.run.test_assemblers
diff --git a/osbuild/testutil/__init__.py b/osbuild/testutil/__init__.py
index 12266a4c..0681ff9b 100644
--- a/osbuild/testutil/__init__.py
+++ b/osbuild/testutil/__init__.py
@@ -5,9 +5,11 @@ import contextlib
 import inspect
 import os
 import pathlib
+import random
 import re
 import shutil
 import socket
+import string
 import subprocess
 import tempfile
 import textwrap
@@ -125,6 +127,7 @@ def mock_command(cmd_name: str, script: str):
 
 @contextlib.contextmanager
 def make_container(tmp_path, fake_content, base="scratch"):
+    fake_container_tag = "osbuild-test-" + "".join(random.choices(string.digits, k=12))
     fake_container_src = tmp_path / "fake-container-src"
     fake_container_src.mkdir(exist_ok=True)
     make_fake_tree(fake_container_src, fake_content)
@@ -134,22 +137,16 @@ def make_container(tmp_path, fake_content, base="scratch"):
     COPY . .
     """
     fake_containerfile_path.write_text(container_file_content, encoding="utf8")
-    p = subprocess.Popen([
+    subprocess.check_call([
         "podman", "build",
         "--no-cache",
+        "-t", fake_container_tag,
         "-f", os.fspath(fake_containerfile_path),
-    ], 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 container_id
+        yield fake_container_tag
     finally:
-        subprocess.check_call(["podman", "image", "rm", container_id])
+        subprocess.check_call(["podman", "image", "rm", fake_container_tag])
 
 
 @contextlib.contextmanager
@@ -186,3 +183,21 @@ def find_one_subclass_in_module(module: ModuleType, subclass: Type) -> object:
             raise ValueError(f"already have {cls}, also found {name}:{memb}")
         cls = memb
     return cls
+
+
+def make_fake_images_inputs(fake_oci_path, name):
+    fname = fake_oci_path.name
+    dirname = fake_oci_path.parent
+    return {
+        "images": {
+            "path": dirname,
+            "data": {
+                "archives": {
+                    fname: {
+                        "format": "oci-archive",
+                        "name": name,
+                    },
+                },
+            },
+        },
+    }
diff --git a/sources/test/test_curl_source.py b/sources/test/test_curl_source.py
index 49813ad0..47c7849c 100644
--- a/sources/test/test_curl_source.py
+++ b/sources/test/test_curl_source.py
@@ -208,3 +208,20 @@ def test_curl_download_proxy(mocked_run, tmp_path, monkeypatch, sources_service,
             assert args[0][idx:idx + 2] == ["--proxy", "http://my-proxy"]
         else:
             assert "--proxy" not in args[0]
+
+
+@patch("subprocess.run")
+def test_curl_user_agent(mocked_run, tmp_path, sources_service):
+    test_sources = make_test_sources(tmp_path, 80, 2,)
+    fake_curl_downloader = FakeCurlDownloader(test_sources)
+    mocked_run.side_effect = fake_curl_downloader.faked_run
+
+    sources_service.cache = tmp_path / "curl-cache"
+    sources_service.cache.mkdir()
+    sources_service.fetch_all(test_sources)
+
+    for call_args in mocked_run.call_args_list:
+        args, _kwargs = call_args
+        idx = args[0].index("--header")
+        assert "User-Agent: osbuild" in args[0][idx + 1]
+        assert "https://osbuild.org/" in args[0][idx + 1]
diff --git a/stages/test/conftest.py b/stages/test/conftest.py
index d9a314e4..f2f9d5a7 100644
--- a/stages/test/conftest.py
+++ b/stages/test/conftest.py
@@ -43,39 +43,3 @@ def stage_schema(request: pytest.FixtureRequest) -> osbuild.meta.Schema:
     root = caller_dir.parent.parent
     mod_info = osbuild.meta.ModuleInfo.load(root, "Stage", stage_name)
     return osbuild.meta.Schema(mod_info.get_schema(version=schema_version), stage_name)
-
-
-@pytest.fixture
-def bootc_devices_mounts_dict() -> dict:
-    """ bootc_devices_mounts_dict returns a dict with a typical bootc
-    devices/mount dict
-    """
-    return {
-        "devices": {
-            "disk": {
-                "type": "org.osbuild.loopback",
-                "options": {
-                    "filename": "disk.raw",
-                    "partscan": True,
-                }
-            }
-        },
-        "mounts": [
-            {
-                "name": "root",
-                "type": "org.osbuild.ext4",
-                "source": "disk",
-                "partition": 4,
-                "target": "/"
-            }, {
-                "name": "ostree.deployment",
-                "type": "org.osbuild.ostree.deployment",
-                "options": {
-                    "source": "mount",
-                    "deployment": {
-                        "default": True,
-                    }
-                }
-            }
-        ]
-    }
diff --git a/stages/test/test_bootc_install_to_fs.py b/stages/test/test_bootc_install_to_fs.py
index f31dc03e..7f1fe9a0 100644
--- a/stages/test/test_bootc_install_to_fs.py
+++ b/stages/test/test_bootc_install_to_fs.py
@@ -31,7 +31,7 @@ FAKE_INPUTS = {
         "path": "/input/images/path",
         "data": {
             "archives": {
-                "/input/images/path": {
+                "filename": {
                     "format": "oci-archive",
                     "name": "some-img-name",
                 },
@@ -69,7 +69,7 @@ def test_bootc_install_to_fs(mock_run, mocked_named_tmp, mocked_temp_dir, stage_
             "path": "/input/images/path",
             "data": {
                 "archives": {
-                    "/input/images/path": {
+                    "filename": {
                         "format": "oci-archive",
                         "name": "some-img-name",
                     },
diff --git a/stages/test/test_container_deploy.py b/stages/test/test_container_deploy.py
index 02a11795..748369af 100644
--- a/stages/test/test_container_deploy.py
+++ b/stages/test/test_container_deploy.py
@@ -9,7 +9,7 @@ from unittest.mock import call, patch
 import pytest
 
 import osbuild.testutil
-from osbuild.testutil import has_executable, make_container
+from osbuild.testutil import has_executable, make_container, make_fake_images_inputs
 
 STAGE_NAME = "org.osbuild.container-deploy"
 
@@ -22,28 +22,15 @@ def test_container_deploy_integration(tmp_path, stage_module):
     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"
+            fake_oci_path = tmp_path / "fake-container"
             subprocess.check_call([
                 "podman", "save",
                 "--format=oci-archive",
-                f"--output={fake_container_dst}",
+                f"--output={fake_oci_path}",
                 cont_tag,
             ])
 
-    inputs = {
-        "images": {
-            # seems to be unused with fake_container_path?
-            "path": fake_container_dst,
-            "data": {
-                "archives": {
-                    fake_container_dst: {
-                        "format": "oci-archive",
-                        "name": cont_tag,
-                    },
-                },
-            },
-        },
-    }
+    inputs = make_fake_images_inputs(fake_oci_path, "some-name")
     output_dir = tmp_path / "output"
     options = {}
 
@@ -66,28 +53,15 @@ def test_container_deploy_exclude(tmp_path, stage_module):
         "dir2/file4": "dir2/file4 content",
     }) as base_tag:
         # export for the container-deploy stage
-        fake_container_dst = tmp_path / "fake-container"
+        fake_oci_path = tmp_path / "fake-container"
         subprocess.check_call([
             "podman", "save",
             "--format=oci-archive",
-            f"--output={fake_container_dst}",
+            f"--output={fake_oci_path}",
             base_tag,
         ])
 
-    inputs = {
-        "images": {
-            # seems to be unused with fake_container_path?
-            "path": fake_container_dst,
-            "data": {
-                "archives": {
-                    fake_container_dst: {
-                        "format": "oci-archive",
-                        "name": base_tag,
-                    },
-                },
-            },
-        },
-    }
+    inputs = make_fake_images_inputs(fake_oci_path, "some-name")
     options = {
         "exclude": [
             "file2",
diff --git a/stages/test/test_groups.py b/stages/test/test_groups.py
index 2d5f7081..a3940a3f 100644
--- a/stages/test/test_groups.py
+++ b/stages/test/test_groups.py
@@ -29,10 +29,3 @@ def test_schema_validation(stage_schema, test_data, expected_err):
     else:
         assert res.valid is False
         assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
-
-
-def test_schema_supports_bootc_style_mounts(stage_schema, bootc_devices_mounts_dict):
-    test_input = bootc_devices_mounts_dict
-    test_input["type"] = STAGE_NAME
-    res = stage_schema.validate(test_input)
-    assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
diff --git a/stages/test/test_kickstart.py b/stages/test/test_kickstart.py
index 1aeeb305..ad211dd4 100644
--- a/stages/test/test_kickstart.py
+++ b/stages/test/test_kickstart.py
@@ -234,6 +234,12 @@ TEST_INPUT = [
         },
         "ostreecontainer --url=http://some-ostree-url.com/foo",
     ),
+    ({"ostreecontainer": {"transport": "oci", "url": "/run/install/repo/container", }, },
+     "ostreecontainer --url=/run/install/repo/container --transport=oci",),
+    ({"ostreecontainer": {"transport": "oci-archive", "url": "/run/install/repo/container.tar", }, },
+     "ostreecontainer --url=/run/install/repo/container.tar --transport=oci-archive",),
+    ({"ostreecontainer": {"transport": "dir", "url": "/run/install/repo/container", }, },
+     "ostreecontainer --url=/run/install/repo/container --transport=dir",),
     ({"bootloader": {"append": "karg1 karg2=0"}}, "bootloader --append='karg1 karg2=0'"),
 ]
 
diff --git a/stages/test/test_selinux.py b/stages/test/test_selinux.py
index dca271b6..af1e1b56 100644
--- a/stages/test/test_selinux.py
+++ b/stages/test/test_selinux.py
@@ -48,13 +48,6 @@ def test_schema_validation_selinux_file_context_required(stage_schema):
     testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
-def test_schema_supports_bootc_style_mounts(stage_schema, bootc_devices_mounts_dict):
-    test_input = bootc_devices_mounts_dict
-    test_input["type"] = STAGE_NAME
-    res = stage_schema.validate(test_input)
-    assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
-
-
 @patch("osbuild.util.selinux.setfiles")
 def test_selinux_file_contexts(mocked_setfiles, tmp_path, stage_module):
     options = {
diff --git a/stages/test/test_skopeo.py b/stages/test/test_skopeo.py
index 2b591e72..13f897c0 100644
--- a/stages/test/test_skopeo.py
+++ b/stages/test/test_skopeo.py
@@ -1,8 +1,13 @@
 #!/usr/bin/python3
+import json
+import os
+import subprocess
+import tarfile
 
 import pytest
 
 from osbuild import testutil
+from osbuild.testutil import has_executable, make_container, make_fake_images_inputs
 
 STAGE_NAME = "org.osbuild.skopeo"
 
@@ -13,8 +18,15 @@ STAGE_NAME = "org.osbuild.skopeo"
     ({"destination": {}}, "is not valid under any of the given schemas"),
     ({"destination": {"type": "foo"}}, "is not valid under any of the given schemas"),
     ({"destination": {"type": "oci"}}, "is not valid under any of the given schemas"),
+    ({"destination": {"type": "dir"}}, "is not valid under any of the given schemas"),
+    ({"destination": {"type": "os-archive"}}, "is not valid under any of the given schemas"),
+    ({"destination": {"type": "dir", "path": "/foo"}, "remove-signatures": "YesPlease"},
+     "'YesPlease' is not of type 'boolean'"),
     # good
     ({"destination": {"type": "oci", "path": "/foo"}}, ""),
+    ({"destination": {"type": "oci-archive", "path": "/foo"}}, ""),
+    ({"destination": {"type": "dir", "path": "/foo"}}, ""),
+    ({"destination": {"type": "dir", "path": "/foo"}, "remove-signatures": True}, ""),
 
     # this one might not be expected but it's valid because we don't require any
     # *inputs* and it'll be a no-op in the stage
@@ -33,3 +45,101 @@ def test_schema_validation_skopeo(stage_schema, test_data, expected_err):
     else:
         assert res.valid is False
         testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
+
+
+def make_fake_oci_archive(tmp_path):
+    with make_container(tmp_path, {"file1": "file1 from final layer"}) as cont_tag:
+        # export for the container-deploy stage
+        fake_container_dst = tmp_path / "fake-container"
+        subprocess.check_call([
+            "podman", "save",
+            "--format=oci-archive",
+            f"--output={fake_container_dst}",
+            cont_tag,
+        ])
+    return fake_container_dst
+
+
+def assert_manifest_file(manifest_file):
+    assert manifest_file.exists()
+    data = json.loads(manifest_file.read_bytes())
+    assert data.get("config") is not None, \
+        "'manifest.json' seems corrupt - no 'config' section found"
+    assert data["config"].get("digest") is not None, \
+        "'manifest.json' seems corrupt - no 'config.digest' section found"
+
+
+def _test_skopeo_copy(tmp_path, stage_module, typ, dest_name):
+    fake_oci_path = make_fake_oci_archive(tmp_path)
+    inputs = make_fake_images_inputs(fake_oci_path, "some-name")
+
+    output_dir = tmp_path / "output"
+    local_path = f"/some/{dest_name}"
+    options = {
+        "destination": {
+            "type": typ,
+            "path": local_path,
+        }
+    }
+    stage_module.main(inputs, output_dir, options)
+    result = output_dir / local_path.lstrip("/")
+    assert result.exists()
+    return result
+
+
+@pytest.mark.skipif(os.getuid() != 0, reason="needs root")
+@pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
+def test_skopeo_copy_dir(tmp_path, stage_module):
+    result_path = _test_skopeo_copy(tmp_path, stage_module, "dir", "skopeo-dir")
+    assert (result_path / "version").exists()
+    assert_manifest_file(result_path / "manifest.json")
+
+
+@pytest.mark.skipif(os.getuid() != 0, reason="needs root")
+@pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
+def test_skopeo_copy_oci_archive(tmp_path, stage_module):
+    result_path = _test_skopeo_copy(tmp_path, stage_module, "oci-archive", "skopeo-archive.tar")
+    assert result_path.exists()
+    with tarfile.open(result_path) as tf:
+        assert tf
+
+
+@pytest.mark.skipif(os.getuid() != 0, reason="needs root")
+@pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
+def test_skopeo_copy_oci(tmp_path, stage_module):
+    result_path = _test_skopeo_copy(tmp_path, stage_module, "oci", "skopeo-archive-oci")
+    index_file = (result_path / "index.json")
+    assert index_file.exists()
+
+    data = json.loads(index_file.read_bytes())
+    assert data.get("manifests") is not None, "'index.json' seems corrupt - no 'manifests' section found"
+    assert data["manifests"][0].get("digest") is not None, \
+        "'manifest.json' seems corrupt - no 'manifests[0].digest' section found"
+    data_digest = data["manifests"][0]["digest"].split(':')
+    manifest_file = result_path / "blobs" / data_digest[0] / data_digest[1]
+    assert_manifest_file(manifest_file)
+
+
+@pytest.mark.parametrize("remove_signatures", [True, False])
+def test_skopeo_copy_remove_signatures(tmp_path, stage_module, remove_signatures):
+    # We are completely faking everything here, because we mock out skopeo
+    fake_oci_path = tmp_path / "fake-container"
+    inputs = make_fake_images_inputs(fake_oci_path, "some-name")
+
+    with testutil.mock_command("skopeo", "") as args:
+        options = {
+            "destination": {
+                "type": "oci",
+                "path": "/tmp/test-output-skopeo-dir",
+            },
+            "remove-signatures": remove_signatures,
+        }
+        output_dir = tmp_path / "output"
+        stage_module.main(inputs, output_dir, options)
+
+        if remove_signatures:
+            # Check that skopeo has --remove-signatures right after the copy subcommand
+            assert args.call_args_list[0][0:2] == ["copy", "--remove-signatures"]
+        else:
+            # Check that --remove-signatures is not present in the skopeo command
+            assert "--remove-signatures" not in args.call_args_list[0]
diff --git a/stages/test/test_users.py b/stages/test/test_users.py
index 392449fb..c74cbe55 100644
--- a/stages/test/test_users.py
+++ b/stages/test/test_users.py
@@ -36,13 +36,6 @@ def test_schema_validation(stage_schema, test_data, expected_err):
         assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
-def test_schema_supports_bootc_style_mounts(stage_schema, bootc_devices_mounts_dict):
-    test_input = bootc_devices_mounts_dict
-    test_input["type"] = STAGE_NAME
-    res = stage_schema.validate(test_input)
-    assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
-
-
 TEST_CASES = [
     # user_opts,expected commandline args
     ({}, []),
diff --git a/test/mod/test_meta.py b/test/mod/test_meta.py
index 6adbe5ba..419199a6 100644
--- a/test/mod/test_meta.py
+++ b/test/mod/test_meta.py
@@ -359,3 +359,57 @@ def test_get_schema_automatically_added(tmp_path, property_key, expected_value):
     modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", "org.osbuild.noop")
     json_schema = modinfo.get_schema()
     assert json_schema["properties"][property_key] == expected_value
+
+
+@pytest.fixture(name="bootc_devices_mounts_dict")
+def bootc_devices_mounts_dict_fixture() -> dict:
+    """ bootc_devices_mounts_dict returns a dict with a typical bootc
+    devices/mount dict
+    """
+    return {
+        "devices": {
+            "disk": {
+                "type": "org.osbuild.loopback",
+                "options": {
+                    "filename": "disk.raw",
+                    "partscan": True,
+                }
+            }
+        },
+        "mounts": [
+            {
+                "name": "root",
+                "type": "org.osbuild.ext4",
+                "source": "disk",
+                "partition": 4,
+                "target": "/"
+            }, {
+                "name": "ostree.deployment",
+                "type": "org.osbuild.ostree.deployment",
+                "options": {
+                    "source": "mount",
+                    "deployment": {
+                        "default": True,
+                    }
+                }
+            }
+        ]
+    }
+
+
+@pytest.mark.parametrize("fmt_ver,name_or_type_key", [
+    (1, "name"),
+    (2, "type"),
+])
+def test_get_schema_devices_mounts_works(tmp_path, bootc_devices_mounts_dict, fmt_ver, name_or_type_key):
+    stage_name = "org.osbuild.noop"
+
+    fake_stage = bootc_devices_mounts_dict
+    fake_stage[name_or_type_key] = stage_name
+
+    make_fake_meta_json(tmp_path, stage_name, version=fmt_ver)
+    modinfo = osbuild.meta.ModuleInfo.load(tmp_path, "Stage", stage_name)
+    json_schema = modinfo.get_schema(version=str(fmt_ver))
+    stage_schema = osbuild.meta.Schema(json_schema, stage_name)
+    res = stage_schema.validate(fake_stage)
+    assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
diff --git a/test/mod/test_testutil_make_container.py b/test/mod/test_testutil_make_container.py
index 2c8a7717..eb2c064e 100644
--- a/test/mod/test_testutil_make_container.py
+++ b/test/mod/test_testutil_make_container.py
@@ -9,7 +9,7 @@ import pytest
 from osbuild.testutil import has_executable, make_container, mock_command
 
 
-def test_make_container_bad_podman_prints_podman_output(tmp_path, capsys):
+def test_make_container_bad_podman_prints_podman_output(tmp_path, capfd):
     fake_broken_podman = textwrap.dedent("""\
     #!/bin/sh
     echo fake-broken-podman
@@ -19,11 +19,12 @@ def test_make_container_bad_podman_prints_podman_output(tmp_path, capsys):
         with pytest.raises(subprocess.CalledProcessError):
             with make_container(tmp_path, {}) as _:
                 pass
-    assert "fake-broken-podman" in capsys.readouterr().out
+    assert "fake-broken-podman" in capfd.readouterr().out
 
 
 @pytest.mark.skipif(not has_executable("podman"), reason="no podman executable")
-def test_make_container_integration(tmp_path, capsys):
+def test_make_container_integration(tmp_path, capfd):
     with make_container(tmp_path, {"/etc/foo": "foo-content"}) as cref:
-        assert len(cref) == 64
-    assert "COMMIT" in capsys.readouterr().out
+        # names have the form "osubild-test-<random-number-of-len12>"
+        assert len(cref) == len("osbuild-test-123456789012")
+    assert "COMMIT" in capfd.readouterr().out
diff --git a/test/mod/test_util_mnt.py b/test/mod/test_util_mnt.py
index 3b706642..259ac026 100644
--- a/test/mod/test_util_mnt.py
+++ b/test/mod/test_util_mnt.py
@@ -1,4 +1,5 @@
 import os
+import re
 
 import pytest
 
@@ -10,7 +11,9 @@ from osbuild.util.mnt import MountGuard, mount
 def test_mount_failure_msg(tmp_path):
     with pytest.raises(RuntimeError) as e:
         mount("/dev/invalid-src", tmp_path)
-    assert "special device /dev/invalid-src does not exist" in str(e.value)
+    # latest util-linux mount uses fsconfig(2) instead of mount(2) so the
+    # error is different
+    assert re.search(r"special device /dev/invalid-src does not exist|Can't lookup blockdev.", str(e.value))
 
 
 @pytest.mark.skipif(os.getuid() != 0, reason="root only")
@@ -18,7 +21,9 @@ def test_mount_guard_failure_msg(tmp_path):
     with pytest.raises(RuntimeError) as e:
         with MountGuard() as mg:
             mg.mount("/dev/invalid-src", tmp_path)
-    assert "special device /dev/invalid-src does not exist" in str(e.value)
+    # latest util-linux mount uses fsconfig(2) instead of mount(2) so the
+    # error is different
+    assert re.search(r"special device /dev/invalid-src does not exist|Can't lookup blockdev.", str(e.value))
 
 
 @pytest.mark.skipif(os.getuid() != 0, reason="root only")
@@ -52,4 +57,4 @@ def test_osbuild_mount_failure_msg(tmp_path):
             "options": [],
         }
         mnt_service.mount(args)
-    assert "special device /dev/invalid-src does not exist" in str(e.value)
+    assert re.search(r"special device /dev/invalid-src does not exist|Can't open blockdev.", str(e.value))
diff --git a/test/mod/test_util_path.py b/test/mod/test_util_path.py
index cfeb1656..017671b2 100644
--- a/test/mod/test_util_path.py
+++ b/test/mod/test_util_path.py
@@ -3,6 +3,7 @@
 #
 import os
 import time
+from datetime import datetime
 from pathlib import Path
 from tempfile import TemporaryDirectory
 
@@ -18,7 +19,10 @@ def tempdir_fixture():
 
 
 def test_clamp_mtime(tempdir):
-    start = int(time.time())
+    # This does not need to be precise, anything created within the last 1½h
+    # should be "clamped". By adding a bit of slack chrony, random
+    # RTC jumps and DST/timezone changes will not affect the test
+    clamp_start = int(time.time() - 90 * 60)
 
     tree = Path(tempdir, "tree")
     tree.mkdir()
@@ -33,12 +37,13 @@ def test_clamp_mtime(tempdir):
     link = Path(tree, "link")
     link.symlink_to(folder, target_is_directory=True)
 
-    timepoint = 1644758228
-    path.clamp_mtime(tree, start, timepoint)
+    # Some time way in the past
+    clamp_to = int(datetime(2022, 2, 13, 14, 17, 8).timestamp())
+    path.clamp_mtime(tree, clamp_start, clamp_to)
 
     def ensure_mtime(target, dfd):
         stat = os.stat(target, dir_fd=dfd, follow_symlinks=False)
-        assert stat.st_mtime <= timepoint, f"failed for {target} ({dfd})"
+        assert stat.st_mtime <= clamp_to, f"failed for {target} ({dfd})"
 
     for _, dirs, files, dfd in os.fwalk(tree):
         ensure_mtime(".", dfd)
diff --git a/tools/test/test_depsolve.py b/tools/test/test_depsolve.py
index 89b3507d..fb92cac0 100644
--- a/tools/test/test_depsolve.py
+++ b/tools/test/test_depsolve.py
@@ -225,7 +225,7 @@ def config_combos(tmp_path, servers):
                 "name": server["name"],
                 "baseurl": server["address"],
                 "check_gpg": False,
-                "ignoressl": True,
+                "sslverify": False,
                 "rhsm": False,
                 "gpgkeys": [TEST_KEY + server["name"]],
             })
@@ -277,3 +277,4 @@ def test_depsolve(tmp_path, cache_dir, repo_servers, dnf_cmd, detect_fn, test_ca
         assert res["repos"].keys() == test_case["results"]["reponames"]
         for repo in res["repos"].values():
             assert repo["gpgkeys"] == [TEST_KEY + repo["id"]]
+            assert repo["sslverify"] is False