Repository: osbuild/osbuild
Pull request #1705 (2024-04-05 08:36:44)

stages: move users/selinux to v2 and add devices/mounts support
When moving to `bootc install to-filesystem` we will need support
for mounting the deployed disk and writing to the deployment root
this requires that we teach the users and selinux stages to
have them available. This is a first step towards this.

It also adds tests to ensure the options can be passed.

The next PR will build on top of this and add a concept of an alternative "tree" dir so that users/selinux can be pointed to "mount://bootc-deploayment-root/" instead of the normal tree dir.


Patch:
diff --git a/stages/org.osbuild.selinux.meta.json b/stages/org.osbuild.selinux.meta.json
index 05dbf348..ea1bb3ef 100644
--- a/stages/org.osbuild.selinux.meta.json
+++ b/stages/org.osbuild.selinux.meta.json
@@ -17,35 +17,44 @@
   "capabilities": [
     "CAP_MAC_ADMIN"
   ],
-  "schema": {
-    "additionalProperties": false,
-    "required": [
-      "file_contexts"
-    ],
-    "properties": {
-      "file_contexts": {
-        "type": "string",
-        "description": "Path to the active SELinux policy's `file_contexts`"
-      },
-      "exclude_paths": {
-        "type": "array",
-        "description": "Paths to exclude when setting labels via file_contexts",
-        "items": {
-          "type": "string"
+  "schema_2": {
+    "options": {
+      "additionalProperties": false,
+      "required": [
+        "file_contexts"
+      ],
+      "properties": {
+        "file_contexts": {
+          "type": "string",
+          "description": "Path to the active SELinux policy's `file_contexts`"
+        },
+        "exclude_paths": {
+          "type": "array",
+          "description": "Paths to exclude when setting labels via file_contexts",
+          "items": {
+            "type": "string"
+          }
+        },
+        "labels": {
+          "type": "object",
+          "description": "Labels to set of the specified files or folders",
+          "items": {
+            "type": "object"
+          }
+        },
+        "force_autorelabel": {
+          "type": "boolean",
+          "description": "Do not use. Forces auto-relabelling on first boot.",
+          "default": false
         }
-      },
-      "labels": {
-        "type": "object",
-        "description": "Labels to set of the specified files or folders",
-        "items": {
-          "type": "object"
-        }
-      },
-      "force_autorelabel": {
-        "type": "boolean",
-        "description": "Do not use. Forces auto-relabelling on first boot.",
-        "default": false
       }
+    },
+    "devices": {
+      "type": "object",
+      "additionalProperties": true
+    },
+    "mounts": {
+      "type": "array"
     }
   }
 }
diff --git a/stages/org.osbuild.users.meta.json b/stages/org.osbuild.users.meta.json
index 0483ac3d..6c8668a6 100644
--- a/stages/org.osbuild.users.meta.json
+++ b/stages/org.osbuild.users.meta.json
@@ -9,67 +9,76 @@
     "inside a chroot to ensure that a home dir exists for the user, as `usermod`",
     "does not create it (it will move existing dirs though)."
   ],
-  "schema": {
-    "additionalProperties": false,
-    "properties": {
-      "users": {
-        "additionalProperties": false,
-        "type": "object",
-        "description": "Keys are usernames, values are objects giving user info.",
-        "patternProperties": {
-          "^[A-Za-z0-9_.][A-Za-z0-9_.-]{0,31}$": {
-            "type": "object",
-            "properties": {
-              "uid": {
-                "description": "User UID",
-                "type": "number"
-              },
-              "gid": {
-                "description": "User GID",
-                "type": "number"
-              },
-              "groups": {
-                "description": "Array of group names for this user",
-                "type": "array",
-                "items": {
+  "schema_2": {
+    "options": {
+      "additionalProperties": false,
+      "properties": {
+        "users": {
+          "additionalProperties": false,
+          "type": "object",
+          "description": "Keys are usernames, values are objects giving user info.",
+          "patternProperties": {
+            "^[A-Za-z0-9_.][A-Za-z0-9_.-]{0,31}$": {
+              "type": "object",
+              "properties": {
+                "uid": {
+                  "description": "User UID",
+                  "type": "number"
+                },
+                "gid": {
+                  "description": "User GID",
+                  "type": "number"
+                },
+                "groups": {
+                  "description": "Array of group names for this user",
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                "description": {
+                  "description": "User account description (or full name)",
                   "type": "string"
-                }
-              },
-              "description": {
-                "description": "User account description (or full name)",
-                "type": "string"
-              },
-              "home": {
-                "description": "Path to user's home directory",
-                "type": "string"
-              },
-              "shell": {
-                "description": "User's login shell",
-                "type": "string"
-              },
-              "password": {
-                "description": "User's encrypted password, as returned by crypt(3)",
-                "type": "string"
-              },
-              "key": {
-                "description": "SSH Public Key to add to ~/.ssh/authorized_keys",
-                "type": "string"
-              },
-              "keys": {
-                "description": "Array of SSH Public Keys to add to ~/.ssh/authorized_keys",
-                "type": "array",
-                "items": {
+                },
+                "home": {
+                  "description": "Path to user's home directory",
+                  "type": "string"
+                },
+                "shell": {
+                  "description": "User's login shell",
+                  "type": "string"
+                },
+                "password": {
+                  "description": "User's encrypted password, as returned by crypt(3)",
+                  "type": "string"
+                },
+                "key": {
+                  "description": "SSH Public Key to add to ~/.ssh/authorized_keys",
                   "type": "string"
+                },
+                "keys": {
+                  "description": "Array of SSH Public Keys to add to ~/.ssh/authorized_keys",
+                  "type": "array",
+                  "items": {
+                    "type": "string"
+                  }
+                },
+                "expiredate": {
+                  "description": "The date on which the user account will be disabled. This date is represented as a number of days since January 1st, 1970.",
+                  "type": "integer"
                 }
-              },
-              "expiredate": {
-                "description": "The date on which the user account will be disabled. This date is represented as a number of days since January 1st, 1970.",
-                "type": "integer"
               }
             }
           }
         }
       }
+    },
+    "devices": {
+      "type": "object",
+      "additionalProperties": true
+    },
+    "mounts": {
+      "type": "array"
     }
   }
 }


Test patch:
diff --git a/stages/test/conftest.py b/stages/test/conftest.py
index f2f9d5a7..d9a314e4 100644
--- a/stages/test/conftest.py
+++ b/stages/test/conftest.py
@@ -43,3 +43,39 @@ 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_selinux.py b/stages/test/test_selinux.py
index 7e0987eb..27eba4a5 100644
--- a/stages/test/test_selinux.py
+++ b/stages/test/test_selinux.py
@@ -12,7 +12,7 @@ STAGE_NAME = "org.osbuild.selinux"
 
 def get_test_input(test_data, implicit_file_contexts=True):
     test_input = {
-        "name": STAGE_NAME,
+        "type": STAGE_NAME,
         "options": {}
     }
     if implicit_file_contexts:
@@ -32,7 +32,6 @@ def get_test_input(test_data, implicit_file_contexts=True):
     ({"labels": "xxx"}, "'xxx' is not of type 'object'"),
     ({"force_autorelabel": "foo"}, "'foo' is not of type 'boolean'"),
 ])
-@pytest.mark.parametrize("stage_schema", ["1"], indirect=True)
 def test_schema_validation_selinux(stage_schema, test_data, expected_err):
     res = stage_schema.validate(get_test_input(test_data))
     if expected_err == "":
@@ -42,7 +41,6 @@ def test_schema_validation_selinux(stage_schema, test_data, expected_err):
         testutil.assert_jsonschema_error_contains(res, expected_err, expected_num_errs=1)
 
 
-@pytest.mark.parametrize("stage_schema", ["1"], indirect=True)
 def test_schema_validation_selinux_file_context_required(stage_schema):
     res = stage_schema.validate(get_test_input({}, implicit_file_contexts=False))
     assert res.valid is False
@@ -50,6 +48,13 @@ 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_users.py b/stages/test/test_users.py
index ce41a3d1..392449fb 100644
--- a/stages/test/test_users.py
+++ b/stages/test/test_users.py
@@ -4,10 +4,45 @@ from unittest.mock import patch
 
 import pytest
 
-from osbuild.testutil import make_fake_tree, mock_command
+from osbuild.testutil import (
+    assert_jsonschema_error_contains,
+    make_fake_tree,
+    mock_command,
+)
 
 STAGE_NAME = "org.osbuild.users"
 
+
+@pytest.mark.parametrize("test_data,expected_err", [
+    # bad
+    ({"users": {"!invalid-name": {}}}, "'!invalid-name' does not match any of the regex"),
+    ({"users": {"foo": {"home": 0}}}, "0 is not of type 'string'"),
+    # good
+    ({}, ""),
+    ({"users": {"foo": {}}}, ""),
+])
+def test_schema_validation(stage_schema, test_data, expected_err):
+    test_input = {
+        "type": STAGE_NAME,
+        "options": {},
+    }
+    test_input["options"].update(test_data)
+    res = stage_schema.validate(test_input)
+
+    if expected_err == "":
+        assert res.valid is True, f"err: {[e.as_dict() for e in res.errors]}"
+    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]}"
+
+
 TEST_CASES = [
     # user_opts,expected commandline args
     ({}, []),