Repository: osbuild/osbuild
Pull request #1452 (2023-11-15 18:05:37)

stages(machine-id): add a new "machine-id"  stage
This is a draft for a new machien-id stage. it would allow us to create image that are in the sytemd "ConditionFirstBoot" state.

Draft for now to ensure it follows the vision/ideas of Tom and the team.

See also https://github.com/osbuild/osbuild/pull/960 for more context (and the commit messages here).

teg: Makes sense to me! The question of whether or not to enable FirstBoot mode, and when is correctly deferred to `osbuild/images`, so I see no reason not to land this.
mvo5: I had to remove the integration test for the feature from this commit because I need to figure out how to run `systemd-nspawn` inside our test container first :-/ I put it into https://github.com/osbuild/osbuild/compare/main...mvo5:stages/machine-id-integration-test?expand=1 for now and will get back to it. But this should be unblocked now.

Patch:
diff --git a/stages/org.osbuild.machine-id b/stages/org.osbuild.machine-id
new file mode 100755
index 00000000..3e04cce5
--- /dev/null
+++ b/stages/org.osbuild.machine-id
@@ -0,0 +1,55 @@
+#!/usr/bin/python3
+"""
+Deal with /etc/machine-id
+
+Explicitly define the state to /etc/machine-id. The possible values for
+first-boot are:
+
+- yes: This sets the machine-id to "uninitialized" and this will trigger
+       ContidionFirstBoot in systemd
+- no: This creates an empty machine-id. It will trigger the generation
+      of a new machine-id but *not* the ConditionFirstBoot
+- preserve: Leave the existing machine-id in place. Not having a machine-id
+            with that set is an error.
+"""
+
+import pathlib
+import sys
+
+import osbuild.api
+
+SCHEMA = """
+"additionalProperties": false,
+"required": ["first-boot"],
+"properties": {
+  "first-boot": {
+    "enum": ["yes", "no", "preserve"],
+    "description": "Set the first boot behavior of the /etc/machine-id file in the tree"
+  }
+}
+"""
+
+
+def main(tree, options):
+    mode = options["first-boot"]
+
+    machine_id_file = pathlib.Path(tree) / "etc/machine-id"
+    if mode == "yes":
+        # available since systemd v247, systemd PR#16939
+        machine_id_file.write_bytes(b"uninitialized\n")
+    elif mode == "no":
+        with machine_id_file.open("wb") as fp:
+            fp.truncate(0)
+    elif mode == "preserve":
+        if not machine_id_file.is_file():
+            print(f"{tree}/etc/machine-id cannot be preserved, it does not exist")
+            return 1
+    else:
+        raise ValueError(f"unexpected machine-id mode '{mode}'")
+    return 0
+
+
+if __name__ == '__main__':
+    args = osbuild.api.arguments()
+    r = main(args["tree"], args["options"])
+    sys.exit(r)


Test patch:
diff --git a/stages/test/test_machine-id.py b/stages/test/test_machine-id.py
new file mode 100644
index 00000000..efe98a75
--- /dev/null
+++ b/stages/test/test_machine-id.py
@@ -0,0 +1,82 @@
+#!/usr/bin/python3
+
+import os
+import pathlib
+import unittest.mock
+
+import pytest
+
+import osbuild.meta
+from osbuild.testutil.imports import import_module_from_path
+
+
+def stage(stage_name):
+    test_dir = pathlib.Path(__file__).parent
+    stage_path = pathlib.Path(test_dir) / f"../org.osbuild.{stage_name}"
+    return import_module_from_path("stage", os.fspath(stage_path))
+
+
+@pytest.fixture(name="machine_id_path")
+def machine_id_path_fixture(tmp_path):
+    machine_id_path = tmp_path / "etc/machine-id"
+    machine_id_path.parent.mkdir()
+    return machine_id_path
+
+
+@pytest.mark.parametrize("already_has_etc_machine_id", [True, False])
+def test_machine_id_first_boot_yes(tmp_path, machine_id_path, already_has_etc_machine_id):
+    if already_has_etc_machine_id:
+        machine_id_path.touch()
+
+    stage("machine-id").main(tmp_path, {"first-boot": "yes"})
+    assert machine_id_path.read_bytes() == b"uninitialized\n"
+
+
+@pytest.mark.parametrize("already_has_etc_machine_id", [True, False])
+def test_machine_id_first_boot_no(tmp_path, machine_id_path, already_has_etc_machine_id):
+    if already_has_etc_machine_id:
+        machine_id_path.write_bytes(b"\x01\x02\x03")
+
+    stage("machine-id").main(tmp_path, {"first-boot": "no"})
+    assert machine_id_path.stat().st_size == 0
+
+
+@pytest.mark.parametrize("already_has_etc_machine_id", [True, False])
+@unittest.mock.patch("builtins.print")
+def test_machine_id_first_boot_preserve(mock_print, tmp_path, machine_id_path, already_has_etc_machine_id):
+    if already_has_etc_machine_id:
+        machine_id_path.write_bytes(b"\x01\x02\x03")
+
+    ret = stage("machine-id").main(tmp_path, {"first-boot": "preserve"})
+    if already_has_etc_machine_id:
+        assert os.stat(machine_id_path).st_size == 3
+    else:
+        assert ret == 1
+        mock_print.assert_called_with(f"{tmp_path}/etc/machine-id cannot be preserved, it does not exist")
+
+
+@pytest.mark.parametrize("test_data,expected_err", [
+    ({"first-boot": "invalid-option"}, "'invalid-option' is not one of "),
+])
+def test_machine_id_schema_validation(test_data, expected_err):
+    name = "org.osbuild.machine-id"
+    root = pathlib.Path(__file__).parents[2]
+    mod_info = osbuild.meta.ModuleInfo.load(root, "Stage", name)
+    schema = osbuild.meta.Schema(mod_info.get_schema(), name)
+
+    test_input = {
+        "name": "org.osbuild.machine-id",
+        "options": {},
+    }
+    test_input["options"].update(test_data)
+    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]
+
+
+def test_machine_id_first_boot_unknown(tmp_path):
+    with pytest.raises(ValueError, match=r"unexpected machine-id mode 'invalid'"):
+        stage("machine-id").main(tmp_path, {"first-boot": "invalid"})