diff --git a/mkosi/__init__.py b/mkosi/__init__.py index b8abba045..cfb9f995f 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -26,6 +26,7 @@ from contextlib import AbstractContextManager from pathlib import Path from typing import Any, Optional, Union, cast +from urllib.parse import urlparse from mkosi.archive import can_extract_tar, extract_tar, make_cpio, make_tar from mkosi.bootloader import ( @@ -343,12 +344,14 @@ def check_root_populated(context: Context) -> None: ) -def configure_os_release(context: Context) -> None: - """Write IMAGE_ID and IMAGE_VERSION to /usr/lib/os-release in the image.""" +def configure_os_release_and_appstream(context: Context) -> None: + """Write os-release and appstream metainfo in the image.""" if context.config.overlay or context.config.output_format.is_extension_image(): return + osrelease_content = {} + for candidate in ["usr/lib/os-release", "usr/lib/initrd-release", "etc/os-release"]: osrelease = context.root / candidate @@ -385,9 +388,69 @@ def configure_os_release(context: Context) -> None: newosrelease.rename(osrelease) + osrelease_content = read_env_file(osrelease) + if ArtifactOutput.os_release in context.config.split_artifacts: shutil.copy(osrelease, context.staging / context.config.output_split_os_release) + # https://www.freedesktop.org/software/appstream/docs/sect-Metadata-OS.html + osid = context.config.appstream_id or osrelease_content.get("ID", "linux") + name = context.config.appstream_name or osrelease_content.get("NAME") or context.config.image_id or osid + if context.config.appstream_description: + description = context.config.appstream_description + else: + description = f"{osrelease_content.get('PRETTY_NAME', 'Image')} built with mkosi" + icon = context.config.appstream_icon + home_url = context.config.appstream_url or osrelease_content.get("HOME_URL") + if home_url: + url = urlparse(home_url) + if not url.netloc: + home_url = None + if context.config.appstream_id: + id = context.config.appstream_id + elif home_url: + url = urlparse(home_url) + netloc = url.netloc.split(".") + netloc.reverse() + if "www" in netloc: + netloc.remove("www") + id = ".".join(netloc) + id += f".{osid}" + else: + id = osid + timestamp = ( + datetime.datetime.fromtimestamp(context.config.source_date_epoch, tz=datetime.timezone.utc) + if context.config.source_date_epoch is not None + else datetime.datetime.now(tz=datetime.timezone.utc) + ).isoformat() + + metainfo = '\n' + metainfo += '\n' + metainfo += f" {id}\n" + metainfo += f" {name}\n" + metainfo += f" {osid} image built with mkosi\n" + metainfo += f"

{description}

\n" + if home_url: + metainfo += f' {home_url}\n' + if icon: + metainfo += f' {icon}\n' + metainfo += " FSFAP\n" + metainfo += " \n" + metainfo += ( + f' \n' + ) + metainfo += " \n" + metainfo += " \n" + metainfo += " \n" + metainfo += ' \n' + metainfo += "
\n" + + metainto_out = context.root / f"usr/share/metainfo/{id}.metainfo.xml" + metainto_out.parent.mkdir(parents=True, exist_ok=True) + metainto_out.write_text(metainfo) + if ArtifactOutput.appstream_metainfo in context.config.split_artifacts: + (context.staging / context.config.output_split_appstream_metainfo).write_text(metainfo) + def configure_extension_release(context: Context) -> None: if context.config.output_format not in (OutputFormat.sysext, OutputFormat.confext): @@ -3921,7 +3984,7 @@ def build_image(context: Context) -> None: fixup_vmlinuz_location(context) configure_autologin(context) - configure_os_release(context) + configure_os_release_and_appstream(context) configure_extension_release(context) configure_initrd(context) configure_ssh(context) diff --git a/mkosi/config.py b/mkosi/config.py index 32c847cc5..c824cb6be 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -575,6 +575,7 @@ class ArtifactOutput(StrEnum): pcrs = enum.auto() roothash = enum.auto() os_release = enum.auto() + appstream_metainfo = enum.auto() @staticmethod def compat_no() -> list["ArtifactOutput"]: @@ -1986,6 +1987,13 @@ class Config: machine: Optional[str] forward_journal: Optional[Path] + appstream_id: Optional[str] + appstream_name: Optional[str] + appstream_summary: Optional[str] + appstream_description: Optional[str] + appstream_url: Optional[str] + appstream_icon: Optional[str] + vmm: Vmm console: ConsoleMode cpus: int @@ -2132,6 +2140,10 @@ def output_split_roothash(self) -> str: def output_split_os_release(self) -> str: return f"{self.output}.osrelease" + @property + def output_split_appstream_metainfo(self) -> str: + return f"{self.output}.metainfo.xml" + @property def output_nspawn_settings(self) -> str: return f"{self.output}.nspawn" @@ -2173,6 +2185,7 @@ def outputs(self) -> list[str]: self.output_split_pcrs, self.output_split_roothash, self.output_split_os_release, + self.output_split_appstream_metainfo, self.output_nspawn_settings, self.output_checksum, self.output_signature, @@ -3977,6 +3990,43 @@ def parse_ini(path: Path, only_sections: Collection[str] = ()) -> Iterator[tuple default=ConfigFeature.auto, help="Run systemd-storagetm as part of the serve verb", ), + ConfigSetting( + dest="appstream_id", + section="Content", + parse=config_parse_string, + help="'id' field for Appstream metainfo file", + ), + ConfigSetting( + dest="appstream_name", + section="Content", + parse=config_parse_string, + help="'name' field for Appstream metainfo file", + ), + ConfigSetting( + dest="appstream_summary", + section="Content", + parse=config_parse_string, + help="'summary' field for Appstream metainfo file", + ), + ConfigSetting( + dest="appstream_description", + section="Content", + parse=config_parse_string, + help="'description' field for Appstream metainfo file", + ), + ConfigSetting( + dest="appstream_url", + section="Content", + parse=config_parse_string, + help="'url' homepage field for Appstream metainfo file", + ), + ConfigSetting( + dest="appstream_icon", + section="Content", + parse=config_parse_string, + default="https://brand.systemd.io/assets/svg/systemd-logomark.svg", + help="'icon' URL field for Appstream metainfo file", + ), ] SETTINGS_LOOKUP_BY_NAME = {name: s for s in SETTINGS for name in [s.name, *s.compat_names]} SETTINGS_LOOKUP_BY_DEST = {s.dest: s for s in SETTINGS} @@ -5033,6 +5083,12 @@ def summary(config: Config) -> str: Make Initrd: {yes_no(config.make_initrd)} SSH: {yes_no(config.ssh)} SELinux Relabel: {config.selinux_relabel} + Appstream Metainfo ID: {none_to_none(config.appstream_id)} + Appstream Metainfo Name: {none_to_none(config.appstream_name)} + Appstream Metainfo Summary: {none_to_none(config.appstream_summary)} + Appstream Metainfo Description: {none_to_none(config.appstream_description)} + Appstream Metainfo URL: {none_to_none(config.appstream_url)} + Appstream Icon: {none_to_none(config.appstream_icon)} """ if config.output_format.is_extension_or_portable_image() or config.output_format in ( diff --git a/mkosi/resources/man/mkosi.1.md b/mkosi/resources/man/mkosi.1.md index ce8a75fc1..93ad00195 100644 --- a/mkosi/resources/man/mkosi.1.md +++ b/mkosi/resources/man/mkosi.1.md @@ -624,8 +624,8 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, `SplitArtifacts=`, `--split-artifacts=` : The artifact types to split out of the final image. A comma-delimited list consisting of `uki`, `kernel`, `initrd`, `os-release`, `prcs`, `partitions`, - `roothash` and `tar`. When building a bootable image `kernel` and `initrd` - correspond to their artifact found in the image (or in the UKI), + `appstream-metainfo`, `roothash` and `tar`. 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. If `pcrs` is specified, a JSON file containing the pre-calculated TPM2 digests is written out, according to the [UKI specification](https://uapi-group.org/specifications/specs/unified_kernel_image/#json-format-for-pcrsig), @@ -1156,6 +1156,10 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`, `mkosi.machine-id` exists in the local directory, the UUID to use is read from it. Otherwise, `uninitialized` will be written to `/etc/machine-id`. +`AppstreamId=`, `--appstream-id=`, `AppstreamName=`, `--appstream-name=`, `AppstreamSummary=`, `--appstream-summary=`, `AppstreamDescription=`, `--appstream-description=`, `AppstreamUrl=`, `--appstream-url=`, `AppstreamIcon=`, `--appstream-icon=` +: Specifies content for the [appstream metainfo](https://www.freedesktop.org/software/appstream/docs/sect-Metadata-OS.html) + file. If not provided, the content will be derived from the os-release file. + ### [Validation] Section `SecureBoot=`, `--secure-boot=` diff --git a/mkosi/resources/mkosi-obs/mkosi.conf b/mkosi/resources/mkosi-obs/mkosi.conf index 378383f21..259f4c78e 100644 --- a/mkosi/resources/mkosi-obs/mkosi.conf +++ b/mkosi/resources/mkosi-obs/mkosi.conf @@ -14,7 +14,7 @@ LocalMirror=file:///.build.binaries/ [Output] OutputDirectory= Checksum=yes -SplitArtifacts=pcrs,roothash,os-release +SplitArtifacts=pcrs,roothash,os-release,appstream-metainfo CompressOutput=zstd [Validation] diff --git a/tests/test_json.py b/tests/test_json.py index 7bf5ecfd1..e9ef15d9f 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -98,6 +98,12 @@ def test_config() -> None: dump = textwrap.dedent( """\ { + "AppstreamDescription": null, + "AppstreamIcon": null, + "AppstreamId": null, + "AppstreamName": null, + "AppstreamSummary": null, + "AppstreamUrl": null, "Architecture": "ia64", "Autologin": false, "BaseTrees": [ @@ -437,6 +443,12 @@ def test_config() -> None: ) args = Config( + appstream_id=None, + appstream_name=None, + appstream_summary=None, + appstream_description=None, + appstream_url=None, + appstream_icon=None, architecture=Architecture.ia64, autologin=False, base_trees=[Path("/hello/world")],