Repository: osbuild/osbuild
Pull request #1458 (2023-11-21 07:47:17)

Create fake `machine-id` and cleanup before exiting
- runners: create fake machine id when needed
- runners: clean up temp files before exiting the runner



Patch:
diff --git a/osbuild/monitor.py b/osbuild/monitor.py
index b5b499e7..9741a415 100644
--- a/osbuild/monitor.py
+++ b/osbuild/monitor.py
@@ -56,10 +56,10 @@ class BaseMonitor(abc.ABC):
         self.out = TextWriter(fd)
 
     def begin(self, pipeline: osbuild.Pipeline):
-        """Called once at the beginning of a build"""
+        """Called once at the beginning of a pipeline"""
 
-    def finish(self, result: Dict):
-        """Called at the very end of the build"""
+    def finish(self, results: Dict):
+        """Called at the very end of a pipeline"""
 
     def stage(self, stage: osbuild.Stage):
         """Called when a stage is being built"""
@@ -68,7 +68,7 @@ class BaseMonitor(abc.ABC):
         """Called when an assembler is being built"""
 
     def result(self, result: osbuild.pipeline.BuildResult):
-        """Called when a module is done with its result"""
+        """Called when a module (stage/assembler) is done with its result"""
 
     def log(self, message: str):
         """Called for all module log outputs"""
@@ -79,11 +79,11 @@ class NullMonitor(BaseMonitor):
 
 
 class LogMonitor(BaseMonitor):
-    """Monitor that follows show the log output of modules
+    """Monitor that follows the log output of modules
 
     This monitor will print a header with `name: id` followed
     by the options for each module as it is being built. The
-    full log messages of the modules will be print as soon as
+    full log messages of the modules will be printed as soon as
     they become available.
     The constructor argument `fd` is a file descriptor, where
     the log will get written to. If `fd`  is a `TTY`, escape
diff --git a/osbuild/util/runners.py b/osbuild/util/runners.py
index 3688a867..ec2a3fec 100644
--- a/osbuild/util/runners.py
+++ b/osbuild/util/runners.py
@@ -1,8 +1,10 @@
 import os.path
+import pathlib
 import platform
 import shutil
 import subprocess
 import sys
+from contextlib import contextmanager
 
 
 def ldconfig(*dirs):
@@ -31,12 +33,39 @@ def sysusers():
         sys.exit(1)
 
 
+@contextmanager
+def create_machine_id_if_needed(tree="", keep_empty=False):
+    """Create a machine-id with a fake machine id if it does not exist.
+       The machine-id file will be delete at context exit unless specified
+       with 'keep_empty' variable. In that case an empty machine-id will
+       be kept.
+    """
+    path = pathlib.Path(f"{tree}/etc/machine-id")
+    try:
+        if not path.exists():
+            path.parent.mkdir(mode=0o755, exist_ok=True)
+            with path.open(mode="w", encoding="utf8") as f:
+                # create a fake machine ID to improve reproducibility
+                f.write("ffffffffffffffffffffffffffffffff\n")
+                path.chmod(0o444)
+        yield
+    finally:
+        path.unlink()
+        if keep_empty:
+            path.touch()
+            path.chmod(0o444)
+
+
 def tmpfiles():
     # Allow systemd-tmpfiles to return non-0. Some packages want to create
     # directories owned by users that are not set up with systemd-sysusers.
     subprocess.run(["systemd-tmpfiles", "--create"], check=False)
 
 
+def remove_tmpfiles():
+    subprocess.run(["systemd-tmpfiles", "--clean", "--remove"], check=False)
+
+
 def nsswitch():
     # the default behavior is fine, but using nss-resolve does not
     # necessarily work in a non-booted container, so make sure that
@@ -65,7 +94,8 @@ def sequoia():
     # images).
     os.makedirs("/etc/crypto-policies", exist_ok=True)
     shutil.copytree(
-        "/usr/share/crypto-policies/back-ends/DEFAULT", "/etc/crypto-policies/back-ends"
+        "/usr/share/crypto-policies/back-ends/DEFAULT",
+        "/etc/crypto-policies/back-ends"
     )
 
 
diff --git a/runners/org.osbuild.centos9 b/runners/org.osbuild.centos9
index a6a7b7c2..b9a6c397 100755
--- a/runners/org.osbuild.centos9
+++ b/runners/org.osbuild.centos9
@@ -10,9 +10,10 @@ if __name__ == "__main__":
     with api.exception_handler():
         runners.ldconfig()
         runners.sysusers()
-        runners.tmpfiles()
-        runners.nsswitch()
-
-        r = subprocess.run(sys.argv[1:], check=False)
+        with runners.create_machine_id_if_needed():
+            runners.tmpfiles()
+            runners.nsswitch()
+            r = subprocess.run(sys.argv[1:], check=False)
+            runners.remove_tmpfiles()
 
         sys.exit(r.returncode)
diff --git a/runners/org.osbuild.fedora30 b/runners/org.osbuild.fedora30
index a6a7b7c2..b9a6c397 100755
--- a/runners/org.osbuild.fedora30
+++ b/runners/org.osbuild.fedora30
@@ -10,9 +10,10 @@ if __name__ == "__main__":
     with api.exception_handler():
         runners.ldconfig()
         runners.sysusers()
-        runners.tmpfiles()
-        runners.nsswitch()
-
-        r = subprocess.run(sys.argv[1:], check=False)
+        with runners.create_machine_id_if_needed():
+            runners.tmpfiles()
+            runners.nsswitch()
+            r = subprocess.run(sys.argv[1:], check=False)
+            runners.remove_tmpfiles()
 
         sys.exit(r.returncode)
diff --git a/runners/org.osbuild.fedora38 b/runners/org.osbuild.fedora38
index 92fb55eb..45a7c245 100755
--- a/runners/org.osbuild.fedora38
+++ b/runners/org.osbuild.fedora38
@@ -10,9 +10,11 @@ if __name__ == "__main__":
     with api.exception_handler():
         runners.ldconfig()
         runners.sysusers()
-        runners.tmpfiles()
-        runners.nsswitch()
-        runners.sequoia()
+        with runners.create_machine_id_if_needed():
+            runners.tmpfiles()
+            runners.nsswitch()
+            runners.sequoia()
+            r = subprocess.run(sys.argv[1:], check=False)
+            runners.remove_tmpfiles()
 
-        r = subprocess.run(sys.argv[1:], check=False)
         sys.exit(r.returncode)
diff --git a/runners/org.osbuild.rhel81 b/runners/org.osbuild.rhel81
index d00399e7..9a911dea 100755
--- a/runners/org.osbuild.rhel81
+++ b/runners/org.osbuild.rhel81
@@ -35,10 +35,12 @@ if __name__ == "__main__":
     with api.exception_handler():
         runners.ldconfig()
         runners.sysusers()
-        runners.tmpfiles()
-        runners.nsswitch()
-        os_release()
-        runners.python_alternatives()
+        with runners.create_machine_id_if_needed():
+            runners.tmpfiles()
+            runners.nsswitch()
+            os_release()
+            runners.python_alternatives()
+            r = subprocess.run(sys.argv[1:], check=False)
+            runners.remove_tmpfiles()
 
-        r = subprocess.run(sys.argv[1:], check=False)
         sys.exit(r.returncode)
diff --git a/runners/org.osbuild.rhel82 b/runners/org.osbuild.rhel82
index 71e6841b..1c14e9da 100755
--- a/runners/org.osbuild.rhel82
+++ b/runners/org.osbuild.rhel82
@@ -10,12 +10,12 @@ if __name__ == "__main__":
     with api.exception_handler():
         runners.ldconfig()
         runners.sysusers()
-        runners.tmpfiles()
-        runners.nsswitch()
-        runners.python_alternatives()
-
-        env = runners.quirks()
-
-        r = subprocess.run(sys.argv[1:], env=env, check=False)
+        with runners.create_machine_id_if_needed():
+            runners.tmpfiles()
+            runners.nsswitch()
+            runners.python_alternatives()
+            env = runners.quirks()
+            r = subprocess.run(sys.argv[1:], env=env, check=False)
+            runners.remove_tmpfiles()
 
         sys.exit(r.returncode)
diff --git a/stages/org.osbuild.kickstart b/stages/org.osbuild.kickstart
index 22434350..9d5f5372 100755
--- a/stages/org.osbuild.kickstart
+++ b/stages/org.osbuild.kickstart
@@ -17,6 +17,80 @@ from typing import Dict, List
 import osbuild.api
 
 SCHEMA = r"""
+"definitions": {
+  "ksboolean-from-pykickstart-ref": {
+    "enum": ["yes", "on", "true", "1", 1, "off", "no", "false", "0", 0]
+  },
+  "network-options-ref": {
+    "type": "object",
+    "additionalProperties": false,
+    "description": "Configures network information for the target system and activates network devices in the installation environment",
+    "required": ["device"],
+    "properties": {
+      "activate": {
+        "description": "Activate (or reactivate) the device. When set to false prevents the device from automatically activating on boot",
+        "type": "boolean"
+      },
+      "bootproto": {
+        "description": "Boot protocol selection",
+        "enum": ["dhcp", "bootp", "ibft", "static"]
+      },
+      "device": {
+        "description": "Specifies the device to be configured",
+        "type": "string",
+        "pattern": "^([a-zA-Z0-9]{2,16}|(([a-fA-F0-9]{2}:){5}[a-fA-F0-9A]{2}))$"
+      },
+      "onboot": {
+        "description": "Whether or not to enable the device at boot time",
+        "$ref": "#/definitions/ksboolean-from-pykickstart-ref"
+      },
+      "ip": {
+        "description": "IP address of the device",
+        "type": "string",
+        "pattern": "^(((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}|auto)$"
+      },
+      "ipv6": {
+        "description": "IPv6 address of the device, in the form of address[/prefix length]",
+        "type": "string"
+      },
+      "gateway": {
+        "description": "Default gateway as a single IPv4 address",
+        "type": "string",
+        "pattern": "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$"
+      },
+      "ipv6gateway": {
+        "description": "Default gateway as a single IPv6 address",
+        "type": "string"
+      },
+      "nameservers": {
+        "description": "DNS name servesr, a list of IP addresses",
+        "type": "array",
+        "minSize": 1,
+        "items": {
+          "type": "string",
+          "pattern": "^((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}$"
+        }
+      },
+      "netmask": {
+        "description": "Network mask for the installed system",
+        "type": "string"
+      },
+      "hostname": {
+        "description": "Host name for the installed system",
+        "type": "string",
+        "pattern": "(?=^.{1,253}$)^[a-zA-Z0-9][a-zA-Z0-9-]{0,62}((\\.[a-zA-Z0-9][a-zA-Z0-9-]{0,62})+)?$"
+      },
+      "essid": {
+        "description": "The network ID for wireless networks",
+        "type": "string"
+      },
+      "wpakey": {
+        "description": "The WPA encryption key for wireless networks",
+        "type": "string"
+      }
+    }
+  }
+},
 "additionalProperties": false,
 "required": ["path"],
 "properties": {
@@ -262,6 +336,13 @@ SCHEMA = r"""
         "type": "integer"
       }
     }
+  },
+  "network": {
+    "type": "array",
+    "minItems": 1,
+    "items": {
+      "$ref": "#/definitions/network-options-ref"
+    }
   }
 }
 """
@@ -382,6 +463,38 @@ def make_autopart(options: Dict) -> str:
     return cmd
 
 
+def make_network(options: Dict) -> List[str]:
+    networks = options.get("network")
+    if networks is None:
+        return []
+    res = []
+    for net in networks:
+        cmd = "network"
+        # simple string options
+        for key in [
+                "device", "bootproto", "onboot", "ip", "ipv6", "gateway",
+                "ipv6gateway", "netmask", "hostname", "essid", "wpakey",
+        ]:
+            if key not in net:
+                continue
+            val = net.get(key)
+            cmd += f" --{key}={val}"
+        # nameservers is a list
+        nameservers = net.get("nameservers")
+        if nameservers:
+            nss = " ".join([f"--nameserver={ns}" for ns in nameservers])
+            cmd += f" {nss}"
+        # activate can be true/false/unset
+        activate = net.get("activate")
+        if isinstance(activate, bool):
+            if activate:
+                cmd += " --activate"
+            else:
+                cmd += " --no-activate"
+        res += [cmd]
+    return res
+
+
 def main(tree, options):  # pylint: disable=too-many-branches
     path = options["path"].lstrip("/")
     ostree = options.get("ostree")
@@ -433,6 +546,7 @@ def main(tree, options):  # pylint: disable=too-many-branches
     reboot = make_reboot(options)
     if reboot:
         config += [reboot]
+    config += make_network(options)
 
     target = os.path.join(tree, path)
     base = os.path.dirname(target)
diff --git a/stages/org.osbuild.rpm b/stages/org.osbuild.rpm
index bdc33779..80b8bf86 100755
--- a/stages/org.osbuild.rpm
+++ b/stages/org.osbuild.rpm
@@ -35,7 +35,6 @@ This stage will return the following metadata via the osbuild API:
 import contextlib
 import json
 import os
-import pathlib
 import subprocess
 import sys
 import tempfile
@@ -43,6 +42,7 @@ from operator import itemgetter
 
 from osbuild import api
 from osbuild.util.mnt import mount
+from osbuild.util.runners import create_machine_id_if_needed
 
 SCHEMA = """
 "additionalProperties": false,
@@ -263,21 +263,6 @@ def parse_input(inputs):
     return path, files
 
 
-def create_machine_id_if_needed(tree):
-    """Create a machine-id with a fake machine id if it does not exist"""
-    path = f"{tree}/etc/machine-id"
-    if os.path.exists(path):
-        return False
-
-    os.makedirs(f"{tree}/etc", exist_ok=True)
-    with open(path, "w", encoding="utf8") as f:
-        # create a fake machine ID to improve reproducibility
-        f.write("ffffffffffffffffffffffffffffffff\n")
-        os.fchmod(f.fileno(), 0o400)
-
-    return True
-
-
 # pylint: disable=too-many-branches
 def main(tree, inputs, options):
     pkgpath, packages = parse_input(inputs)
@@ -322,69 +307,61 @@ def main(tree, inputs, options):
 
     os.symlink("/proc/self/fd", f"{tree}/dev/fd")
 
-    machine_id_created = create_machine_id_if_needed(tree)
-
-    ostree_booted = None
-    if options.get("ostree_booted", False):
-        os.makedirs(f"{tree}/run", exist_ok=True)
-        ostree_booted = f"{tree}/{OSTREE_BOOTED_MARKER}"
-        with open(ostree_booted, "w", encoding="utf8") as f:
-            f.write("")
-
-    extra_args = []
-    if options.get("exclude", {}).get("docs"):
-        extra_args += ["--excludedocs"]
-
-    # prevent dracut from running, if enabled
-    no_dracut = options.get("disable_dracut", False)
-    if no_dracut:
-        masked_files = disable_dracut(tree)
-
-    langs = options.get("install_langs")
-    if langs:
-        macro = "%_install_langs " + ":".join(langs)
-        print(f"using '{macro}'")
-        extra_args += [
-            "--define", macro,
-        ]
-
-    with tempfile.NamedTemporaryFile(prefix="manifest.", mode='w') as manifest:
-        manifest.writelines(c + '\n' for c in packages)
-        manifest.flush()
-        subprocess.run([
-            "rpm",
-            "--verbose",
-            *rpm_args,
-            "--root", tree,
-            *extra_args,
-            # All digests and signatures of the rpms has been verified,
-            # default to not verifying package or header signatures when
-            # reading.
-            "--nosignature",
-            "--install", manifest.name
-        ], cwd=pkgpath, check=True)
-
-    for key in options.get("gpgkeys.fromtree", []):
-        path = os.path.join(tree, key.lstrip("/"))
-        subprocess.run([
-            "rpmkeys",
-            *rpm_args,
-            "--root", tree,
-            "--import", path
-        ], check=True)
-        print(f"imported gpg keys from '{key}'")
-
-    # re-enabled dracut
-    if no_dracut:
-        enable_dracut(masked_files)
-        remove_unowned_etc_kernel(tree, rpm_args)
-
-    # remove temporary machine ID if it was created by us
-    if machine_id_created:
-        print("deleting the fake machine id")
-        machine_id_file = pathlib.Path(f"{tree}/etc/machine-id")
-        machine_id_file.unlink()
-        machine_id_file.touch(mode=0o444)
+    with create_machine_id_if_needed(tree, keep_empty=True):
+        ostree_booted = None
+        if options.get("ostree_booted", False):
+            os.makedirs(f"{tree}/run", exist_ok=True)
+            ostree_booted = f"{tree}/{OSTREE_BOOTED_MARKER}"
+            with open(ostree_booted, "w", encoding="utf8") as f:
+                f.write("")
+
+        extra_args = []
+        if options.get("exclude", {}).get("docs"):
+            extra_args += ["--excludedocs"]
+
+        # prevent dracut from running, if enabled
+        no_dracut = options.get("disable_dracut", False)
+        if no_dracut:
+            masked_files = disable_dracut(tree)
+
+        langs = options.get("install_langs")
+        if langs:
+            macro = "%_install_langs " + ":".join(langs)
+            print(f"using '{macro}'")
+            extra_args += [
+                "--define", macro,
+            ]
+
+        with tempfile.NamedTemporaryFile(prefix="manifest.", mode='w') as manifest:
+            manifest.writelines(c + '\n' for c in packages)
+            manifest.flush()
+            subprocess.run([
+                "rpm",
+                "--verbose",
+                *rpm_args,
+                "--root", tree,
+                *extra_args,
+                # All digests and signatures of the rpms has been verified,
+                # default to not verifying package or header signatures when
+                # reading.
+                "--nosignature",
+                "--install", manifest.name
+            ], cwd=pkgpath, check=True)
+
+        for key in options.get("gpgkeys.fromtree", []):
+            path = os.path.join(tree, key.lstrip("/"))
+            subprocess.run([
+                "rpmkeys",
+                *rpm_args,
+                "--root", tree,
+                "--import", path
+            ], check=True)
+            print(f"imported gpg keys from '{key}'")
+
+        # re-enabled dracut
+        if no_dracut:
+            enable_dracut(masked_files)
+            remove_unowned_etc_kernel(tree, rpm_args)
 
     if ostree_booted:
         os.unlink(ostree_booted)


Test patch:
diff --git a/stages/test/test_kickstart.py b/stages/test/test_kickstart.py
index 6accce71..8428a266 100644
--- a/stages/test/test_kickstart.py
+++ b/stages/test/test_kickstart.py
@@ -157,14 +157,49 @@ TEST_INPUT = [
         " --backuppassphrase --cipher=twofish-cbc --luks-version=2" +
         " --pbkdf=scrypt --pbkdf-memory=256 --pbkdf-time=512"
     ),
+    # network is always a list
+    ({"network": [{"device": "foo", "activate": False}, {"device": "bar", "bootproto": "dhcp"}]},
+     "network --device=foo --no-activate\nnetwork --device=bar --bootproto=dhcp"),
+    ({"network": [{"device": "foo", "activate": True}]}, "network --device=foo --activate"),
+    ({"network": [{"device": "foo", "activate": False}]}, "network --device=foo --no-activate"),
+    ({"network": [{"device": "foo", "bootproto": "dhcp"}]}, "network --device=foo --bootproto=dhcp"),
+    ({"network": [{"device": "foo", "onboot": "on"}]}, "network --device=foo --onboot=on"),
+    ({"network": [{"device": "foo", "ip": "10.0.0.2"}]}, "network --device=foo --ip=10.0.0.2"),
+    ({"network": [{"device": "foo", "ip": "auto"}]}, "network --device=foo --ip=auto"),
+    ({"network": [{"device": "foo", "ipv6": "3ffe:ffff:0:1::1/128"}]},
+     "network --device=foo --ipv6=3ffe:ffff:0:1::1/128"),
+    ({"network": [{"device": "foo", "ipv6": "dhcp"}]}, "network --device=foo --ipv6=dhcp"),
+    ({"network": [{"device": "foo", "gateway": "10.0.0.1"}]}, "network --device=foo --gateway=10.0.0.1"),
+    ({"network": [{"device": "foo", "ipv6gateway": "FE80::1"}]}, "network --device=foo --ipv6gateway=FE80::1"),
+    ({"network": [{"device": "foo", "nameservers": ["1.1.1.1"]}]}, "network --device=foo --nameserver=1.1.1.1"),
+    ({"network": [{"device": "foo", "nameservers": ["1.1.1.1", "8.8.8.8"]}]},
+     "network --device=foo --nameserver=1.1.1.1 --nameserver=8.8.8.8"),
+    ({"network": [{"device": "foo", "netmask": "255.255.0.0"}]}, "network --device=foo --netmask=255.255.0.0"),
+    ({"network": [{"device": "foo", "hostname": "meep"}]}, "network --device=foo --hostname=meep"),
+    ({"network": [{"device": "foo", "essid": "wlan-123"}]}, "network --device=foo --essid=wlan-123"),
+    ({"network": [{"device": "foo", "wpakey": "secret"}]}, "network --device=foo --wpakey=secret"),
+    # device= can be written in multiple forms, see
+    # https://pykickstart.readthedocs.io/en/latest/kickstart-docs.html#network
+    ({"network": [{"device": "1234567890123456"}]}, "network --device=1234567890123456"),
+    ({"network": [{"device": "em1"}]}, "network --device=em1"),
+    ({"network": [{"device": "01:23:45:67:89:ab"}]}, "network --device=01:23:45:67:89:ab"),
+    ({"network": [{"device": "link"}]}, "network --device=link"),
+    ({"network": [{"device": "bootif"}]}, "network --device=bootif"),
+    # hostname alone can be 63 chars (see hostname(7))
+    ({"network": [{"device": "foo", "hostname": "123456789012345678901234567890123456789012345678901234567890123"}]},
+     "network --device=foo --hostname=123456789012345678901234567890123456789012345678901234567890123"),
+    # hostname can also be written as a QFDN
+    ({"network": [{"device": "foo", "hostname": "foo.bar.com"}]},
+     "network --device=foo --hostname=foo.bar.com"),
 ]
 
 
 def schema_validate_kickstart_stage(test_data):
     name = "org.osbuild.kickstart"
+    version = "1"
     root = os.path.join(os.path.dirname(__file__), "../..")
     mod_info = osbuild.meta.ModuleInfo.load(root, "Stage", name)
-    schema = osbuild.meta.Schema(mod_info.get_schema(), name)
+    schema = osbuild.meta.Schema(mod_info.get_schema(version=version), name)
     test_input = {
         "name": "org.osbuild.kickstart",
         "options": {
@@ -240,6 +275,49 @@ def test_kickstart_valid(tmp_path, test_input, expected):  # pylint: disable=unu
         ({"autopart": {"type": "not-valid"}}, "'not-valid' is not one of ["),
         # Only one of --pbkdf-{time,iterations} can be specified at the same time
         ({"autopart": {"pbkdf-time": 1, "pbkdf-iterations": 2}}, " should not be valid under "),
+        # network is always a list
+        ({"network": {"device": "foo"}}, " is not of type 'array'"),
+        ({"network": [{"device": "foo", "activate": "string"}]}, " is not of type 'boolean'"),
+        ({"network": [{"device": "foo", "random": "option"}]}, "Additional properties are not allowed "),
+        ({"network": [{"device": "foo", "bootproto": "invalid"}]}, " is not one of ["),
+        ({"network": [{"device": "foo", "ip": "invalid"}]}, " does not match "),
+        ({"network": [{"device": "foo", "ip": "256.1.1.1"}]}, " does not match "),
+        ({"network": [{"device": "foo", "ip": "1.256.1.1"}]}, " does not match "),
+        ({"network": [{"device": "foo", "ip": "1.1.256.1"}]}, " does not match "),
+        ({"network": [{"device": "foo", "ip": "1.1.1.256"}]}, " does not match "),
+        # kernel will accept this (and make it 127.0.0.1) but it's
+        # technically not valid. if this becomes a problem we may need to
+        # relax (or remove) the ipv4 validation regex. Also
+        # 127.256 will be accepted and overflows into "127.0.1.0".
+        ({"network": [{"device": "foo", "ip": "127.1"}]}, " does not match "),
+        ({"network": [{"device": "foo", "gateway": "invalid"}]}, " does not match "),
+        ({"network": [{"device": "foo", "nameservers": ["invalid"]}]}, " does not match "),
+        # schema says at least 2 chars (this is arbitrary)
+        ({"network": [{"device": "f"}]}, " does not match "),
+        # device name can be max 16 chars (see IFNAMSIZ in the kernel source)
+        # but otherwise are very free from, see
+        # https://elixir.bootlin.com/linux/v6.6.1/source/net/core/dev.c#L1038
+        ({"network": [{"device": "12345678901234567"}]}, " does not match "),
+        # when specificed via MAC address it 17 chars (12 chars plus 5 ":")
+        # and look like a mac address
+        ({"network": [{"device": "00:01"}]}, " does not match "),
+        ({"network": [{"device": "00:XX"}]}, " does not match "),
+        ({"network": [{"device": "foo/bar"}]}, " does not match "),
+        ({"network": [{"device": "foo?"}]}, " does not match "),
+        # see hostname(7)
+        ({"network": [{"device": "foo",
+                       "hostname": "1234567890123456789012345678901234567890123456789012345678901234"}]}, " does not match "),
+        ({"network": [{"device": "foo", "hostname": "x$"}]}, " does not match "),
+        ({"network": [{"device": "foo", "hostname": "-invalid"}]}, " does not match "),
+        ({"network": [{"device": "foo", "hostname": "foo..bar"}]}, " does not match "),
+        # not more than 253 chars (63*3 + 62 + 3 dots = 254)
+        ({"network": [{"device": "foo",
+                       "hostname":
+                       "123456789012345678901234567890123456789012345678901234567890123" +
+                       ".123456789012345678901234567890123456789012345678901234567890123" +
+                       ".123456789012345678901234567890123456789012345678901234567890123" +
+                       ".12345678901234567890123456789012345678901234567890123456789012"
+                       }]}, " does not match "),
     ],
 )
 def test_schema_validation_bad_apples(test_data, expected_err):
diff --git a/stages/test/test_ostree_post_copy.py b/stages/test/test_ostree_post_copy.py
new file mode 100644
index 00000000..2c349d02
--- /dev/null
+++ b/stages/test/test_ostree_post_copy.py
@@ -0,0 +1,61 @@
+#!/usr/bin/python3
+
+import os.path
+from unittest.mock import call, patch
+
+import pytest
+
+import osbuild.meta
+from osbuild.testutil.imports import import_module_from_path
+
+
+def schema_validate_stage_ostree_post_copy(test_data):
+    name = "org.osbuild.ostree.post-copy"
+    version = "2"
+    root = os.path.join(os.path.dirname(__file__), "../..")
+    mod_info = osbuild.meta.ModuleInfo.load(root, "Stage", name)
+    schema = osbuild.meta.Schema(mod_info.get_schema(version=version), name)
+    test_input = {
+        "type": "org.osbuild.ostree.post-copy",
+        "options": {
+            "sysroot": "/some/sysroot",
+        },
+    }
+    test_input.update(test_data)
+    return schema.validate(test_input)
+
+
+@patch("osbuild.util.ostree.cli")
+def test_ostree_post_copy_smoke(mock_ostree_cli):
+    stage_path = os.path.join(os.path.dirname(__file__), "../org.osbuild.ostree.post-copy")
+    stage = import_module_from_path("stage", stage_path)
+
+    paths = {
+        "mounts": "/run/osbuild/mounts",
+    }
+    options = {
+        "sysroot": "/some/sysroot",
+    }
+    stage.main(paths, options)
+
+    assert mock_ostree_cli.call_args_list == [
+        call("admin", "post-copy", sysroot="/run/osbuild/mounts/some/sysroot")]
+
+
+@pytest.mark.parametrize(
+    "test_data,expected_err",
+    [
+        # devices is not used directly in the stage but it's required
+        # because it's used as input for "mounts", see
+        # https://github.com/osbuild/osbuild/pull/1343/files#r1402161208
+        ({"devices": "must-be-object"}, " is not of type 'object'"),
+        ({"mounts": "must-be-array"}, " is not of type 'array'"),
+    ],
+)
+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]