From 0a2e0bb3d226ac7391114b5b5e65edac09f0a3d5 Mon Sep 17 00:00:00 2001 From: Michael Vogt Date: Tue, 14 Nov 2023 12:29:54 +0100 Subject: [PATCH] stages: add org.osbuild.machine-id stage This is a variation of PR https://github.com/osbuild/osbuild/pull/960 that put the machine-id handling into it's own stage and adds explicit handling what should happen with it. For machine-id(5) we essentially want the following three states implemented: 1. `first-boot: yes` will ensure that /etc/machine-id is in the "uninitialized" state. This means on boot the systemd `ConditionFirstBoot` is triggered and a new id in `/etc/machine-id` is created. This will work for systemd v247+. 2. `first-boot: no` will ensure that /etc/machine-id exists but is empty. This will trigger the creation of a new machine-id but will *not* trigger `ConditionFirstBoot`. 3. `first-boot: preserve` will just keep the existing machine-id. Note that it will error if there is no /etc/machine-id Note that the `org.osbuild.rpm` will also create a `{tree}/etc/machine-id` while it runs to ensure that postinst scripts will not fail that rely on this file. This is an implementation detail but unfortunately the rpm stage will leave an empty machine-id file if it was missing. So we cannot just remove /etc/machine-id because any following rpm stage would re-create it again (and we cannot change that without breaking backward compatiblity). Thanks to the special semantic that a missing /etc/machine-id and an /etc/machine-id with the `uninitialized` string are equivalent we don't care. To support systemd versions below v247 we could offer an option to remove /etc/machine-id. But the downside of this is that it would only work if the org.osbuild.machine-id stage is after the rpm stage. See also the discussion in PR#960. Thanks to Tom, Christian for the PR and the background. --- stages/org.osbuild.machine-id | 55 +++++++++++++++++++++++ stages/test/test_machine-id.py | 82 ++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100755 stages/org.osbuild.machine-id create mode 100644 stages/test/test_machine-id.py 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) 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"})