From a0efc1b5a4894fd18489801b620e0c99442d5f74 Mon Sep 17 00:00:00 2001 From: Daan De Meyer Date: Sun, 6 Oct 2024 15:30:57 +0200 Subject: [PATCH] Define our own config for PE addons and UKI profiles ukify's config parser uses python's configparser module and as such suffers from all its issues just like we used to in mkosi. Having ukify parse the config file also means that we have to make sure any paths configured in the profile are available in the sandbox. Instead, let's define our own configs for the PE addons and UKI profiles so we get to take advantage of our own config file parser and have full knowledge of all the configured settings so we can mount extra stuff into the sandbox if needed. It also gets rid of the hack where we parse ukify's config file to figure out the command line. --- mkosi/__init__.py | 50 ++++++++------ mkosi/config.py | 115 +++++++++++++++++++++++++++++---- mkosi/resources/man/mkosi.1.md | 45 ++++++++++++- tests/test_json.py | 22 +++++-- 4 files changed, 191 insertions(+), 41 deletions(-) diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 58edc7681..f88ca9c05 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -70,7 +70,6 @@ format_bytes, parse_boolean, parse_config, - parse_ini, summary, systemd_tool_version, want_selinux_relabel, @@ -1487,7 +1486,7 @@ def run_ukify( stub: Path, output: Path, *, - cmdline: str = "", + cmdline: Sequence[str] = (), arguments: Sequence[PathString] = (), options: Sequence[PathString] = (), sign: bool = True, @@ -1499,11 +1498,9 @@ def run_ukify( if not (arch := context.config.architecture.to_efi()): die(f"Architecture {context.config.architecture} does not support UEFI") - cmdline = cmdline.strip() - # Older versions of systemd-stub expect the cmdline section to be null terminated. We can't # embed NUL terminators in argv so let's communicate the cmdline via a file instead. - (context.workspace / "cmdline").write_text(f"{cmdline}\x00") + (context.workspace / "cmdline").write_text(f"{' '.join(cmdline)}\x00") cmd = [ python_binary(context.config, binary=ukify), @@ -1645,7 +1642,7 @@ def build_uki( options += ["--ro-bind", initrd, workdir(initrd)] with complete_step(f"Generating unified kernel image for kernel version {kver}"): - run_ukify(context, stub, output, cmdline=" ".join(cmdline), arguments=arguments, options=options) + run_ukify(context, stub, output, cmdline=cmdline, arguments=arguments, options=options) def systemd_stub_binary(context: Context) -> Path: @@ -1977,15 +1974,14 @@ def install_pe_addons(context: Context) -> None: addon_dir.mkdir(parents=True, exist_ok=True) for addon in context.config.pe_addons: - output = addon_dir / addon.with_suffix(".addon.efi").name + output = addon_dir / f"{addon.output}.addon.efi" with complete_step(f"Generating PE addon /{output.relative_to(context.root)}"): run_ukify( context, stub, output, - arguments=["--config", workdir(addon)], - options=["--ro-bind", addon, workdir(addon)], + cmdline=addon.cmdline, ) @@ -2008,25 +2004,23 @@ def build_uki_profiles(context: Context, cmdline: Sequence[str]) -> list[Path]: profiles = [] for profile in context.config.unified_kernel_image_profiles: - output = context.workspace / "uki-profiles" / profile.with_suffix(".efi").name - - # We want to append the cmdline from the ukify config file to the base kernel command line so parse - # it from the ukify config file and append it to our own kernel command line. + id = profile.profile["ID"] + output = context.workspace / f"uki-profiles/{id}.efi" - profile_cmdline = "" + profile_section = context.workspace / f"uki-profiles/{id}.profile" - for section, k, v in parse_ini(profile): - if section == "UKI" and k == "Cmdline": - profile_cmdline = v.replace("\n", " ") + with profile_section.open("w") as f: + for k, v in profile.profile.items(): + f.write(f"{k}={v}\n") - with complete_step(f"Generating UKI profile '{profile.stem}'"): + with complete_step(f"Generating UKI profile '{id}'"): run_ukify( context, stub, output, - cmdline=f"{' '.join(cmdline)} {profile_cmdline}", - arguments=["--config", workdir(profile)], - options=["--ro-bind", profile, workdir(profile)], + cmdline=[*cmdline, *profile.cmdline], + arguments=["--profile", f"@{profile_section}"], + options=["--ro-bind", profile_section, profile_section], sign=False, ) @@ -2410,6 +2404,20 @@ def check_inputs(config: Config) -> None: if config.secure_boot_key_source != config.sign_expected_pcr_key_source: die("Secure boot key source and expected PCR signatures key source have to be the same") + for addon in config.pe_addons: + if not addon.output: + die( + "PE addon configured without output filename", + hint="Use Output= to configure the output filename", + ) + + for profile in config.unified_kernel_image_profiles: + if "ID" not in profile.profile: + die( + "UKI Profile is missing ID key in its .profile section", + hint="Use Profile= to configure the profile ID", + ) + def check_tool(config: Config, *tools: PathString, reason: str, hint: Optional[str] = None) -> Path: tool = config.find_binary(*tools) diff --git a/mkosi/config.py b/mkosi/config.py index c7bed5374..aa648f9b9 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -887,8 +887,8 @@ def config_match_enum(match: str, value: StrEnum) -> bool: def config_make_list_parser( - delimiter: str, *, + delimiter: Optional[str] = None, parse: Callable[[str], Any] = str, unescape: bool = False, reset: bool = True, @@ -904,13 +904,15 @@ def config_parse_list(value: Optional[str], old: Optional[list[Any]]) -> Optiona if unescape: lex = shlex.shlex(value, posix=True) lex.whitespace_split = True - lex.whitespace = f"\n{delimiter}" + lex.whitespace = f"\n{delimiter or ''}" lex.commenters = "" values = list(lex) if reset and not values: return None else: - values = value.replace(delimiter, "\n").split("\n") + if delimiter: + value = value.replace(delimiter, "\n") + values = value.split("\n") if reset and len(values) == 1 and values[0] == "": return None @@ -947,8 +949,8 @@ def config_match_version(match: str, value: str) -> bool: def config_make_dict_parser( - delimiter: str, *, + delimiter: Optional[str] = None, parse: Callable[[str], tuple[str, Any]], unescape: bool = False, allow_paths: bool = False, @@ -985,13 +987,15 @@ def config_parse_dict(value: Optional[str], old: Optional[dict[str, Any]]) -> Op if unescape: lex = shlex.shlex(value, posix=True) lex.whitespace_split = True - lex.whitespace = f"\n{delimiter}" + lex.whitespace = f"\n{delimiter or ''}" lex.commenters = "" values = list(lex) if reset and not values: return None else: - values = value.replace(delimiter, "\n").split("\n") + if delimiter: + value = value.replace(delimiter, "\n") + values = value.split("\n") if reset and len(values) == 1 and values[0] == "": return None @@ -1007,7 +1011,7 @@ def parse_environment(value: str) -> tuple[str, str]: return (key, value) -def parse_credential(value: str) -> tuple[str, str]: +def parse_key_value(value: str) -> tuple[str, str]: key, _, value = value.partition("=") key, value = key.strip(), value.strip() return (key, value) @@ -1512,6 +1516,45 @@ def key_transformer(k: str) -> str: ) +@dataclasses.dataclass(frozen=True) +class PEAddon: + output: str + cmdline: list[str] + + +@dataclasses.dataclass(frozen=True) +class UKIProfile: + profile: dict[str, str] + cmdline: list[str] + + +def make_simple_config_parser(settings: Sequence[ConfigSetting], type: type[Any]) -> Callable[[str], Any]: + lookup = {s.name: s for s in settings} + + def parse_simple_config(value: str) -> Any: + path = parse_path(value) + config = argparse.Namespace() + + for section, name, value in parse_ini(path, only_sections=[s.section for s in settings]): + if not name and not value: + continue + + if not (s := lookup.get(name)): + die(f"Unknown setting {name}") + + if section != s.section: + logging.warning(f"Setting {name} should be configured in [{s.section}], not [{section}].") + + if name != s.name: + logging.warning(f"Setting {name} is deprecated, please use {s.name} instead.") + + setattr(config, s.dest, s.parse(value, getattr(config, s.dest, None))) + + return type(**{k: v for k, v in vars(config).items() if k in inspect.signature(type).parameters}) + + return parse_simple_config + + @dataclasses.dataclass(frozen=True) class Config: """Type-hinted storage for command line arguments. @@ -1584,7 +1627,7 @@ class Config: shim_bootloader: ShimBootloader unified_kernel_images: ConfigFeature unified_kernel_image_format: str - unified_kernel_image_profiles: list[Path] + unified_kernel_image_profiles: list[UKIProfile] initrds: list[Path] initrd_packages: list[str] initrd_volatile_packages: list[str] @@ -1593,7 +1636,7 @@ class Config: kernel_modules_include: list[str] kernel_modules_exclude: list[str] kernel_modules_include_host: bool - pe_addons: list[Path] + pe_addons: list[PEAddon] kernel_modules_initrd: bool kernel_modules_initrd_include: list[str] @@ -1981,6 +2024,35 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple yield section, "", "" +PE_ADDON_SETTINGS = ( + ConfigSetting( + dest="output", + section="Addon", + parse=config_make_filename_parser("Output= requires a filename with no path components."), + default="", + ), + ConfigSetting( + dest="cmdline", + section="Addon", + parse=config_make_list_parser(delimiter=" "), + ), +) + + +UKI_PROFILE_SETTINGS = ( + ConfigSetting( + dest="profile", + section="Profile", + parse=config_make_dict_parser(parse=parse_key_value), + ), + ConfigSetting( + dest="cmdline", + section="Profile", + parse=config_make_list_parser(delimiter=" "), + ), +) + + SETTINGS = ( # Include section ConfigSetting( @@ -2501,7 +2573,10 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple long="--uki-profile", metavar="PATH", section="Content", - parse=config_make_list_parser(delimiter=",", parse=make_path_parser()), + parse=config_make_list_parser( + delimiter=",", + parse=make_simple_config_parser(UKI_PROFILE_SETTINGS, UKIProfile), + ), recursive_paths=("mkosi.uki-profiles/",), help="Configuration files to generate UKI profiles", ), @@ -2571,7 +2646,10 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple long="--pe-addon", metavar="PATH", section="Content", - parse=config_make_list_parser(delimiter=",", parse=make_path_parser()), + parse=config_make_list_parser( + delimiter=",", + parse=make_simple_config_parser(PE_ADDON_SETTINGS, PEAddon), + ), recursive_paths=("mkosi.pe-addons/",), help="Configuration files to generate PE addons", ), @@ -3153,9 +3231,7 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple long="--credential", metavar="NAME=VALUE", section="Host", - parse=config_make_dict_parser( - delimiter=" ", parse=parse_credential, allow_paths=True, unescape=True - ), + parse=config_make_dict_parser(delimiter=" ", parse=parse_key_value, allow_paths=True, unescape=True), help="Pass a systemd credential to systemd-nspawn or qemu", paths=("mkosi.credentials",), ), @@ -4657,6 +4733,15 @@ def key_source_transformer(keysource: dict[str, Any], fieldtype: type[KeySource] assert "Type" in keysource return KeySource(type=KeySourceType(keysource["Type"]), source=keysource.get("Source", "")) + def pe_addon_transformer(addons: list[dict[str, Any]], fieldtype: type[PEAddon]) -> list[PEAddon]: + return [PEAddon(output=addon["Output"], cmdline=addon["Cmdline"]) for addon in addons] + + def uki_profile_transformer( + profiles: list[dict[str, Any]], + fieldtype: type[UKIProfile], + ) -> list[UKIProfile]: + return [UKIProfile(profile=profile["Profile"], cmdline=profile["Cmdline"]) for profile in profiles] + # The type of this should be # dict[ # type, @@ -4696,6 +4781,8 @@ def key_source_transformer(keysource: dict[str, Any], fieldtype: type[KeySource] Network: enum_transformer, KeySource: key_source_transformer, Vmm: enum_transformer, + list[PEAddon]: pe_addon_transformer, + list[UKIProfile]: uki_profile_transformer, } def json_transformer(key: str, val: Any) -> Any: diff --git a/mkosi/resources/man/mkosi.1.md b/mkosi/resources/man/mkosi.1.md index c873c83e7..88064d2e9 100644 --- a/mkosi/resources/man/mkosi.1.md +++ b/mkosi/resources/man/mkosi.1.md @@ -942,20 +942,26 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, `UnifiedKernelImageProfiles=`, `--uki-profile=` : Build additional UKI profiles. Takes a comma separated list of paths - to `ukify` config files. This option may be used multiple times in + to UKI profile config files. This option may be used multiple times in which case each config gets built into a corresponding UKI profile. Config files in the `mkosi.uki-profiles/` directory are automatically picked up. All configured UKI profiles are added as additional UKI profiles to each UKI built by mkosi. + See the documentation for the `Profile` section for information on + which settings can be configured in UKI profile config files. + `PeAddons=`, `--pe-addon` : Build additional PE addons. Takes a comma separated list of paths to - `ukify` config files. This option may be used multiple times in which case + PE addon config files. This option may be used multiple times in which case each config gets built into a corresponding addon. Each addon has the name of the config file, with the extension replaced with `.addon.efi`. Config files in the `mkosi.pe-addons/` directory are automatically picked up. + See the documentation for the `Addon` section for information on + which settings can be configured in PE addon config files. + `Initrds=`, `--initrd` : Use user-provided initrd(s). Takes a comma separated list of paths to initrd files. This option may be used multiple times in which case the initrd lists @@ -1932,6 +1938,41 @@ config file is read: each individual subimage as if they were "universal" settings. See the **Building multiple images** section for more information. +### [Addon] Section + +The `Addon` section can be used in UKI profile config files which are +passed to the `PEAddons=` setting. The following settings can be +specified in the `Addon` section: + +`Output=` +: The name the addon should have in the addons directory in the ESP. + The final name is the name specified here suffixed with + `.addon.efi`. + +`Cmdline=` +: The kernel command line arguments to store in the `.cmdline` section + of the addon. Takes a space delimited list of extra kernel command + line arguments. + +### [Profile] Section + +The `Profile` section can be used in UKI profile config files which are +passed to the `UnifiedKernelImageProfiles=` setting. The following +settings can be specified in the `Profile` section: + +`Profile=` +: The contents of the `.profile` section of the UKI profile. Takes a + list of key/value pairs separated by `=`. The `ID=` key must be + specified. See the UKI [specification](https://uapi-group.org/specifications/specs/unified_kernel_image/#multi-profile-ukis) + for a full list of possible keys. + +`Cmdline=` +: Extra kernel command line options for the UKI profile. Takes a space + delimited list of extra kernel command line arguments. Note that + the final `.cmdline` section will the combination of the base + `.cmdline` section and the extra kernel command line arguments + specified with this setting. + ## Specifiers The current value of various settings can be accessed when parsing diff --git a/tests/test_json.py b/tests/test_json.py index 21be8a404..317152e33 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -25,11 +25,13 @@ ManifestFormat, Network, OutputFormat, + PEAddon, QemuDrive, QemuFirmware, QemuVsockCID, SecureBootSignTool, ShimBootloader, + UKIProfile, Verb, Vmm, ) @@ -208,7 +210,12 @@ def test_config() -> None: ], "Passphrase": null, "PeAddons": [ - "/my-addon.conf" + { + "Cmdline": [ + "key=value" + ], + "Output": "abc" + } ], "PostInstallationScripts": [ "/bar/qux" @@ -351,7 +358,14 @@ def test_config() -> None: ], "UnifiedKernelImageFormat": "myuki", "UnifiedKernelImageProfiles": [ - "/profile" + { + "Cmdline": [ + "key=value" + ], + "Profile": { + "key": "value" + } + } ], "UnifiedKernelImages": "auto", "UnitProperties": [ @@ -454,7 +468,7 @@ def test_config() -> None: packages=[], pass_environment=["abc"], passphrase=None, - pe_addons=[Path("/my-addon.conf")], + pe_addons=[PEAddon(output="abc", cmdline=["key=value"])], postinst_scripts=[Path("/bar/qux")], postoutput_scripts=[Path("/foo/src")], prepare_scripts=[Path("/run/foo")], @@ -532,7 +546,7 @@ def test_config() -> None: tools_tree_release=None, tools_tree_repositories=["abc"], unified_kernel_image_format="myuki", - unified_kernel_image_profiles=[Path("/profile")], + unified_kernel_image_profiles=[UKIProfile(profile={"key": "value"}, cmdline=["key=value"])], unified_kernel_images=ConfigFeature.auto, unit_properties=["PROPERTY=VALUE"], use_subvolumes=ConfigFeature.auto,