diff --git a/mkosi/__init__.py b/mkosi/__init__.py index 7cb78c9adf..0b0ddfa4dd 100644 --- a/mkosi/__init__.py +++ b/mkosi/__init__.py @@ -87,7 +87,7 @@ yes_no, ) from mkosi.context import Context -from mkosi.distribution import Distribution, detect_distribution +from mkosi.distribution import Distribution, DistributionRelease, detect_distribution from mkosi.documentation import show_docs from mkosi.installer import clean_package_manager_metadata from mkosi.kmod import ( @@ -667,7 +667,7 @@ def run_configure_scripts(config: Config) -> Config: env = dict( DISTRIBUTION=str(config.distribution), - RELEASE=config.release, + RELEASE=str(config.release), ARCHITECTURE=str(config.architecture), QEMU_ARCHITECTURE=config.architecture.to_qemu(), DISTRIBUTION_ARCHITECTURE=config.distribution.installer.architecture(config.architecture), @@ -715,7 +715,7 @@ def run_sync_scripts(config: Config) -> None: env = dict( DISTRIBUTION=str(config.distribution), - RELEASE=config.release, + RELEASE=str(config.release), ARCHITECTURE=str(config.architecture), DISTRIBUTION_ARCHITECTURE=config.distribution.installer.architecture(config.architecture), SRCDIR="/work/src", @@ -831,7 +831,7 @@ def run_prepare_scripts(context: Context, build: bool) -> None: env = dict( DISTRIBUTION=str(context.config.distribution), - RELEASE=context.config.release, + RELEASE=str(context.config.release), ARCHITECTURE=str(context.config.architecture), DISTRIBUTION_ARCHITECTURE=context.config.distribution.installer.architecture( context.config.architecture @@ -904,7 +904,7 @@ def run_build_scripts(context: Context) -> None: env = dict( DISTRIBUTION=str(context.config.distribution), - RELEASE=context.config.release, + RELEASE=str(context.config.release), ARCHITECTURE=str(context.config.architecture), DISTRIBUTION_ARCHITECTURE=context.config.distribution.installer.architecture( context.config.architecture @@ -984,7 +984,7 @@ def run_postinst_scripts(context: Context) -> None: env = dict( DISTRIBUTION=str(context.config.distribution), - RELEASE=context.config.release, + RELEASE=str(context.config.release), ARCHITECTURE=str(context.config.architecture), DISTRIBUTION_ARCHITECTURE=context.config.distribution.installer.architecture( context.config.architecture @@ -1058,7 +1058,7 @@ def run_finalize_scripts(context: Context) -> None: env = dict( DISTRIBUTION=str(context.config.distribution), - RELEASE=context.config.release, + RELEASE=str(context.config.release), ARCHITECTURE=str(context.config.architecture), DISTRIBUTION_ARCHITECTURE=context.config.distribution.installer.architecture( context.config.architecture @@ -1132,7 +1132,7 @@ def run_postoutput_scripts(context: Context) -> None: env = dict( DISTRIBUTION=str(context.config.distribution), - RELEASE=context.config.release, + RELEASE=str(context.config.release), ARCHITECTURE=str(context.config.architecture), DISTRIBUTION_ARCHITECTURE=context.config.distribution.installer.architecture( context.config.architecture @@ -3481,12 +3481,14 @@ def make_image( cmdline += ["--definitions", workdir(d)] opts += ["--ro-bind", d, workdir(d)] - def can_orphan_file(distribution: Union[Distribution, str, None], release: Optional[str]) -> bool: + def can_orphan_file( + distribution: Union[Distribution, str, None], release: Optional[DistributionRelease] + ) -> bool: if not isinstance(distribution, Distribution): return True return not ( - (distribution == Distribution.centos and release and GenericVersion(release) == 9) + (distribution == Distribution.centos and release and release == 9) or (distribution == Distribution.ubuntu and release == "jammy") ) @@ -4187,7 +4189,7 @@ def run_box(args: Args, config: Config) -> None: if hd: env |= {"MKOSI_HOST_DISTRIBUTION": str(hd)} if hr: - env |= {"MKOSI_HOST_RELEASE": hr} + env |= {"MKOSI_HOST_RELEASE": str(hr)} if config.tools() != Path("/"): env |= {"MKOSI_DEFAULT_TOOLS_TREE_PATH": os.fspath(config.tools())} @@ -4602,7 +4604,7 @@ def run_clean_scripts(config: Config) -> None: env = dict( DISTRIBUTION=str(config.distribution), - RELEASE=config.release, + RELEASE=str(config.release), ARCHITECTURE=str(config.architecture), DISTRIBUTION_ARCHITECTURE=config.distribution.installer.architecture(config.architecture), SRCDIR="/work/src", diff --git a/mkosi/config.py b/mkosi/config.py index 051f710891..c24228c573 100644 --- a/mkosi/config.py +++ b/mkosi/config.py @@ -26,12 +26,12 @@ import textwrap import typing import uuid -from collections.abc import Collection, Iterable, Iterator, Sequence +from collections.abc import Collection, Iterable, Iterator, Mapping, Sequence from contextlib import AbstractContextManager from pathlib import Path from typing import Any, Callable, ClassVar, Generic, Optional, Protocol, TypeVar, Union, cast -from mkosi.distribution import Distribution, detect_distribution +from mkosi.distribution import Distribution, DistributionRelease, detect_distribution from mkosi.log import ARG_DEBUG, ARG_DEBUG_SANDBOX, ARG_DEBUG_SHELL, complete_step, die from mkosi.pager import page from mkosi.run import SandboxProtocol, find_binary, nosandbox, run, sandbox_cmd, workdir @@ -60,7 +60,7 @@ class DataclassInstance(Protocol): D = TypeVar("D", bound=DataclassInstance) SE = TypeVar("SE", bound=StrEnum) -ConfigParseCallback = Callable[[Optional[str], Optional[T]], Optional[T]] +ConfigParseCallback = Callable[[Optional[str], Optional[T], Mapping[str, Any]], Optional[T]] ConfigMatchCallback = Callable[[str, T], bool] ConfigDefaultCallback = Callable[[dict[str, Any]], T] @@ -798,14 +798,22 @@ def parse_paths_from_directory( return sorted(parse_path(os.fspath(p), resolve=resolve, secret=secret) for p in path.glob(glob)) -def config_parse_key(value: Optional[str], old: Optional[str]) -> Optional[Path]: +def config_parse_key( + value: Optional[str], + old: Optional[str], + namespace: Mapping[str, Any], +) -> Optional[Path]: if not value: return None return parse_path(value, secret=True) if Path(value).exists() else Path(value) -def config_parse_certificate(value: Optional[str], old: Optional[str]) -> Optional[Path]: +def config_parse_certificate( + value: Optional[str], + old: Optional[str], + namespace: Mapping[str, Any], +) -> Optional[Path]: if not value: return None @@ -854,7 +862,11 @@ def config_match_list(match: str, value: list[T]) -> bool: return config_match_list -def config_parse_string(value: Optional[str], old: Optional[str]) -> Optional[str]: +def config_parse_string( + value: Optional[str], + old: Optional[str], + namespace: Mapping[str, Any], +) -> Optional[str]: return value or None @@ -876,7 +888,11 @@ def config_match_key_value(match: str, value: dict[str, str]) -> bool: return value.get(k, None) == v -def config_parse_boolean(value: Optional[str], old: Optional[bool]) -> Optional[bool]: +def config_parse_boolean( + value: Optional[str], + old: Optional[bool], + namespace: Mapping[str, Any], +) -> Optional[bool]: if value is None: return False @@ -893,7 +909,11 @@ def parse_feature(value: str) -> ConfigFeature: return ConfigFeature.enabled if parse_boolean(value) else ConfigFeature.disabled -def config_parse_feature(value: Optional[str], old: Optional[ConfigFeature]) -> Optional[ConfigFeature]: +def config_parse_feature( + value: Optional[str], + old: Optional[ConfigFeature], + namespace: Mapping[str, Any], +) -> Optional[ConfigFeature]: if value is None: return ConfigFeature.auto @@ -907,7 +927,23 @@ def config_match_feature(match: str, value: ConfigFeature) -> bool: return value == parse_feature(match) -def config_parse_compression(value: Optional[str], old: Optional[Compression]) -> Optional[Compression]: +def config_parse_release( + value: Optional[str], + old: Optional[DistributionRelease], + namespace: Mapping[str, Any], +) -> Optional[DistributionRelease]: + if not value: + return None + + distribution = cast(Distribution, namespace.get("distribution", config_default_distribution({}))) + return distribution.installer.parse_release(value) + + +def config_parse_compression( + value: Optional[str], + old: Optional[Compression], + namespace: Mapping[str, Any], +) -> Optional[Compression]: if not value: return None @@ -917,7 +953,11 @@ def config_parse_compression(value: Optional[str], old: Optional[Compression]) - return Compression.zstd if parse_boolean(value) else Compression.none -def config_parse_uuid(value: Optional[str], old: Optional[str]) -> Optional[uuid.UUID]: +def config_parse_uuid( + value: Optional[str], + old: Optional[str], + namespace: Mapping[str, Any], +) -> Optional[uuid.UUID]: if not value: return None @@ -930,7 +970,11 @@ def config_parse_uuid(value: Optional[str], old: Optional[str]) -> Optional[uuid die(f"{value} is not a valid UUID") -def config_parse_source_date_epoch(value: Optional[str], old: Optional[int]) -> Optional[int]: +def config_parse_source_date_epoch( + value: Optional[str], + old: Optional[int], + namespace: Mapping[str, Any], +) -> Optional[int]: if not value: return None @@ -945,7 +989,11 @@ def config_parse_source_date_epoch(value: Optional[str], old: Optional[int]) -> return timestamp -def config_parse_compress_level(value: Optional[str], old: Optional[int]) -> Optional[int]: +def config_parse_compress_level( + value: Optional[str], + old: Optional[int], + namespace: Mapping[str, Any], +) -> Optional[int]: if not value: return None @@ -960,7 +1008,11 @@ def config_parse_compress_level(value: Optional[str], old: Optional[int]) -> Opt return level -def config_parse_mode(value: Optional[str], old: Optional[int]) -> Optional[int]: +def config_parse_mode( + value: Optional[str], + old: Optional[int], + namespace: Mapping[str, Any], +) -> Optional[int]: if not value: return None @@ -1013,24 +1065,24 @@ def config_default_distribution(namespace: dict[str, Any]) -> Distribution: if not isinstance(detected, Distribution): logging.info( - "Distribution of your host can't be detected or isn't a supported target. " - "Defaulting to Distribution=custom." + "Distribution of your host can't be detected or isn't a supported target. Defaulting to Distribution=custom." # noqa: E501 ) return Distribution.custom return detected -def config_default_release(namespace: dict[str, Any]) -> str: +def config_default_release(namespace: dict[str, Any]) -> DistributionRelease: hd: Union[Distribution, str, None] - hr: Optional[str] + hr: Optional[DistributionRelease] if ( (d := os.getenv("MKOSI_HOST_DISTRIBUTION")) and d in Distribution.values() and (r := os.getenv("MKOSI_HOST_RELEASE")) ): - hd, hr = Distribution(d), r + hd = Distribution(d) + hr = hd.installer.parse_release(r) else: hd, hr = detect_distribution() @@ -1038,7 +1090,7 @@ def config_default_release(namespace: dict[str, Any]) -> str: if namespace["distribution"] == hd and hr is not None: return hr - return cast(str, namespace["distribution"].installer.default_release()) + return cast(DistributionRelease, namespace["distribution"].installer.default_release()) def config_default_tools_tree_distribution(namespace: dict[str, Any]) -> Distribution: @@ -1075,7 +1127,7 @@ def config_default_source_date_epoch(namespace: dict[str, Any]) -> Optional[int] break else: s = os.environ.get("SOURCE_DATE_EPOCH") - return config_parse_source_date_epoch(s, None) + return config_parse_source_date_epoch(s, None, {}) def config_default_proxy_url(namespace: dict[str, Any]) -> Optional[str]: @@ -1152,14 +1204,22 @@ def parse_enum(value: str) -> SE: def config_make_enum_parser(type: type[SE]) -> ConfigParseCallback[SE]: - def config_parse_enum(value: Optional[str], old: Optional[SE]) -> Optional[SE]: + def config_parse_enum( + value: Optional[str], + old: Optional[SE], + namespace: Mapping[str, Any], + ) -> Optional[SE]: return make_enum_parser(type)(value) if value else None return config_parse_enum def config_make_enum_parser_with_boolean(type: type[SE], *, yes: SE, no: SE) -> ConfigParseCallback[SE]: - def config_parse_enum(value: Optional[str], old: Optional[SE]) -> Optional[SE]: + def config_parse_enum( + value: Optional[str], + old: Optional[SE], + namespace: Mapping[str, Any], + ) -> Optional[SE]: if not value: return None @@ -1203,7 +1263,11 @@ def config_make_list_parser( reset: bool = True, key: Optional[Callable[[T], Any]] = None, ) -> ConfigParseCallback[list[T]]: - def config_parse_list(value: Optional[str], old: Optional[list[T]]) -> Optional[list[T]]: + def config_parse_list( + value: Optional[str], + old: Optional[list[T]], + namespace: Mapping[str, Any], + ) -> Optional[list[T]]: new = old.copy() if old else [] if value is None: @@ -1263,6 +1327,31 @@ def config_match_version(match: str, value: str) -> bool: return True +def config_match_release(match: str, value: DistributionRelease) -> bool: + for sigil, opfunc in { + "==": operator.eq, + "!=": operator.ne, + "<=": operator.le, + ">=": operator.ge, + ">": operator.gt, + "<": operator.lt, + }.items(): + if (rhs := startswith(match, sigil)) is not None: + op = opfunc + comp_version = rhs + break + else: + # default to equality if no operation is specified + op = operator.eq + comp_version = match + + # all constraints must be fulfilled + if not op(value, comp_version): + return False + + return True + + def config_make_dict_parser( *, delimiter: Optional[str] = None, @@ -1274,6 +1363,7 @@ def config_make_dict_parser( def config_parse_dict( value: Optional[str], old: Optional[dict[str, PathString]], + namespace: Mapping[str, Any], ) -> Optional[dict[str, PathString]]: new = old.copy() if old else {} @@ -1357,7 +1447,11 @@ def config_make_path_parser( absolute: bool = False, constants: Sequence[str] = (), ) -> ConfigParseCallback[Path]: - def config_parse_path(value: Optional[str], old: Optional[Path]) -> Optional[Path]: + def config_parse_path( + value: Optional[str], + old: Optional[Path], + namespace: Mapping[str, Any], + ) -> Optional[Path]: if not value: return None @@ -1381,7 +1475,11 @@ def is_valid_filename(s: str) -> bool: def config_make_filename_parser(hint: str) -> ConfigParseCallback[str]: - def config_parse_filename(value: Optional[str], old: Optional[str]) -> Optional[str]: + def config_parse_filename( + value: Optional[str], + old: Optional[str], + namespace: Mapping[str, Any], + ) -> Optional[str]: if not value: return None @@ -1404,7 +1502,9 @@ def match_path_exists(image: str, value: str) -> bool: def config_parse_root_password( - value: Optional[str], old: Optional[tuple[str, bool]] + value: Optional[str], + old: Optional[tuple[str, bool]], + namespace: Mapping[str, Any], ) -> Optional[tuple[str, bool]]: if not value: return None @@ -1456,14 +1556,22 @@ def parse_bytes(value: str) -> int: return result -def config_parse_bytes(value: Optional[str], old: Optional[int] = None) -> Optional[int]: +def config_parse_bytes( + value: Optional[str], + old: Optional[int] = None, + namespace: Mapping[str, Any] = {}, +) -> Optional[int]: if not value: return None return parse_bytes(value) -def config_parse_number(value: Optional[str], old: Optional[int] = None) -> Optional[int]: +def config_parse_number( + value: Optional[str], + old: Optional[int] = None, + namespace: Mapping[str, Any] = {}, +) -> Optional[int]: if not value: return None @@ -1512,7 +1620,11 @@ def parse_drive(value: str) -> Drive: ) -def config_parse_sector_size(value: Optional[str], old: Optional[int]) -> Optional[int]: +def config_parse_sector_size( + value: Optional[str], + old: Optional[int], + namespace: Mapping[str, Any], +) -> Optional[int]: if not value: return None @@ -1530,7 +1642,11 @@ def config_parse_sector_size(value: Optional[str], old: Optional[int]) -> Option return size -def config_parse_vsock_cid(value: Optional[str], old: Optional[int]) -> Optional[int]: +def config_parse_vsock_cid( + value: Optional[str], + old: Optional[int], + namespace: Mapping[str, Any], +) -> Optional[int]: if not value: return None @@ -1551,7 +1667,11 @@ def config_parse_vsock_cid(value: Optional[str], old: Optional[int]) -> Optional return cid -def config_parse_minimum_version(value: Optional[str], old: Optional[str]) -> Optional[str]: +def config_parse_minimum_version( + value: Optional[str], + old: Optional[str], + namespace: Mapping[str, Any], +) -> Optional[str]: if not value: return old @@ -1628,7 +1748,11 @@ def __str__(self) -> str: return f"{self.type}:{self.source}" if self.source else str(self.type) -def config_parse_key_source(value: Optional[str], old: Optional[KeySource]) -> Optional[KeySource]: +def config_parse_key_source( + value: Optional[str], + old: Optional[KeySource], + namespace: Mapping[str, Any], +) -> Optional[KeySource]: if not value: return KeySource(type=KeySourceType.file) @@ -1658,6 +1782,7 @@ def __str__(self) -> str: def config_parse_certificate_source( value: Optional[str], old: Optional[CertificateSource], + namespace: Mapping[str, Any], ) -> Optional[CertificateSource]: if not value: return CertificateSource(type=CertificateSourceType.file) @@ -1672,7 +1797,9 @@ def config_parse_certificate_source( def config_parse_artifact_output_list( - value: Optional[str], old: Optional[list[ArtifactOutput]] + value: Optional[str], + old: Optional[list[ArtifactOutput]], + namespace: Mapping[str, Any], ) -> Optional[list[ArtifactOutput]]: if not value: return [] @@ -1683,7 +1810,7 @@ def config_parse_artifact_output_list( return ArtifactOutput.compat_yes() if boolean_value else ArtifactOutput.compat_no() list_parser = config_make_list_parser(delimiter=",", parse=make_enum_parser(ArtifactOutput)) - return list_parser(value, old) + return list_parser(value, old, namespace) class SettingScope(StrEnum): @@ -1722,6 +1849,7 @@ class ConfigSetting(Generic[T]): section: str parse: ConfigParseCallback[T] = config_parse_string # type: ignore # see mypy#3737 match: Optional[ConfigMatchCallback[T]] = None + depends: tuple[str, ...] = tuple() name: str = "" default: Optional[T] = None default_factory: Optional[ConfigDefaultCallback[T]] = None @@ -1956,7 +2084,7 @@ def finalize_value(config: dict[str, Any], setting: ConfigSetting[object]) -> No elif setting.default: default = setting.default else: - default = setting.parse(None, None) + default = setting.parse(None, None, {}) config[setting.dest] = default @@ -1974,8 +2102,7 @@ def parse_simple_config(value: str) -> D: if section != s.section: logging.warning( - f"{path.absolute()}: Setting {name} should be configured in [{s.section}], not " - f"[{section}]." + f"{path.absolute()}: Setting {name} should be configured in [{s.section}], not [{section}]." # noqa: E501 ) if name != s.name: @@ -1983,7 +2110,13 @@ def parse_simple_config(value: str) -> D: f"{path.absolute()}: Setting {name} is deprecated, please use {s.name} instead." ) - config[s.dest] = s.parse(value, config.get(s.dest)) + current_namespace = {} + if s.depends: + for d in s.depends: + finalize_value(config, SETTINGS_LOOKUP_BY_DEST[d]) + current_namespace[d] = config[d] + + config[s.dest] = s.parse(value, config.get(s.dest), current_namespace) for setting in settings: finalize_value(config, setting) @@ -2010,7 +2143,7 @@ class Config: pass_environment: list[str] distribution: Distribution - release: str + release: DistributionRelease architecture: Architecture mirror: Optional[str] snapshot: Optional[str] @@ -2435,7 +2568,7 @@ def expand_key_specifiers(self, key: str) -> str: specifiers = { "&": "&", "d": str(self.distribution), - "r": self.release, + "r": str(self.release), "a": str(self.architecture), "i": self.image_id or "", "v": self.image_version or "", @@ -2716,8 +2849,9 @@ def parse_kernel_module_filter_regexp(p: str) -> str: short="-r", section="Distribution", specifier="r", - parse=config_parse_string, - match=config_make_string_matcher(), + parse=config_parse_release, + match=config_match_release, + depends=("distribution",), default_factory=config_default_release, default_factory_depends=("distribution",), help="Distribution release to install", @@ -4654,7 +4788,17 @@ def __call__( for v in values: assert isinstance(v, str) or v is None - parsed_value = s.parse(v, getattr(namespace, self.dest, None)) + current_namespace = {} + for d in s.depends: + setting = SETTINGS_LOOKUP_BY_DEST[d] + if d in namespace: + current_namespace[d] = getattr(namespace, d) + elif setting.default_factory: + current_namespace[d] = setting.default_factory(dict(namespace)) # type: ignore + else: + current_namespace[d] = setting.default + + parsed_value = s.parse(v, getattr(namespace, self.dest, None), current_namespace) if parsed_value is None: setattr(namespace, f"{s.dest}_was_none", True) setattr(namespace, s.dest, parsed_value) @@ -4824,7 +4968,7 @@ def finalize_value(self, setting: ConfigSetting[T]) -> Optional[T]: and field and (origin in (dict, list, str) or (origin is typing.Union and type(None) in args)) ): - default = setting.parse(None, None) + default = setting.parse(None, None, {}) elif setting.dest in self.defaults: default = self.defaults[setting.dest] elif setting.default_factory: @@ -4846,7 +4990,7 @@ def finalize_value(self, setting: ConfigSetting[T]) -> Optional[T]: elif setting.default is not None: default = setting.default else: - default = setting.parse(None, None) + default = setting.parse(None, None, {}) self.defaults[setting.dest] = default @@ -4985,6 +5129,7 @@ def parse_config_one(self, path: Path, parse_profiles: bool = False, parse_local self.config[s.dest] = s.parse( file_run_or_read(extra).rstrip("\n") if s.path_read_text else f, self.config.get(s.dest), + {setting: v for setting, v in self.config.items() if setting in s.depends}, ) for f in s.recursive_path_suffixes: @@ -5000,7 +5145,11 @@ def parse_config_one(self, path: Path, parse_profiles: bool = False, parse_local ) for e in recursive_extras: if e.exists(): - self.config[s.dest] = s.parse(os.fspath(e), self.config.get(s.dest)) + self.config[s.dest] = s.parse( + os.fspath(e), + self.config.get(s.dest), + {setting: v for setting, v in image.items() if setting in s.depends}, + ) if path.exists(): logging.debug(f"Loading configuration file {path}") @@ -5043,7 +5192,11 @@ def parse_config_one(self, path: Path, parse_profiles: bool = False, parse_local v = self.expand_specifiers(v, path) - self.config[s.dest] = s.parse(v, self.config.get(s.dest)) + self.config[s.dest] = s.parse( + v, + self.config.get(s.dest), + {setting: v for setting, v in self.config.items() if setting in s.depends}, + ) self.parse_new_includes() if extras and (path.parent / "mkosi.conf.d").exists(): @@ -5843,6 +5996,8 @@ def default(self, o: Any) -> Any: return str(o) elif isinstance(o, GenericVersion): return str(o) + elif isinstance(o, DistributionRelease): + return str(o) elif isinstance(o, os.PathLike): return os.fspath(o) elif isinstance(o, uuid.UUID): diff --git a/mkosi/distribution/__init__.py b/mkosi/distribution/__init__.py index 5d42b8f5b3..45bc06034d 100644 --- a/mkosi/distribution/__init__.py +++ b/mkosi/distribution/__init__.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: LGPL-2.1-or-later import enum +import functools import importlib import urllib.parse from collections.abc import Sequence @@ -9,6 +10,7 @@ from mkosi.log import die from mkosi.util import StrEnum, read_env_file +from mkosi.versioncomp import GenericVersion if TYPE_CHECKING: from mkosi.config import Architecture, Config @@ -24,6 +26,65 @@ class PackageType(StrEnum): apk = enum.auto() +@functools.total_ordering +class DistributionRelease: + def __init__(self, release: str, *, releasemap: Optional[dict[str, tuple[str, str]]] = None) -> None: + self.releasemap = {} if releasemap is None else releasemap + self._release = release + + def __str__(self) -> str: + return self.releasemap.get(self._release, (None, self._release))[1] + + def __fspath__(self) -> str: + return str(self) + + def __repr__(self) -> str: + return f"DistributionRelease('{self._release}')" + + def major(self) -> str: + return self._release.partition(".")[0] + + def minor(self) -> str: + return self._release.partition(".")[1] + + def isnumeric(self) -> bool: + return self._release.isdigit() + + def capitalize(self) -> str: + return str(self).capitalize() + + def _construct_versions(self, other: object) -> tuple[GenericVersion, Optional[GenericVersion]]: + v1 = GenericVersion(self.releasemap.get(self._release.lower(), (self._release.lower(),))[0]) + + if isinstance(other, DistributionRelease): + if self.releasemap == other.releasemap: + v2 = GenericVersion( + other.releasemap.get(other._release.lower(), (other._release.lower(),))[0] + ) + else: + v2 = None + elif isinstance(other, GenericVersion): + v2 = GenericVersion(self.releasemap.get(str(other), (str(other),))[0]) + elif isinstance(other, (str, int)): + v2 = GenericVersion(self.releasemap.get(str(other), (str(other),))[0]) + else: + raise ValueError(f"{other} not a DistributionRelease, str or int") + + return v1, v2 + + def __eq__(self, other: object) -> bool: + v1, v2 = self._construct_versions(other) + if v2 is None: + return False + return v1 == v2 + + def __lt__(self, other: object) -> bool: + v1, v2 = self._construct_versions(other) + if v2 is None: + return False + return v1 < v2 + + class Distribution(StrEnum): # Please consult docs/distribution-policy.md and contact one # of the mkosi maintainers before implementing a new distribution. @@ -77,6 +138,8 @@ def installer(self) -> type["DistributionInstaller"]: class DistributionInstaller: + _default_release = "" + _releasemap: dict[str, tuple[str, str]] = {} registry: dict[Distribution, "type[DistributionInstaller]"] = {} def __init_subclass__(cls, distribution: Distribution): @@ -131,8 +194,12 @@ def package_type(cls) -> PackageType: return PackageType.none @classmethod - def default_release(cls) -> str: - return "" + def default_release(cls) -> DistributionRelease: + return DistributionRelease(cls._default_release, releasemap=cls._releasemap) + + @classmethod + def parse_release(cls, release: str) -> DistributionRelease: + return DistributionRelease(release, releasemap=cls._releasemap) @classmethod def default_tools_tree_distribution(cls) -> Optional[Distribution]: @@ -151,7 +218,9 @@ def is_kernel_package(cls, package: str) -> bool: return False -def detect_distribution(root: Path = Path("/")) -> tuple[Union[Distribution, str, None], Optional[str]]: +def detect_distribution( + root: Path = Path("/"), +) -> tuple[Union[Distribution, str, None], Optional[DistributionRelease]]: try: os_release = read_env_file(root / "etc/os-release") except FileNotFoundError: @@ -181,7 +250,10 @@ def detect_distribution(root: Path = Path("/")) -> tuple[Union[Distribution, str if d and d.is_apt_distribution() and version_codename: version_id = version_codename - return d or dist_id, version_id + if d and version_id: + return d, d.installer.parse_release(version_id) + + return d, DistributionRelease(version_id) if version_id is not None else None def join_mirror(mirror: str, link: str) -> str: diff --git a/mkosi/distribution/alma.py b/mkosi/distribution/alma.py index 0df6037ffc..7185b30e6a 100644 --- a/mkosi/distribution/alma.py +++ b/mkosi/distribution/alma.py @@ -13,12 +13,11 @@ def pretty_name(cls) -> str: @classmethod def gpgurls(cls, context: Context) -> tuple[str, ...]: - major = cls.major_release(context.config) return ( find_rpm_gpgkey( context, - f"RPM-GPG-KEY-AlmaLinux-{major}", - f"https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-{major}", + f"RPM-GPG-KEY-AlmaLinux-{context.config.release.major()}", + f"https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-{context.config.release.major()}", ), ) diff --git a/mkosi/distribution/arch.py b/mkosi/distribution/arch.py index abcff507c8..f81a1de6f6 100644 --- a/mkosi/distribution/arch.py +++ b/mkosi/distribution/arch.py @@ -7,12 +7,20 @@ from mkosi.config import Architecture, Config from mkosi.context import Context from mkosi.curl import curl -from mkosi.distribution import Distribution, DistributionInstaller, PackageType, join_mirror +from mkosi.distribution import ( + Distribution, + DistributionInstaller, + DistributionRelease, + PackageType, + join_mirror, +) from mkosi.installer.pacman import Pacman, PacmanRepository from mkosi.log import complete_step, die class Installer(DistributionInstaller, distribution=Distribution.arch): + _default_release = "rolling" + @classmethod def pretty_name(cls) -> str: return "Arch Linux" @@ -26,8 +34,8 @@ def package_type(cls) -> PackageType: return PackageType.pkg @classmethod - def default_release(cls) -> str: - return "rolling" + def parse_release(cls, release: str) -> DistributionRelease: + return DistributionRelease("rolling") @classmethod def package_manager(cls, config: "Config") -> type[Pacman]: diff --git a/mkosi/distribution/azure.py b/mkosi/distribution/azure.py index a440cc3479..c0bf041e50 100644 --- a/mkosi/distribution/azure.py +++ b/mkosi/distribution/azure.py @@ -15,14 +15,12 @@ class Installer(fedora.Installer, distribution=Distribution.azure): + _default_release = "3.0" + @classmethod def pretty_name(cls) -> str: return "Azure Linux" - @classmethod - def default_release(cls) -> str: - return "3.0" - @classmethod def filesystem(cls) -> str: return "ext4" @@ -54,7 +52,7 @@ def repositories(cls, context: Context) -> Iterable[RpmRepository]: return mirror = context.config.mirror or "https://packages.microsoft.com/azurelinux" - url = join_mirror(mirror, context.config.release) + url = join_mirror(mirror, str(context.config.release)) for repo in ("base", "extended", "ms-oss", "ms-non-oss", "cloud-native", "nvidia"): yield RpmRepository( diff --git a/mkosi/distribution/centos.py b/mkosi/distribution/centos.py index 57176a30a6..8301d0edf2 100644 --- a/mkosi/distribution/centos.py +++ b/mkosi/distribution/centos.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +import os from collections.abc import Iterable from mkosi.config import Architecture, Config @@ -15,12 +16,13 @@ from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey, setup_rpm from mkosi.log import die from mkosi.util import startswith -from mkosi.versioncomp import GenericVersion CENTOS_SIG_REPO_PRIORITY = 50 class Installer(DistributionInstaller, distribution=Distribution.centos): + _default_release = "10" + @classmethod def pretty_name(cls) -> str: return "CentOS" @@ -33,18 +35,10 @@ def filesystem(cls) -> str: def package_type(cls) -> PackageType: return PackageType.rpm - @classmethod - def default_release(cls) -> str: - return "10" - @classmethod def default_tools_tree_distribution(cls) -> Distribution: return Distribution.fedora - @classmethod - def major_release(cls, config: "Config") -> str: - return config.release.partition(".")[0] - @classmethod def package_manager(cls, config: "Config") -> type[Dnf]: return Dnf @@ -57,24 +51,21 @@ def grub_prefix(cls) -> str: def dbpath(cls, context: Context) -> str: # The Hyperscale SIG uses /usr/lib/sysimage/rpm in its rebuild of rpm for C9S that's shipped in the # hyperscale-packages-experimental repository. - if ( - GenericVersion(context.config.release) > 9 - or "hyperscale-packages-experimental" in context.config.repositories - ): + if context.config.release > 9 or "hyperscale-packages-experimental" in context.config.repositories: return "/usr/lib/sysimage/rpm" return "/var/lib/rpm" @classmethod def setup(cls, context: Context) -> None: - if GenericVersion(context.config.release) <= 8: + if context.config.release <= 8: die(f"{cls.pretty_name()} Stream 8 or earlier variants are not supported") setup_rpm(context, dbpath=cls.dbpath(context)) Dnf.setup(context, list(cls.repositories(context))) (context.sandbox_tree / "etc/dnf/vars/stream").write_text( - f"{cls.major_release(context.config)}-stream\n" + f"{context.config.release.major()}-stream\n" ) @classmethod @@ -99,7 +90,7 @@ def architecture(cls, arch: Architecture) -> str: def gpgurls(cls, context: Context) -> tuple[str, ...]: # First, start with the names of the appropriate keys in /etc/pki/rpm-gpg. - if GenericVersion(context.config.release) == 9: + if context.config.release == 9: rel = "RPM-GPG-KEY-centosofficial" else: rel = "RPM-GPG-KEY-centosofficial-SHA256" @@ -108,7 +99,7 @@ def gpgurls(cls, context: Context) -> tuple[str, ...]: # Next, follow up with the names of the appropriate keys in /usr/share/distribution-gpg-keys. - if GenericVersion(context.config.release) == 9: + if context.config.release == 9: rel = "RPM-GPG-KEY-CentOS-Official" else: rel = "RPM-GPG-KEY-CentOS-Official-SHA256" @@ -159,7 +150,7 @@ def repository_variants( if mirror == "https://composes.stream.centos.org": subdir = f"stream-{context.config.release}/production" elif mirror == "https://mirror.facebook.net/centos-composes": - subdir = context.config.release + subdir = os.fspath(context.config.release) elif repo == "extras": subdir = "SIGs/$stream" else: @@ -252,16 +243,16 @@ def repositories(cls, context: Context) -> Iterable[RpmRepository]: @classmethod def epel_repositories(cls, context: Context) -> Iterable[RpmRepository]: # Since EPEL 10, there's an associated minor release for every RHEL minor release. - if GenericVersion(context.config.release) >= 10: + if context.config.release >= 10: release = context.config.release else: - release = cls.major_release(context.config) + release = cls.parse_release(context.config.release.major()) gpgurls = ( find_rpm_gpgkey( context, - f"RPM-GPG-KEY-EPEL-{cls.major_release(context.config)}", - f"https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-{cls.major_release(context.config)}", + f"RPM-GPG-KEY-EPEL-{context.config.release.major()}", + f"https://dl.fedoraproject.org/pub/epel/RPM-GPG-KEY-EPEL-{context.config.release.major()}", ), ) @@ -274,7 +265,7 @@ def epel_repositories(cls, context: Context) -> Iterable[RpmRepository]: ("epel", "epel"), ("epel-testing", "epel/testing"), ] - if GenericVersion(context.config.release) < 10: + if context.config.release < 10: repodirs += [ ("epel-next", "epel/next"), ("epel-next-testing", "epel/testing/next"), @@ -310,7 +301,7 @@ def epel_repositories(cls, context: Context) -> Iterable[RpmRepository]: # epel-next does not exist anymore since EPEL 10. repos = ["epel"] - if GenericVersion(context.config.release) < 10: + if context.config.release < 10: repos += ["epel-next"] for repo in repos: @@ -353,7 +344,7 @@ def epel_repositories(cls, context: Context) -> Iterable[RpmRepository]: ) # epel-next does not exist anymore since EPEL 10. - if GenericVersion(context.config.release) < 10: + if context.config.release < 10: yield RpmRepository( "epel-next-testing", f"{url}&repo=epel-testing-next-{release}", @@ -460,7 +451,7 @@ def latest_snapshot(cls, config: Config) -> str: mirror = config.mirror or "https://composes.stream.centos.org" if mirror == "https://mirror.facebook.net/centos-composes": - subdir = config.release + subdir = os.fspath(config.release) else: subdir = f"stream-{config.release}/production" diff --git a/mkosi/distribution/debian.py b/mkosi/distribution/debian.py index ca95dd4266..f2c65edf66 100644 --- a/mkosi/distribution/debian.py +++ b/mkosi/distribution/debian.py @@ -18,6 +18,24 @@ class Installer(DistributionInstaller, distribution=Distribution.debian): + _default_release = "testing" + _releasemap = { + "11": ("11", "bullseye"), + "bullseye": ("11", "bullseye"), + "12": ("12", "bookworm"), + "bookworm": ("12", "bookworm"), + "13": ("13", "trixie"), + "trixie": ("13", "trixie"), + "14": ("14", "forky"), + "forky": ("14", "forky"), + "15": ("15", "duke"), + "duke": ("15", "duke"), + "sid": ("9999", "sid"), + "stable": ("12", "stable"), + "testing": ("13", "testing"), + "unstable": ("9999", "sid"), + } + @classmethod def pretty_name(cls) -> str: return "Debian" @@ -30,10 +48,6 @@ def filesystem(cls) -> str: def package_type(cls) -> PackageType: return PackageType.deb - @classmethod - def default_release(cls) -> str: - return "testing" - @classmethod def package_manager(cls, config: Config) -> type[Apt]: return Apt diff --git a/mkosi/distribution/fedora.py b/mkosi/distribution/fedora.py index 8a8cc4fac5..02a9093250 100644 --- a/mkosi/distribution/fedora.py +++ b/mkosi/distribution/fedora.py @@ -101,6 +101,12 @@ def find_fedora_rpm_gpgkeys(context: Context) -> Iterable[str]: class Installer(DistributionInstaller, distribution=Distribution.fedora): + _default_release = "rawhide" + _releasemap = { + "adams": ("42", "42"), + "rawhide": ("9999", "rawhide"), + } + @classmethod def pretty_name(cls) -> str: return "Fedora Linux" @@ -113,10 +119,6 @@ def filesystem(cls) -> str: def package_type(cls) -> PackageType: return PackageType.rpm - @classmethod - def default_release(cls) -> str: - return "rawhide" - @classmethod def grub_prefix(cls) -> str: return "grub2" diff --git a/mkosi/distribution/kali.py b/mkosi/distribution/kali.py index d3dec56b7c..c3ca59e3d9 100644 --- a/mkosi/distribution/kali.py +++ b/mkosi/distribution/kali.py @@ -11,14 +11,12 @@ class Installer(debian.Installer, distribution=Distribution.kali): + _default_release = "kali-rolling" + @classmethod def pretty_name(cls) -> str: return "Kali Linux" - @classmethod - def default_release(cls) -> str: - return "kali-rolling" - @classmethod def default_tools_tree_distribution(cls) -> Distribution: return Distribution.kali diff --git a/mkosi/distribution/mageia.py b/mkosi/distribution/mageia.py index 657af17c64..656e0bf8bf 100644 --- a/mkosi/distribution/mageia.py +++ b/mkosi/distribution/mageia.py @@ -10,6 +10,11 @@ class Installer(fedora.Installer, distribution=Distribution.mageia): + _default_distribution = "cauldron" + _releasemap = { + "cauldron": ("99", "cauldron"), + } + @classmethod def pretty_name(cls) -> str: return "Mageia" @@ -18,10 +23,6 @@ def pretty_name(cls) -> str: def filesystem(cls) -> str: return "ext4" - @classmethod - def default_release(cls) -> str: - return "cauldron" - @classmethod def install(cls, context: Context) -> None: cls.install_packages(context, ["filesystem"], apivfs=False) diff --git a/mkosi/distribution/openmandriva.py b/mkosi/distribution/openmandriva.py index 424a04f249..f508a7df88 100644 --- a/mkosi/distribution/openmandriva.py +++ b/mkosi/distribution/openmandriva.py @@ -10,6 +10,16 @@ class Installer(fedora.Installer, distribution=Distribution.openmandriva): + _default_release = "cooker" + _releasemap = { + "lx 5.0": ("5.0", "lx 5.0"), + "stable": ("5.0", "stable"), + "rock": ("5.0", "rock"), + "rolling": ("98", "rolling"), + "rome": ("98", "rome"), + "cooker": ("99", "cooker"), + } + @classmethod def pretty_name(cls) -> str: return "OpenMandriva" @@ -18,10 +28,6 @@ def pretty_name(cls) -> str: def filesystem(cls) -> str: return "ext4" - @classmethod - def default_release(cls) -> str: - return "cooker" - @classmethod def install(cls, context: Context) -> None: cls.install_packages(context, ["filesystem"], apivfs=False) diff --git a/mkosi/distribution/opensuse.py b/mkosi/distribution/opensuse.py index 428c9b27a7..9332901da7 100644 --- a/mkosi/distribution/opensuse.py +++ b/mkosi/distribution/opensuse.py @@ -10,17 +10,30 @@ from mkosi.config import Architecture, Config, parse_ini from mkosi.context import Context from mkosi.curl import curl -from mkosi.distribution import Distribution, DistributionInstaller, PackageType, join_mirror +from mkosi.distribution import ( + Distribution, + DistributionInstaller, + PackageType, + join_mirror, +) from mkosi.installer.dnf import Dnf from mkosi.installer.rpm import RpmRepository, find_rpm_gpgkey, setup_rpm from mkosi.installer.zypper import Zypper from mkosi.log import complete_step, die from mkosi.run import exists_in_sandbox, run, workdir from mkosi.util import PathString -from mkosi.versioncomp import GenericVersion class Installer(DistributionInstaller, distribution=Distribution.opensuse): + _default_release = "tumbleweed" + _releasemap = { + "current": ("16.0", "current"), + "stable": ("16.0", "stable"), + "leap": ("16.0", "leap"), + "tumbleweed": ("9999", "tumbleweed"), + "rolling": ("9999", "rolling"), + } + @classmethod def pretty_name(cls) -> str: return "openSUSE" @@ -33,10 +46,6 @@ def filesystem(cls) -> str: def package_type(cls) -> PackageType: return PackageType.rpm - @classmethod - def default_release(cls) -> str: - return "tumbleweed" - @classmethod def grub_prefix(cls) -> str: return "grub2" @@ -89,8 +98,12 @@ def keyring(cls, context: Context) -> None: def install(cls, context: Context) -> None: packages = ["filesystem"] if not any(p.endswith("-release") for p in context.config.packages): - if context.config.release in ("current", "stable", "leap") or ( - context.config.release != "tumbleweed" and GenericVersion(context.config.release) >= 16 + if context.config.release in ( + cls.parse_release("current"), + cls.parse_release("stable"), + cls.parse_release("leap"), + ) or ( + context.config.release != cls.parse_release("tumbleweed") and context.config.release >= 16 ): packages += ["Leap-release"] else: @@ -107,7 +120,7 @@ def repositories(cls, context: Context) -> Iterable[RpmRepository]: zypper = cls.package_manager(context.config) is Zypper mirror = context.config.mirror or "https://download.opensuse.org" - if context.config.release == "tumbleweed": + if context.config.release == cls.parse_release("tumbleweed"): gpgkeys = tuple( p for key in ("RPM-GPG-KEY-openSUSE-Tumbleweed", "RPM-GPG-KEY-openSUSE") @@ -197,7 +210,8 @@ def repositories(cls, context: Context) -> Iterable[RpmRepository]: die(f"Snapshot= is only supported for Tumbleweed on {cls.pretty_name()}") if ( - context.config.release in ("current", "stable", "leap") + context.config.release + in (cls.parse_release("current"), cls.parse_release("stable"), cls.parse_release("leap")) and context.config.architecture != Architecture.x86_64 ): die( @@ -206,7 +220,11 @@ def repositories(cls, context: Context) -> Iterable[RpmRepository]: hint="Specify either tumbleweed or a specific leap release such as 15.6", ) - if context.config.release in ("current", "stable", "leap"): + if context.config.release in ( + cls.parse_release("current"), + cls.parse_release("stable"), + cls.parse_release("leap"), + ): release = "openSUSE-current" else: release = f"leap/{context.config.release}" @@ -248,8 +266,9 @@ def repositories(cls, context: Context) -> Iterable[RpmRepository]: ) if ( - context.config.release in ("current", "stable", "leap") - or GenericVersion(context.config.release) >= 16 + context.config.release + in (cls.parse_release("current"), cls.parse_release("stable"), cls.parse_release("leap")) + or context.config.release >= 16 ): subdir += f"distribution/{release}/repo" else: diff --git a/mkosi/distribution/postmarketos.py b/mkosi/distribution/postmarketos.py index 1bf9b34ffc..e96fa9961e 100644 --- a/mkosi/distribution/postmarketos.py +++ b/mkosi/distribution/postmarketos.py @@ -12,6 +12,9 @@ class Installer(DistributionInstaller, distribution=Distribution.postmarketos): + _default_release = "edge" + _releasemap = {"edge": ("9999", "edge")} + @classmethod def pretty_name(cls) -> str: return "postmarketOS" @@ -24,10 +27,6 @@ def filesystem(cls) -> str: def package_type(cls) -> PackageType: return PackageType.apk - @classmethod - def default_release(cls) -> str: - return "edge" - @classmethod def default_tools_tree_distribution(cls) -> Distribution: return Distribution.postmarketos diff --git a/mkosi/distribution/rhel.py b/mkosi/distribution/rhel.py index cf7aa7fa4e..87b20e2fa5 100644 --- a/mkosi/distribution/rhel.py +++ b/mkosi/distribution/rhel.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: LGPL-2.1-or-later +import os from collections.abc import Iterable from pathlib import Path from typing import Any, Optional @@ -21,7 +22,7 @@ def gpgurls(cls, context: Context) -> tuple[str, ...]: return ( find_rpm_gpgkey( context, - f"RPM-GPG-KEY-redhat{cls.major_release(context.config)}-release", + f"RPM-GPG-KEY-redhat{context.config.release.major()}-release", "https://access.redhat.com/security/data/fd431d51.txt", ), ) @@ -90,22 +91,22 @@ def repository_variants( ) v = context.config.release - major = cls.major_release(context.config) + major = v.major() yield RpmRepository( - f"rhel-{v}-{repo}-rpms", - f"baseurl={join_mirror(mirror, f'rhel{major}/{v}/$basearch/{repo}/os')}", + f"rhel-{os.fspath(v)}-{repo}-rpms", + f"baseurl={join_mirror(mirror, f'rhel{major}/{os.fspath(v)}/$basearch/{repo}/os')}", enabled=True, **common, ) yield RpmRepository( - f"rhel-{v}-{repo}-debug-rpms", - f"baseurl={join_mirror(mirror, f'rhel{major}/{v}/$basearch/{repo}/debug')}", + f"rhel-{os.fspath(v)}-{repo}-debug-rpms", + f"baseurl={join_mirror(mirror, f'rhel{major}/{os.fspath(v)}/$basearch/{repo}/debug')}", enabled=False, **common, ) yield RpmRepository( - f"rhel-{v}-{repo}-source", - f"baseurl={join_mirror(mirror, f'rhel{major}/{v}/$basearch/{repo}/source')}", + f"rhel-{os.fspath(v)}-{repo}-source", + f"baseurl={join_mirror(mirror, f'rhel{major}/{os.fspath(v)}/$basearch/{repo}/source')}", enabled=False, **common, ) diff --git a/mkosi/distribution/rhel_ubi.py b/mkosi/distribution/rhel_ubi.py index f03b64e492..d80e3c9eed 100644 --- a/mkosi/distribution/rhel_ubi.py +++ b/mkosi/distribution/rhel_ubi.py @@ -18,7 +18,7 @@ def gpgurls(cls, context: Context) -> tuple[str, ...]: return ( find_rpm_gpgkey( context, - f"RPM-GPG-KEY-redhat{cls.major_release(context.config)}-release", + f"RPM-GPG-KEY-redhat{context.config.release.major()}-release", "https://access.redhat.com/security/data/fd431d51.txt", ), ) diff --git a/mkosi/distribution/rocky.py b/mkosi/distribution/rocky.py index deaa73c46f..af29ee4078 100644 --- a/mkosi/distribution/rocky.py +++ b/mkosi/distribution/rocky.py @@ -13,12 +13,11 @@ def pretty_name(cls) -> str: @classmethod def gpgurls(cls, context: Context) -> tuple[str, ...]: - major = cls.major_release(context.config) return ( find_rpm_gpgkey( context, - f"RPM-GPG-KEY-Rocky-{major}", - f"https://download.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-{major}", + f"RPM-GPG-KEY-Rocky-{context.config.release.major()}", + f"https://download.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-{context.config.release.major()}", ), ) diff --git a/mkosi/distribution/ubuntu.py b/mkosi/distribution/ubuntu.py index 2112b4e8f9..653cf3d516 100644 --- a/mkosi/distribution/ubuntu.py +++ b/mkosi/distribution/ubuntu.py @@ -15,14 +15,36 @@ class Installer(debian.Installer, distribution=Distribution.ubuntu): + _default_release = "devel" + _releasemap = { + "20.04": ("20.04", "focal"), + "focal": ("20.04", "focal"), + "focal fossa": ("20.04", "focal"), + "22.04": ("22.04", "jammy"), + "jammy": ("22.04", "jammy"), + "jammy jellyfish": ("22.04", "jammy"), + "24.04": ("24.04", "noble"), + "noble": ("24.04", "noble"), + "noble numbat": ("24.04", "noble"), + "24.10": ("24.10", "oracular"), + "oracular": ("24.10", "oracular"), + "oracular oriole": ("24.10", "oracular"), + "25.04": ("25.04", "plucky"), + "plucky": ("25.04", "plucky"), + "plucky puffin": ("25.04", "plucky"), + "25.10": ("25.10", "questing"), + "questing": ("25.10", "questing"), + "questing quokka": ("25.10", "questing"), + "26.04": ("26.04", "resolute"), + "resolute": ("26.04", "resolute"), + "resolute raccoon": ("26.04", "resolute"), + "devel": ("9999", "devel"), + } + @classmethod def pretty_name(cls) -> str: return "Ubuntu" - @classmethod - def default_release(cls) -> str: - return "devel" - @classmethod def default_tools_tree_distribution(cls) -> Distribution: return Distribution.debian diff --git a/mkosi/installer/apt.py b/mkosi/installer/apt.py index a6338dfd27..4448eee175 100644 --- a/mkosi/installer/apt.py +++ b/mkosi/installer/apt.py @@ -4,10 +4,11 @@ import textwrap from collections.abc import Sequence from pathlib import Path -from typing import Final, Optional +from typing import Final, Optional, Union from mkosi.config import Config, ConfigFeature from mkosi.context import Context +from mkosi.distribution import DistributionRelease from mkosi.installer import PackageManager from mkosi.log import die from mkosi.run import CompletedProcess, run, workdir @@ -19,7 +20,7 @@ class AptRepository: types: tuple[str, ...] url: str - suite: str + suite: Union[str, DistributionRelease] components: tuple[str, ...] signedby: Optional[Path] snapshot: Optional[str] = None diff --git a/mkosi/manifest.py b/mkosi/manifest.py index df36393861..eef33a7cf5 100644 --- a/mkosi/manifest.py +++ b/mkosi/manifest.py @@ -242,7 +242,7 @@ def as_dict(self) -> dict[str, Any]: if self.context.config.image_version is not None: config["version"] = self.context.config.image_version if self.context.config.release is not None: - config["release"] = self.context.config.release + config["release"] = str(self.context.config.release) return { # Bump this when incompatible changes are made to the manifest format. diff --git a/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/dpkg.conf b/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/dpkg.conf index bfe36320e1..8daded0c32 100644 --- a/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/dpkg.conf +++ b/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/dpkg.conf @@ -2,14 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [TriggerMatch] Distribution=ubuntu -Release=!jammy -Release=!noble -Release=!oracular +Release=>oracular [Content] RemovePackages= diff --git a/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-cryptsetup.conf b/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-cryptsetup.conf index 7ec70d2ea9..09797ed12c 100644 --- a/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-cryptsetup.conf +++ b/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-cryptsetup.conf @@ -2,13 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [TriggerMatch] Distribution=ubuntu -Release=!jammy -Release=!noble +Release=>noble [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf b/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf index 67ffb47b5d..ecc34195d4 100644 --- a/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf +++ b/mkosi/resources/mkosi-initrd/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf @@ -2,8 +2,7 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [TriggerMatch] Distribution=ubuntu diff --git a/mkosi/resources/mkosi-initrd/mkosi.conf.d/fedora-stable.conf b/mkosi/resources/mkosi-initrd/mkosi.conf.d/fedora-stable.conf index acc547d253..cbdb427481 100644 --- a/mkosi/resources/mkosi-initrd/mkosi.conf.d/fedora-stable.conf +++ b/mkosi/resources/mkosi-initrd/mkosi.conf.d/fedora-stable.conf @@ -2,9 +2,7 @@ [Match] Distribution=fedora -Release=|40 -Release=|41 -Release=|42 +Release=<43 [Content] RemovePackages= diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/efi.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/efi.conf index a277bae441..ac0525f118 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/efi.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/efi.conf @@ -2,11 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye +Release=>bullseye [TriggerMatch] Distribution=ubuntu -Release=!jammy +Release=>jammy [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/pkcs11-provider.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/pkcs11-provider.conf index 6cb9aa884c..ae32857824 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/pkcs11-provider.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/pkcs11-provider.conf @@ -2,12 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bookworm -Release=!bullseye +Release=>bookworm [TriggerMatch] Distribution=ubuntu -Release=!jammy +Release=>jammy [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-tools.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-tools.conf index a646c2a7e8..cbfd5106f7 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-tools.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-tools.conf @@ -2,13 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [TriggerMatch] Distribution=ubuntu -Release=!jammy -Release=!noble +Release=>noble [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf index 67ffb47b5d..ecc34195d4 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-repart.conf @@ -2,8 +2,7 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [TriggerMatch] Distribution=ubuntu diff --git a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-ukify.conf b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-ukify.conf index 6fb6d20147..eb8534f475 100644 --- a/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-ukify.conf +++ b/mkosi/resources/mkosi-tools/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-ukify.conf @@ -2,12 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [TriggerMatch] Distribution=ubuntu -Release=!jammy +Release=>jammy [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-tools/mkosi.profiles/package-manager/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/ubuntu-keyring.conf b/mkosi/resources/mkosi-tools/mkosi.profiles/package-manager/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/ubuntu-keyring.conf index a94ac4ab4d..c64b334d9b 100644 --- a/mkosi/resources/mkosi-tools/mkosi.profiles/package-manager/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/ubuntu-keyring.conf +++ b/mkosi/resources/mkosi-tools/mkosi.profiles/package-manager/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/ubuntu-keyring.conf @@ -1,8 +1,14 @@ # SPDX-License-Identifier: LGPL-2.1-or-later -[Match] -Distribution=|!debian -Release=|!bookworm +[TriggerMatch] +Distribution=debian +Release=>bookworm + +[TriggerMatch] +Distribution=kali + +[TriggerMatch] +Distribution=ubuntu [Content] Packages= diff --git a/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/python3-virt-firmware.conf b/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/python3-virt-firmware.conf index 82519a8498..8b8f884af2 100644 --- a/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/python3-virt-firmware.conf +++ b/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/python3-virt-firmware.conf @@ -2,12 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bookworm -Release=!bullseye +Release=>bookworm [TriggerMatch] Distribution=ubuntu -Release=!jammy +Release=>jammy [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/virtiofsd.conf b/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/virtiofsd.conf index 3ef8b198e1..8fe92e21e1 100644 --- a/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/virtiofsd.conf +++ b/mkosi/resources/mkosi-tools/mkosi.profiles/runtime/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/virtiofsd.conf @@ -2,12 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [TriggerMatch] Distribution=ubuntu -Release=!jammy +Release=>jammy [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-signed.conf b/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-signed.conf index b87f26edd3..2e416bf6e0 100644 --- a/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-signed.conf +++ b/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot-signed.conf @@ -2,8 +2,7 @@ [TriggerMatch] Distribution=debian -Release=!bullseye -Release=!bookworm +Release=>bookworm [Match] Architecture=|x86-64 diff --git a/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot.conf b/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot.conf index 081391273d..893d99dd7c 100644 --- a/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot.conf +++ b/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-boot.conf @@ -2,11 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye +Release=>bullseye [TriggerMatch] Distribution=ubuntu -Release=!jammy +Release=>jammy [TriggerMatch] Distribution=kali diff --git a/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-resolved.conf b/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-resolved.conf index d028b048f9..6abd264f4d 100644 --- a/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-resolved.conf +++ b/mkosi/resources/mkosi-vm/mkosi.conf.d/debian-kali-ubuntu/mkosi.conf.d/systemd-resolved.conf @@ -2,11 +2,11 @@ [TriggerMatch] Distribution=debian -Release=!bullseye +Release=>bullseye [TriggerMatch] Distribution=ubuntu -Release=!jammy +Release=>jammy [TriggerMatch] Distribution=kali diff --git a/tests/test_config.py b/tests/test_config.py index 0076df8c42..82de7ced34 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,7 +25,13 @@ parse_config, parse_ini, ) -from mkosi.distribution import Distribution, detect_distribution +from mkosi.distribution import ( + Distribution, + DistributionRelease, + debian, + detect_distribution, + ubuntu, +) from mkosi.util import chdir, resource_path @@ -1899,3 +1905,103 @@ def test_initrds_custom_only(tmp_path: Path) -> None: assert len(config.initrds) == 1 assert config.initrds[0] == d / "myinitrd.cpio" + + +@pytest.mark.parametrize( + "s1,s2", + itertools.combinations_with_replacement( + enumerate( + [ + debian.Installer.parse_release("bullseye"), + debian.Installer.parse_release("bookworm"), + debian.Installer.parse_release("trixie"), + debian.Installer.parse_release("forky"), + debian.Installer.parse_release("duke"), + debian.Installer.parse_release("sid"), + ], + start=11, + ), + 2, + ), +) +def test_debian_release( + s1: tuple[int, DistributionRelease], + s2: tuple[int, DistributionRelease], +) -> None: + i1, v1 = s1 + i2, v2 = s2 + assert (v1 == v2) == (i1 == i2) + assert (v1 < v2) == (i1 < i2) + assert (v1 <= v2) == (i1 <= i2) + assert (v1 > v2) == (i1 > i2) + assert (v1 >= v2) == (i1 >= i2) + assert (v1 != v2) == (i1 != i2) + assert v1 == str(v1) + if v1 != debian.Installer.parse_release("sid"): + assert v1 == i1 + assert v1 == str(i1) + assert v1 < (i1 + 1) + assert v1 > (i1 - 1) + + +def test_debian_unstable() -> None: + assert debian.Installer.parse_release("sid") == debian.Installer.parse_release("unstable") + + +def test_debian_not_ubuntu() -> None: + assert debian.Installer.parse_release("bookworm") != ubuntu.Installer.parse_release("bookworm") + + +def test_match_distro(tmp_path: Path) -> None: + with chdir(tmp_path): + Path("mkosi.conf").write_text( + """\ + [Distribution] + Distribution=debian + Release=trixie + """ + ) + + Path("mkosi.conf.d").mkdir() + Path("mkosi.conf.d/10-abc.conf").write_text( + """\ + [Match] + Release=>=bookworm + + [Build] + Environment=FOO=foo + """ + ) + Path("mkosi.conf.d/20-def.conf").write_text( + """\ + [Match] + Release=>=trixie + + [Build] + Environment=BAR=bar + """ + ) + Path("mkosi.conf.d/30-ghi.conf").write_text( + """\ + [Match] + Release= None: qemu_args=[], ram=123, register=ConfigFeature.enabled, - release="53", + release=DistributionRelease("53"), removable=False, remove_files=[], remove_packages=["all"],