diff --git a/mkosi/__init__.py b/mkosi/__init__.py index fe8453898..768960a75 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -50,6 +50,7 @@ from mkosi.config import ( PACKAGE_GLOBS, Args, + ArtifactOutput, Bootloader, Cacheonly, Compression, @@ -2100,8 +2101,11 @@ def make_uki( output, ) - extract_pe_section(context, output, ".linux", context.staging / context.config.output_split_kernel) - extract_pe_section(context, output, ".initrd", context.staging / context.config.output_split_initrd) + if ArtifactOutput.kernel in context.config.split_artifacts: + extract_pe_section(context, output, ".linux", context.staging / context.config.output_split_kernel) + + if ArtifactOutput.initrd in context.config.split_artifacts: + extract_pe_section(context, output, ".initrd", context.staging / context.config.output_split_initrd) def compressor_command(context: Context, compression: Compression) -> list[PathString]: @@ -2178,6 +2182,9 @@ def get_uki(context: Context) -> Optional[Path]: def copy_uki(context: Context) -> None: + if ArtifactOutput.uki not in context.config.split_artifacts: + return + if (context.staging / context.config.output_split_uki).exists(): return @@ -2186,6 +2193,9 @@ def copy_uki(context: Context) -> None: def copy_vmlinuz(context: Context) -> None: + if ArtifactOutput.kernel not in context.config.split_artifacts: + return + if (context.staging / context.config.output_split_kernel).exists(): return @@ -2201,6 +2211,9 @@ def copy_vmlinuz(context: Context) -> None: def copy_initrd(context: Context) -> None: + if ArtifactOutput.initrd not in context.config.split_artifacts: + return + if not want_initrd(context): return @@ -3378,7 +3391,7 @@ def make_extension_image(context: Context, output: Path) -> None: ] # fmt: skip if context.config.sector_size: cmdline += ["--sector-size", str(context.config.sector_size)] - if context.config.split_artifacts: + if ArtifactOutput.partitions in context.config.split_artifacts: cmdline += ["--split=yes"] with complete_step(f"Building {context.config.output_format} extension image"): @@ -3400,7 +3413,7 @@ def make_extension_image(context: Context, output: Path) -> None: logging.debug(json.dumps(j, indent=4)) - if context.config.split_artifacts: + if ArtifactOutput.partitions in context.config.split_artifacts: for p in (Partition.from_dict(d) for d in j): if p.split_path: maybe_compress(context, context.config.compress_output, p.split_path) @@ -3645,7 +3658,7 @@ def build_image(context: Context) -> None: partitions = make_disk(context, msg="Formatting ESP/XBOOTLDR partitions") grub_bios_setup(context, partitions) - if context.config.split_artifacts: + if ArtifactOutput.partitions in context.config.split_artifacts: make_disk(context, split=True, msg="Extracting partitions") copy_nspawn_settings(context) diff --git a/mkosi/config.py b/mkosi/config.py index 0bb255685..062308a08 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -499,7 +499,23 @@ def native(cls) -> "Architecture": return cls.from_uname(platform.machine()) -def parse_boolean(s: str) -> bool: +class ArtifactOutput(enum.IntFlag): + uki = 1 + kernel = 2 + initrd = 4 + partitions = 8 + + # Set a high bit to prevent these from showing up when only the previous values are set + no = 0b10000000000000000000000000000000 | uki | kernel | initrd + yes = 0b10000000000000000000000000000000 | uki | kernel | initrd | partitions + + # For some reason CI attempts to use the __contains__ from "int" when done on this type + # so just redefined the __contains__ to make it unambiguous + def __contains__(self, other: "ArtifactOutput") -> bool: + return (self.value & other.value) == other.value + + +def try_parse_boolean(s: str) -> Optional[bool]: "Parse 1/true/yes/y/t/on as true and 0/false/no/n/f/off/None as false" s_l = s.lower() @@ -509,7 +525,16 @@ def parse_boolean(s: str) -> bool: if s_l in {"0", "false", "no", "n", "f", "off", "never"}: return False - die(f"Invalid boolean literal: {s!r}") + return None + + +def parse_boolean(s: str) -> bool: + value = try_parse_boolean(s) + + if value is None: + die(f"Invalid boolean literal: {s!r}") + + return value def parse_path( @@ -1291,6 +1316,25 @@ def config_parse_key_source(value: Optional[str], old: Optional[KeySource]) -> O return KeySource(type=type, source=source) +def config_parse_artifact_output( + value: Optional[str], old: Optional[ArtifactOutput] +) -> Optional[ArtifactOutput]: + if not value: + return None + + # Keep for backwards compatibility + boolean_value = try_parse_boolean(value) + if boolean_value is not None: + return ArtifactOutput.yes if boolean_value else ArtifactOutput.no + + try: + new = ArtifactOutput[value] + except KeyError: + die(f"'{value}' is not a valid {ArtifactOutput.__name__}") + + return old | new if old else new + + class SettingScope(StrEnum): # Not passed down to subimages local = enum.auto() @@ -1596,7 +1640,7 @@ class Config: output_mode: Optional[int] image_id: Optional[str] image_version: Optional[str] - split_artifacts: bool + split_artifacts: ArtifactOutput repart_dirs: list[Path] sysupdate_dir: Optional[Path] sector_size: Optional[int] @@ -2292,11 +2336,11 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple ), ConfigSetting( dest="split_artifacts", - metavar="BOOL", nargs="?", section="Output", - parse=config_parse_boolean, - help="Generate split partitions", + parse=config_parse_artifact_output, + default=ArtifactOutput.no, + help="Split artifacts out of the final image", ), ConfigSetting( dest="repart_dirs", @@ -4429,6 +4473,10 @@ def line_join_list(array: Iterable[object]) -> str: return "\n ".join(str(item) for item in array) if array else "none" +def int_flag_names(flag: enum.IntFlag) -> str: + return none_to_none(flag.name).replace("|", "\n ") + + def format_bytes(num_bytes: int) -> str: if num_bytes >= 1024**3: return f"{num_bytes/1024**3 :0.1f}G" @@ -4508,7 +4556,7 @@ def summary(config: Config) -> str: Output Mode: {format_octal_or_default(config.output_mode)} Image ID: {config.image_id} Image Version: {config.image_version} - Split Artifacts: {yes_no(config.split_artifacts)} + Split Artifacts: {int_flag_names(config.split_artifacts)} Repart Directories: {line_join_list(config.repart_dirs)} Sector Size: {none_to_default(config.sector_size)} Overlay: {yes_no(config.overlay)} diff --git a/mkosi/resources/man/mkosi.1.md b/mkosi/resources/man/mkosi.1.md index 0d2c29f98..aeb27b9ba 100644 --- a/mkosi/resources/man/mkosi.1.md +++ b/mkosi/resources/man/mkosi.1.md @@ -596,13 +596,20 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, invoked. The image ID is automatically added to `/usr/lib/os-release`. `SplitArtifacts=`, `--split-artifacts` -: If specified and building a disk image, pass `--split=yes` to systemd-repart - to have it write out split partition files for each configured partition. - Read the [man](https://www.freedesktop.org/software/systemd/man/systemd-repart.html#--split=BOOL) +: The artifact types to split out of the final image. A comma-delimited + list consisting of `uki`, `kernel`, `initrd` and `partitions`. When + building a bootable image `kernel` and `initrd` correspond to their + artifact found in the image (or in the UKI), while `uki` copies out the + entire UKI. + + When building a disk image and `partitions` is specified, + pass `--split=yes` to systemd-repart to have it write out split partition + files for each configured partition. Read the + [man](https://www.freedesktop.org/software/systemd/man/systemd-repart.html#--split=BOOL) page for more information. This is useful in A/B update scenarios where an existing disk image shall be augmented with a new version of a root or `/usr` partition along with its Verity partition and unified - kernel. + kernel. By default `uki`, `kernel` and `initrd` are split out. `RepartDirectories=`, `--repart-dir=` : Paths to directories containing systemd-repart partition definition @@ -2229,7 +2236,7 @@ current working directory. The following scripts are supported: * If **`mkosi.clean`** (`CleanScripts=`) exists, it is executed right after the outputs of a previous build have been cleaned up. A clean script can clean up any outputs that mkosi does not know about (e.g. - artifacts from `SplitArtifacts=yes` or RPMs built in a build script). + artifacts from `SplitArtifacts=partitions` or RPMs built in a build script). Note that this script does not use the tools tree even if one is configured. * If **`mkosi.version`** exists and is executable, it is run during diff --git a/mkosi/sysupdate.py b/mkosi/sysupdate.py index 5cb216817..ea18b149e 100644 --- a/mkosi/sysupdate.py +++ b/mkosi/sysupdate.py @@ -4,15 +4,15 @@ import sys from pathlib import Path -from mkosi.config import Args, Config +from mkosi.config import Args, ArtifactOutput, Config from mkosi.log import die from mkosi.run import run from mkosi.types import PathString def run_sysupdate(args: Args, config: Config) -> None: - if not config.split_artifacts: - die("SplitArtifacts= must be enabled to be able to use mkosi sysupdate") + if ArtifactOutput.partitions not in config.split_artifacts: + die("SplitArtifacts=partitions must be set to be able to use mkosi sysupdate") if not config.sysupdate_dir: die( diff --git a/tests/test_config.py b/tests/test_config.py index 4cca01157..bbd79936e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ from mkosi import expand_kernel_specifiers from mkosi.config import ( Architecture, + ArtifactOutput, Compression, Config, ConfigFeature, @@ -235,19 +236,19 @@ def test_parse_config(tmp_path: Path) -> None: with chdir(d): _, [config] = parse_config() assert config.bootable == ConfigFeature.auto - assert config.split_artifacts is False + assert config.split_artifacts == ArtifactOutput.no # Passing the directory should include both the main config file and the dropin. _, [config] = parse_config(["--include", os.fspath(d / "abc")] * 2) assert config.bootable == ConfigFeature.enabled - assert config.split_artifacts is True + assert config.split_artifacts == ArtifactOutput.yes # The same extra config should not be parsed more than once. assert config.build_packages == ["abc"] # Passing the main config file should not include the dropin. _, [config] = parse_config(["--include", os.fspath(d / "abc/mkosi.conf")]) assert config.bootable == ConfigFeature.enabled - assert config.split_artifacts is False + assert config.split_artifacts == ArtifactOutput.no (d / "mkosi.images").mkdir() @@ -1277,3 +1278,51 @@ def test_mkosi_version_executable(tmp_path: Path) -> None: with chdir(d): _, [config] = parse_config() assert config.image_version == "1.2.3" + + +def test_split_artifacts(tmp_path: Path) -> None: + d = tmp_path + + (d / "mkosi.conf").write_text( + """ + [Output] + SplitArtifacts=uki + """ + ) + + with chdir(d): + _, [config] = parse_config() + assert config.split_artifacts == ArtifactOutput.uki + + (d / "mkosi.conf").write_text( + """ + [Output] + SplitArtifacts=uki + SplitArtifacts=kernel + SplitArtifacts=initrd + """ + ) + + with chdir(d): + _, [config] = parse_config() + assert config.split_artifacts == ArtifactOutput.uki | ArtifactOutput.kernel | ArtifactOutput.initrd + + +def test_split_artifacts_compat(tmp_path: Path) -> None: + d = tmp_path + + with chdir(d): + _, [config] = parse_config() + assert config.split_artifacts == ArtifactOutput.no + + (d / "mkosi.conf").write_text( + """ + [Output] + SplitArtifacts=yes + """ + ) + + with chdir(d): + "mkosi.conf" + _, [config] = parse_config() + assert config.split_artifacts == ArtifactOutput.yes diff --git a/tests/test_json.py b/tests/test_json.py index 3b64356d0..63ad3c411 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -11,6 +11,7 @@ from mkosi.config import ( Architecture, Args, + ArtifactOutput, BiosBootloader, Bootloader, Cacheonly, @@ -333,7 +334,7 @@ def test_config() -> None: } ], "SourceDateEpoch": 12345, - "SplitArtifacts": true, + "SplitArtifacts": 3, "Ssh": false, "SshCertificate": "/path/to/cert", "SshKey": null, @@ -533,7 +534,7 @@ def test_config() -> None: sign_expected_pcr_certificate=Path("/my/cert"), skeleton_trees=[ConfigTree(Path("/foo/bar"), Path("/")), ConfigTree(Path("/bar/baz"), Path("/qux"))], source_date_epoch=12345, - split_artifacts=True, + split_artifacts=ArtifactOutput.uki | ArtifactOutput.kernel, ssh=False, ssh_certificate=Path("/path/to/cert"), ssh_key=None,