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.
This commit is contained in:
Michael Vogt 2023-11-14 12:29:54 +01:00 committed by Achilleas Koutsou
parent e191dc1d0d
commit 0a2e0bb3d2
2 changed files with 137 additions and 0 deletions

55
stages/org.osbuild.machine-id Executable file
View file

@ -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)

View file

@ -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"})