diff --git a/src/gardenlinux/constants.py b/src/gardenlinux/constants.py index 895cac3b..091d8990 100644 --- a/src/gardenlinux/constants.py +++ b/src/gardenlinux/constants.py @@ -141,7 +141,13 @@ "secureboot.aws-efivars": "application/io.gardenlinux.cert.secureboot.aws-efivars", } +GL_BUG_REPORT_URL = "https://github.com/gardenlinux/gardenlinux/issues" +GL_COMMIT_SPECIAL_VALUES = ("local",) +GL_DISTRIBUTION_NAME = "Garden Linux" +GL_HOME_URL = "https://gardenlinux.io" +GL_RELEASE_ID = "gardenlinux" GL_REPOSITORY_URL = "https://github.com/gardenlinux/gardenlinux" +GL_SUPPORT_URL = "https://github.com/gardenlinux/gardenlinux" OCI_ANNOTATION_SIGNATURE_KEY = "io.gardenlinux.oci.signature" OCI_ANNOTATION_SIGNED_STRING_KEY = "io.gardenlinux.oci.signed-string" @@ -153,5 +159,7 @@ S3_DOWNLOADS_DIR = Path(os.path.dirname(__file__)) / ".." / "s3_downloads" -GLVD_BASE_URL = "https://glvd.ingress.glvd.gardnlinux.shoot.canary.k8s-hana.ondemand.com/v1" +GLVD_BASE_URL = ( + "https://glvd.ingress.glvd.gardnlinux.shoot.canary.k8s-hana.ondemand.com/v1" +) GL_DEB_REPO_BASE_URL = "https://packages.gardenlinux.io/gardenlinux" diff --git a/src/gardenlinux/features/__main__.py b/src/gardenlinux/features/__main__.py index 2d9c6260..73cc0d45 100644 --- a/src/gardenlinux/features/__main__.py +++ b/src/gardenlinux/features/__main__.py @@ -42,17 +42,14 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--arch", dest="arch") - parser.add_argument("--cname", dest="cname") + parser.add_argument("--cname", dest="cname", required=True) parser.add_argument("--commit", dest="commit") parser.add_argument("--feature-dir", default="features") + parser.add_argument("--release-file", dest="release_file") parser.add_argument("--default-arch", dest="default_arch") parser.add_argument("--default-version", dest="default_version") parser.add_argument("--version", dest="version") - parser.add_argument( - "--features", type=lambda arg: set([f for f in arg.split(",") if f]) - ) - parser.add_argument( "--ignore", dest="ignore", @@ -64,13 +61,13 @@ def main() -> None: args = parser.parse_args() - assert bool(args.features) or bool( - args.cname - ), "Please provide either `--features` or `--cname` argument" + assert bool(args.feature_dir) or bool( + args.release_file + ), "Please provide either `--feature_dir` or `--release_file` argument" arch = args.arch flavor = None - commit_id = args.commit + commit_id_or_hash = args.commit gardenlinux_root = path.dirname(args.feature_dir) version = args.version @@ -82,7 +79,9 @@ def main() -> None: if version is None or version == "": try: - version, commit_id = get_version_and_commit_id_from_files(gardenlinux_root) + version, commit_id_or_hash = get_version_and_commit_id_from_files( + gardenlinux_root + ) except RuntimeError as exc: logging.debug( "Failed to parse version information for GL root '{0}': {1}".format( @@ -93,17 +92,18 @@ def main() -> None: version = args.default_version if args.cname: - cname = CName(args.cname, arch=arch, commit_id=commit_id, version=version) + cname = CName( + args.cname, arch=arch, commit_hash=commit_id_or_hash, version=version + ) + + if args.release_file is not None: + cname.load_from_release_file(args.release_file) arch = cname.arch flavor = cname.flavor - commit_id = cname.commit_id + commit_id_or_hash = cname.commit_id version = cname.version - input_features = Parser.get_cname_as_feature_set(flavor) - else: - input_features = args.features - if arch is None or arch == "" and (args.type in ("cname", "arch")): raise RuntimeError( "Architecture could not be determined and no default architecture set" @@ -118,59 +118,31 @@ def main() -> None: feature_dir_name = path.basename(args.feature_dir) - additional_filter_func = lambda node: node not in args.ignore - if args.type == "arch": print(arch) - elif args.type in ("cname_base", "cname", "graph"): - graph = Parser(gardenlinux_root, feature_dir_name).filter( - flavor, additional_filter_func=additional_filter_func - ) - - sorted_features = Parser.sort_graph_nodes(graph) - minimal_feature_set = get_minimal_feature_set(graph) - - sorted_minimal_features = sort_subset(minimal_feature_set, sorted_features) - - cname_base = get_cname_base(sorted_minimal_features) - - if args.type == "cname_base": - print(cname_base) - elif args.type == "cname": - cname = flavor - - if arch is not None: - cname += f"-{arch}" - - if commit_id is not None: - cname += f"-{version}-{commit_id}" + elif args.type in ( + "cname_base", + "cname", + "elements", + "features", + "flags", + "graph", + "platforms", + ): + if args.type == "graph" or len(args.ignore) > 1: + features_parser = Parser(gardenlinux_root, feature_dir_name) - print(cname) - elif args.type == "graph": - print(graph_as_mermaid_markup(flavor, graph)) - elif args.type == "features": - print( - Parser(gardenlinux_root, feature_dir_name).filter_as_string( - flavor, additional_filter_func=additional_filter_func + print_output_from_features_parser( + args.type, features_parser, flavor, args.ignore ) - ) - elif args.type in ("flags", "elements", "platforms"): - features_by_type = Parser(gardenlinux_root, feature_dir_name).filter_as_dict( - flavor, additional_filter_func=additional_filter_func - ) - - if args.type == "platforms": - print(",".join(features_by_type["platform"])) - elif args.type == "elements": - print(",".join(features_by_type["element"])) - elif args.type == "flags": - print(",".join(features_by_type["flag"])) + else: + print_output_from_cname(args.type, cname) elif args.type == "commit_id": - print(commit_id) + print(commit_id_or_hash[:8]) elif args.type == "version": print(version) elif args.type == "version_and_commit_id": - print(f"{version}-{commit_id}") + print(f"{version}-{commit_id_or_hash[:8]}") def get_cname_base(sorted_features: Set[str]): @@ -198,21 +170,21 @@ def get_version_and_commit_id_from_files(gardenlinux_root: str) -> tuple[str, st :since: 0.7.0 """ - commit_id = None + commit_hash = None version = None if os.access(path.join(gardenlinux_root, "COMMIT"), os.R_OK): with open(path.join(gardenlinux_root, "COMMIT"), "r") as fp: - commit_id = fp.read().strip()[:8] + commit_hash = fp.read().strip()[:8] if os.access(path.join(gardenlinux_root, "VERSION"), os.R_OK): with open(path.join(gardenlinux_root, "VERSION"), "r") as fp: version = fp.read().strip() - if commit_id is None or version is None: + if commit_hash is None or version is None: raise RuntimeError("Failed to read version or commit ID from files") - return (version, commit_id) + return (version, commit_hash) def get_minimal_feature_set(graph: Any) -> Set[str]: @@ -251,6 +223,95 @@ def graph_as_mermaid_markup(flavor: str, graph: Any) -> str: return markup +def print_output_from_features_parser( + output_type: str, parser: Parser, flavor: str, ignores_list: set +) -> None: + """ + Prints output to stdout based on the given features parser and parameters. + + :param output_type: Output type + :param parser: Features parser + :param flavor: Flavor + :param ignores_list: Features to ignore + + :since: 0.11.0 + """ + + additional_filter_func = lambda node: node not in ignores_list + + if output_type == "features": + print( + parser.filter_as_string( + flavor, additional_filter_func=additional_filter_func + ) + ) + elif (output_type in "platforms", "elements", "flags"): + features_by_type = parser.filter_as_dict( + flavor, additional_filter_func=additional_filter_func + ) + + if output_type == "platforms": + print(",".join(features_by_type["platform"])) + elif output_type == "elements": + print(",".join(features_by_type["element"])) + elif output_type == "flags": + print(",".join(features_by_type["flag"])) + else: + graph = parser.filter(flavor, additional_filter_func=additional_filter_func) + + sorted_features = Parser.sort_graph_nodes(graph) + minimal_feature_set = get_minimal_feature_set(graph) + + sorted_minimal_features = sort_subset(minimal_feature_set, sorted_features) + + cname_base = get_cname_base(sorted_minimal_features) + + if output_type == "cname_base": + print(cname_base) + elif output_type == "cname": + cname = flavor + + if arch is not None: + cname += f"-{arch}" + + if commit_id_or_hash is not None: + cname += f"-{version}-{commit_id_or_hash[:8]}" + + print(cname) + if output_type == "platforms": + print(",".join(features_by_type["platform"])) + elif output_type == "elements": + print(",".join(features_by_type["element"])) + elif output_type == "flags": + print(",".join(features_by_type["flag"])) + elif output_type == "graph": + print(graph_as_mermaid_markup(flavor, graph)) + + +def print_output_from_cname(output_type: str, cname_instance: CName) -> None: + """ + Prints output to stdout based on the given CName instance. + + :param output_type: Output type + :param cname_instance: CName instance + + :since: 0.11.0 + """ + + if output_type == "cname_base": + print(cname_instance.flavor) + elif output_type == "cname": + print(cname_instance.cname) + elif output_type == "platforms": + print(cname_instance.feature_set_platform) + elif output_type == "elements": + print(cname_instance.feature_set_element) + elif output_type == "features": + print(cname_instance.feature_set) + elif output_type == "flags": + print(cname_instance.feature_set_flag) + + def sort_subset(input_set: Set[str], order_list: List[str]) -> List[str]: """ Returns items from `order_list` if given in `input_set`. diff --git a/src/gardenlinux/features/cname.py b/src/gardenlinux/features/cname.py index f245b1d2..58cc9dc8 100644 --- a/src/gardenlinux/features/cname.py +++ b/src/gardenlinux/features/cname.py @@ -5,9 +5,20 @@ """ import re -from typing import Optional - -from ..constants import ARCHS +from configparser import UNNAMED_SECTION, ConfigParser +from os import PathLike +from pathlib import Path +from typing import List, Optional + +from ..constants import ( + ARCHS, + GL_BUG_REPORT_URL, + GL_COMMIT_SPECIAL_VALUES, + GL_DISTRIBUTION_NAME, + GL_HOME_URL, + GL_RELEASE_ID, + GL_SUPPORT_URL, +) from .parser import Parser @@ -24,23 +35,30 @@ class CName(object): Apache License, Version 2.0 """ - def __init__(self, cname, arch=None, commit_id=None, version=None): + def __init__(self, cname, arch=None, commit_hash=None, version=None): """ Constructor __init__(CName) :param cname: Canonical name to represent :param arch: Architecture if not part of cname - :param commit_id: Commit ID if not part of cname + :param commit_hash: Commit ID or hash if not part of cname :param version: Version if not part of cname :since: 0.7.0 """ self._arch = None - self._flavor = None + self._commit_hash = None self._commit_id = None + self._feature_elements_cached = None + self._feature_flags_cached = None + self._feature_platforms_cached = None + self._feature_set_cached = None + self._flavor = None self._version = None + commit_id_or_hash = None + re_match = re.match( "([a-zA-Z0-9]+([\\_\\-][a-zA-Z0-9]+)*?)(-([a-z0-9]+)(-([a-z0-9.]+)-([a-z0-9]+))*)?$", cname, @@ -51,7 +69,7 @@ def __init__(self, cname, arch=None, commit_id=None, version=None): if re_match.lastindex == 1: self._flavor = re_match[1] else: - self._commit_id = re_match[7] + commit_id_or_hash = re_match[7] self._flavor = re_match[1] self._version = re_match[6] @@ -64,23 +82,30 @@ def __init__(self, cname, arch=None, commit_id=None, version=None): self._arch = arch if self._version is None and version is not None: - # Support version values formatted as - - if commit_id is None: + # Support version values formatted as - + if commit_hash is None: re_match = re.match("([a-z0-9.]+)(-([a-z0-9]+))?$", version) assert re_match, f"Not a valid version {version}" - self._commit_id = re_match[3] + commit_id_or_hash = re_match[3] self._version = re_match[1] else: - self._commit_id = commit_id + commit_id_or_hash = commit_hash self._version = version + if commit_id_or_hash is not None: + self._commit_id = commit_id_or_hash[:8] + + if len(commit_id_or_hash) == 40: # sha1 hex + self._commit_hash = commit_id_or_hash + @property def arch(self) -> Optional[str]: """ Returns the architecture for the cname parsed. :return: (str) CName architecture + :since: 0.7.0 """ return self._arch @@ -91,6 +116,7 @@ def cname(self) -> str: Returns the cname parsed. :return: (str) CName + :since: 0.7.0 """ cname = self._flavor @@ -103,12 +129,45 @@ def cname(self) -> str: return cname + @property + def commit_hash(self) -> str: + """ + Returns the commit hash if part of the cname parsed. + + :return: (str) Commit hash + :since: 0.11.0 + """ + + if self._commit_hash is None: + raise RuntimeError( + "GardenLinux canonical name given does not contain the commit hash" + ) + + return self._commit_hash + + @commit_hash.setter + def commit_hash(self, commit_hash) -> None: + """ + Sets the commit hash + + :param commit_hash: Commit hash + + :since: 0.11.0 + """ + + if self._commit_id is not None and not commit_hash.startswith(self._commit_id): + raise RuntimeError("Commit hash given differs from commit ID already set") + + self._commit_id = commit_hash[:8] + self._commit_hash = commit_hash + @property def commit_id(self) -> Optional[str]: """ Returns the commit ID if part of the cname parsed. :return: (str) Commit ID + :since: 0.7.0 """ return self._commit_id @@ -119,6 +178,7 @@ def flavor(self) -> str: Returns the flavor for the cname parsed. :return: (str) Flavor + :since: 0.7.0 """ return self._flavor @@ -129,19 +189,116 @@ def feature_set(self) -> str: Returns the feature set for the cname parsed. :return: (str) Feature set of the cname + :since: 0.7.0 """ + if self._feature_set_cached is not None: + return self._feature_set_cached + return Parser().filter_as_string(self.flavor) + @property + def feature_set_element(self) -> str: + """ + Returns the feature set of type "element" for the cname parsed. + + :return: (str) Feature set elements + :since: 0.11.0 + """ + + if self._feature_elements_cached is not None: + return ",".join(self._feature_elements_cached) + + return ",".join(Parser().filter_as_dict(self.flavor)["element"]) + + @property + def feature_set_flag(self) -> str: + """ + Returns the feature set of type "flag" for the cname parsed. + + :return: (str) Feature set flags + :since: 0.11.0 + """ + + if self._feature_flags_cached is not None: + return ",".join(self._feature_flags_cached) + + return ",".join(Parser().filter_as_dict(self.flavor)["flag"]) + + @property + def feature_set_platform(self) -> str: + """ + Returns the feature set of type "platform" for the cname parsed. + + :return: (str) Feature set platforms + :since: 0.11.0 + """ + + if self._feature_platforms_cached is not None: + return ",".join(self._feature_platforms_cached) + + return ",".join(Parser().filter_as_dict(self.flavor)["platform"]) + + @property + def release_metadata_string(self) -> str: + """ + Returns the release metadata describing the given CName instance. + + :return: (str) Release metadata describing the given CName instance + :since: 0.11.0 + """ + + features = Parser().filter_as_dict(self.flavor) + + elements = ",".join(features["element"]) + flags = ",".join(features["flag"]) + platforms = ",".join(features["platform"]) + + metadata = f""" +ID={GL_RELEASE_ID} +NAME="{GL_DISTRIBUTION_NAME}" +PRETTY_NAME="{GL_DISTRIBUTION_NAME} {self.version}" +IMAGE_VERSION={self.version} +VARIANT_ID="{self.flavor}-{self.arch}" +HOME_URL="{GL_HOME_URL}" +SUPPORT_URL="{GL_SUPPORT_URL}" +BUG_REPORT_URL="{GL_BUG_REPORT_URL}" +GARDENLINUX_CNAME="{self.cname}" +GARDENLINUX_FEATURES="{self.feature_set}" +GARDENLINUX_FEATURES_PLATFORMS="{platforms}" +GARDENLINUX_FEATURES_ELEMENTS="{elements}" +GARDENLINUX_FEATURES_FLAGS="{flags}" +GARDENLINUX_VERSION="{self.version}" +GARDENLINUX_COMMIT_ID="{self.commit_id}" +GARDENLINUX_COMMIT_ID_LONG="{self.commit_hash}" + """.strip() + + return metadata + @property def platform(self) -> str: """ - Returns the platform for the cname parsed. + Returns the feature set of type "platform" for the cname parsed. - :return: (str) Flavor + :return: (str) Feature set platforms + :since: 0.7.0 """ - return re.split("[_-]", self._flavor, maxsplit=1)[0] + return self.feature_set_platform + + @property + def platforms(self) -> List[str]: + """ + Returns the platforms for the cname parsed. + + :return: (str) Platforms + :since: 0.11.0 + """ + + if self._feature_platforms_cached is not None: + return self._feature_platforms_cached + + return Parser().filter_as_dict(self.flavor)["platform"] @property def version(self) -> Optional[str]: @@ -149,6 +306,7 @@ def version(self) -> Optional[str]: Returns the version if part of the cname parsed. :return: (str) Version + :since: 0.7.0 """ return self._version @@ -159,9 +317,106 @@ def version_and_commit_id(self) -> Optional[str]: Returns the version and commit ID if part of the cname parsed. :return: (str) Version and commit ID + :since: 0.7.0 """ if self._commit_id is None: return None return f"{self._version}-{self._commit_id}" + + def load_from_release_file(self, release_file: PathLike | str) -> None: + """ + Loads and parses a release metadata file. + + :param release_file: Release metadata file + + :since: 0.11.0 + """ + + if not isinstance(release_file, PathLike): + release_file = Path(release_file) + + if not release_file.exists(): + raise RuntimeError( + f"Release metadata file given is invalid: {release_file}" + ) + + release_config = ConfigParser(allow_unnamed_section=True) + release_config.read(release_file) + + for release_field in ( + "GARDENLINUX_CNAME", + "GARDENLINUX_FEATURES", + "GARDENLINUX_FEATURES_ELEMENTS", + "GARDENLINUX_FEATURES_FLAGS", + "GARDENLINUX_FEATURES_PLATFORMS", + "GARDENLINUX_VERSION", + ): + if not release_config.has_option(UNNAMED_SECTION, release_field): + raise RuntimeError( + f"Release metadata file given is invalid: {release_file} misses {release_field}" + ) + + loaded_cname_instance = CName( + release_config.get(UNNAMED_SECTION, "GARDENLINUX_CNAME") + ) + + commit_id = release_config.get(UNNAMED_SECTION, "GARDENLINUX_COMMIT_ID") + commit_hash = release_config.get(UNNAMED_SECTION, "GARDENLINUX_COMMIT_ID_LONG") + version = release_config.get(UNNAMED_SECTION, "GARDENLINUX_VERSION") + + if ( + loaded_cname_instance.flavor != self.flavor + or loaded_cname_instance.commit_id != commit_id + or (self._commit_id is not None and self._commit_id != commit_id) + or loaded_cname_instance.version != version + or (self._version is not None and self._version != version) + ): + raise RuntimeError( + f"Release metadata file given is invalid: {release_file} failed consistency check - {self.cname} != {loaded_cname_instance.cname}" + ) + + self._arch = loaded_cname_instance.arch + self._flavor = loaded_cname_instance.flavor + self._commit_hash = commit_hash + self._commit_id = commit_id + self._version = version + + self._feature_set_cached = release_config.get( + UNNAMED_SECTION, "GARDENLINUX_FEATURES" + ) + + self._feature_elements_cached = release_config.get( + UNNAMED_SECTION, "GARDENLINUX_FEATURES_ELEMENTS" + ).split(",") + + self._feature_flags_cached = release_config.get( + UNNAMED_SECTION, "GARDENLINUX_FEATURES_FLAGS" + ).split(",") + + self._feature_platforms_cached = release_config.get( + UNNAMED_SECTION, "GARDENLINUX_FEATURES_PLATFORMS" + ).split(",") + + def save_to_release_file( + self, release_file: PathLike | str, overwrite: Optional[bool] = False + ) -> None: + """ + Saves the release metadata file. + + :param release_file: Release metadata file + + :since: 0.11.0 + """ + + if not isinstance(release_file, PathLike): + release_file = Path(release_file) + + if not overwrite and release_file.exists(): + raise RuntimeError( + f"Refused to overwrite existing release metadata file: {release_file}" + ) + + with release_file.open("w") as fp: + fp.write(self.release_metadata_string) diff --git a/src/gardenlinux/features/cname_main.py b/src/gardenlinux/features/cname_main.py index 8013286a..7a73bc49 100644 --- a/src/gardenlinux/features/cname_main.py +++ b/src/gardenlinux/features/cname_main.py @@ -46,7 +46,7 @@ def main(): assert re_match, f"Not a valid GardenLinux canonical name {args.cname}" arch = args.arch - commit_id = args.commit + commit_id_or_hash = args.commit gardenlinux_root = dirname(args.feature_dir) version = args.version @@ -55,7 +55,9 @@ def main(): if not version: try: - version, commit_id = get_version_and_commit_id_from_files(gardenlinux_root) + version, commit_id_or_hash = get_version_and_commit_id_from_files( + gardenlinux_root + ) except RuntimeError as exc: logging.warning( "Failed to parse version information for GL root '{0}': {1}".format( @@ -63,7 +65,7 @@ def main(): ) ) - cname = CName(args.cname, arch=arch, commit_id=commit_id, version=version) + cname = CName(args.cname, arch=arch, commit_hash=commit_id_or_hash, version=version) assert cname.arch, "Architecture could not be determined" diff --git a/src/gardenlinux/features/metadata_main.py b/src/gardenlinux/features/metadata_main.py new file mode 100644 index 00000000..84a50ae6 --- /dev/null +++ b/src/gardenlinux/features/metadata_main.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +gl-metadata main entrypoint +""" + +import argparse +import logging +import re +from functools import reduce +from os.path import basename, dirname + +from .__main__ import ( + get_cname_base, + get_minimal_feature_set, + get_version_and_commit_id_from_files, + sort_subset, +) +from .cname import CName +from .parser import Parser + +_ARGS_ACTION_ALLOWED = [ + "output-release-metadata", + "write", +] + +def main(): + """ + gl-metadata main() + + :since: 0.7.0 + """ + + parser = argparse.ArgumentParser() + + parser.add_argument("--arch", dest="arch") + parser.add_argument("--cname", required=True, dest="cname") + parser.add_argument("--commit-hash", dest="commit_hash") + parser.add_argument("--release-file", dest="release_file") + parser.add_argument("--overwrite-file", type=bool, dest="overwrite_file") + parser.add_argument("--version", dest="version") + + parser.add_argument( + "action", + nargs="?", + choices=_ARGS_ACTION_ALLOWED, + default="output-release-metadata", + ) + + args = parser.parse_args() + + cname = CName( + args.cname, arch=args.arch, commit_hash=args.commit_hash, version=args.version + ) + + if args.commit_hash is not None: + cname.commit_hash = args.commit_hash + + if args.action == "write": + cname.save_to_release_file(args.release_file, args.overwrite_file) + else: + if args.release_file is not None: + cname.load_from_release_file(args.release_file) + + print(cname.release_metadata_string) + + +if __name__ == "__main__": + main() diff --git a/src/gardenlinux/features/parser.py b/src/gardenlinux/features/parser.py index 2fb6cb9e..d955abf1 100644 --- a/src/gardenlinux/features/parser.py +++ b/src/gardenlinux/features/parser.py @@ -137,35 +137,12 @@ def filter( :since: 0.7.0 """ - input_features = Parser.get_cname_as_feature_set(cname) - filter_set = input_features.copy() + feature_set = Parser.get_cname_as_feature_set(cname) - # @TODO: Remove "special" handling once "bare" is a first-class citizen of the feature graph - if "bare" in input_features: - if not self.graph.has_node("bare"): - self.graph.add_node("bare", content=BARE_FLAVOR_FEATURE_CONTENT) - if not self.graph.has_node("libc"): - self.graph.add_node("libc", content=BARE_FLAVOR_LIBC_FEATURE_CONTENT) - - for feature in input_features: - filter_set.update( - networkx.descendants( - Parser._get_graph_view_for_attr(self.graph, "include"), feature - ) - ) - - graph = networkx.subgraph_view( - self.graph, - filter_node=self._get_filter_set_callable( - filter_set, additional_filter_func - ), + return self.filter_based_on_feature_set( + feature_set, ignore_excludes, additional_filter_func ) - if not ignore_excludes: - Parser._exclude_from_filter_set(graph, input_features, filter_set) - - return graph - def filter_as_dict( self, cname: str, @@ -184,19 +161,7 @@ def filter_as_dict( """ graph = self.filter(cname, ignore_excludes, additional_filter_func) - features = Parser.sort_reversed_graph_nodes(graph) - - features_by_type = {} - - for feature in features: - node_type = Parser._get_graph_node_type(graph.nodes[feature]) - - if node_type not in features_by_type: - features_by_type[node_type] = [] - - features_by_type[node_type].append(feature) - - return features_by_type + return self.filter_graph_as_dict(graph) def filter_as_list( self, @@ -216,7 +181,7 @@ def filter_as_list( """ graph = self.filter(cname, ignore_excludes, additional_filter_func) - return Parser.sort_reversed_graph_nodes(graph) + return self.filter_graph_as_list(graph) def filter_as_string( self, @@ -236,15 +201,116 @@ def filter_as_string( """ graph = self.filter(cname, ignore_excludes, additional_filter_func) + return self.filter_graph_as_string(graph) + + def filter_graph_as_dict( + self, + graph: networkx.Graph, + ) -> dict: + """ + Filters the features graph and returns it as a dict. + + :param graph: Features graph + + :return: (dict) List of features for a given cname, split into platform, element and flag + :since: 0.9.2 + """ + features = Parser.sort_reversed_graph_nodes(graph) + features_by_type = {} + + for feature in features: + node_type = Parser._get_graph_node_type(graph.nodes[feature]) + + if node_type not in features_by_type: + features_by_type[node_type] = [] + + features_by_type[node_type].append(feature) + + return features_by_type + + def filter_graph_as_list( + self, + graph: networkx.Graph, + ) -> list: + """ + Filters the features graph and returns it as a list. + + :param graph: Features graph + + :return: (list) Features list for a given cname + :since: 0.9.2 + """ + + return Parser.sort_reversed_graph_nodes(graph) + + def filter_graph_as_string( + self, + graph: networkx.Graph, + ) -> str: + """ + Filters the features graph and returns it as a string. + + :param graph: Features graph + + :return: (str) Comma separated string with the expanded feature set for the cname + :since: 0.9.2 + """ + + features = Parser.sort_reversed_graph_nodes(graph) return ",".join(features) - def _exclude_from_filter_set(graph, input_features, filter_set): + def filter_based_on_feature_set( + self, + feature_set: (str,), + ignore_excludes: bool = False, + additional_filter_func: Optional[Callable[(str,), bool]] = None, + ) -> networkx.Graph: + """ + Filters the features graph based on a feature set given. + + :param feature_set: Feature set to filter + :param ignore_excludes: Ignore `exclude` feature files + :param additional_filter_func: Additional filter function + + :return: (networkx.Graph) Filtered features graph + :since: 0.9.2 + """ + + filter_set = feature_set.copy() + + # @TODO: Remove "special" handling once "bare" is a first-class citizen of the feature graph + if "bare" in feature_set: + if not self.graph.has_node("bare"): + self.graph.add_node("bare", content=BARE_FLAVOR_FEATURE_CONTENT) + if not self.graph.has_node("libc"): + self.graph.add_node("libc", content=BARE_FLAVOR_LIBC_FEATURE_CONTENT) + + for feature in feature_set: + filter_set.update( + networkx.descendants( + Parser._get_graph_view_for_attr(self.graph, "include"), feature + ) + ) + + graph = networkx.subgraph_view( + self.graph, + filter_node=self._get_filter_set_callable( + filter_set, additional_filter_func + ), + ) + + if not ignore_excludes: + Parser._exclude_from_filter_set(graph, feature_set, filter_set) + + return graph + + def _exclude_from_filter_set(graph, feature_set, filter_set): """ - Removes the given `filter_set` out of `input_features`. + Removes the given `filter_set` out of `feature_set`. - :param input_features: Features + :param feature_set: Features :param filter_set: Set to filter out :since: 0.7.0 @@ -259,7 +325,7 @@ def _exclude_from_filter_set(graph, input_features, filter_set): exclude_list.append(exclude) for exclude in exclude_list: - if exclude in input_features: + if exclude in feature_set: raise ValueError( f"Excluding explicitly included feature {exclude}, unsatisfiable condition" ) diff --git a/tests/features/test_cname.py b/tests/features/test_cname.py index f286d205..615eede7 100644 --- a/tests/features/test_cname.py +++ b/tests/features/test_cname.py @@ -34,3 +34,15 @@ def test_cname_flavor(input_cname: str, expected_output: dict): """ cname = CName(input_cname) assert cname.flavor == expected_output + +def test_cname_commit_id_setter(): + """ + Tests cname setter for `commit_id` to verify a given ID before overwriting. + """ + + cname = CName("container", arch="amd64", version="today", commit_hash="local") + + # Act / Assert + with pytest.raises(RuntimeError, match="Commit hash given differs from commit ID already set"): + cname.commit_hash = "broken" + diff --git a/tests/features/test_main.py b/tests/features/test_main.py index 0184b582..353471b4 100644 --- a/tests/features/test_main.py +++ b/tests/features/test_main.py @@ -4,6 +4,7 @@ import pytest import gardenlinux.features.__main__ as fema +from gardenlinux.features import CName # ------------------------------- # Helper function tests @@ -121,7 +122,7 @@ def test_get_version_missing_file_raises(tmp_path): # ------------------------------- def test_main_prints_arch(monkeypatch, capsys): # Arrange - argv = ["prog", "--arch", "amd64", "--features", "f1", "--version", "1.0", "arch"] + argv = ["prog", "--arch", "amd64", "--cname", "flav", "--version", "1.0", "arch"] monkeypatch.setattr(sys, "argv", argv) monkeypatch.setattr(fema, "Parser", lambda *a, **kw: None) @@ -135,7 +136,7 @@ def test_main_prints_arch(monkeypatch, capsys): def test_main_prints_commit_id(monkeypatch, capsys): # Arrange - argv = ["prog", "--arch", "amd64", "--features", "f1", "commit_id"] + argv = ["prog", "--arch", "amd64", "--cname", "flav", "commit_id"] monkeypatch.setattr(sys, "argv", argv) monkeypatch.setattr( fema, @@ -160,27 +161,20 @@ def test_main_prints_flags_elements_platforms(monkeypatch, capsys): "prog", "--arch", "amd64", - "--features", - "flag1,element1,platform1", + "--cname", + "flav", "--version", "1.0", "flags", ] monkeypatch.setattr(sys, "argv", argv) - class FakeParser: + class FakeCName(CName): def __init__(self, *a, **k): - pass - - @staticmethod - def filter_as_dict(*a, **k): - return { - "flag": ["flag1"], - "element": ["element1"], - "platform": ["platform1"], - } + CName.__init__(self, *a, **k) + self._feature_flags_cached = ["flag1"] - monkeypatch.setattr(fema, "Parser", FakeParser) + monkeypatch.setattr(fema, "CName", FakeCName) # Act fema.main() @@ -192,7 +186,7 @@ def filter_as_dict(*a, **k): def test_main_prints_version(monkeypatch, capsys): # Arrange - argv = ["prog", "--arch", "amd64", "--features", "f1", "version"] + argv = ["prog", "--arch", "amd64", "--cname", "flav", "version"] monkeypatch.setattr(sys, "argv", argv) monkeypatch.setattr( fema, @@ -213,7 +207,7 @@ def test_main_prints_version(monkeypatch, capsys): def test_main_prints_version_and_commit_id(monkeypatch, capsys): # Arrange - argv = ["prog", "--arch", "amd64", "--features", "f1", "version_and_commit_id"] + argv = ["prog", "--arch", "amd64", "--cname", "flav", "version_and_commit_id"] monkeypatch.setattr(sys, "argv", argv) monkeypatch.setattr( fema, @@ -234,7 +228,7 @@ def test_main_prints_version_and_commit_id(monkeypatch, capsys): def test_main_arch_raises_missing_verison(monkeypatch, capsys): # Arrange - argv = ["prog", "--arch", "amd64", "--features", "f1", "arch"] + argv = ["prog", "--arch", "amd64", "--cname", "flav", "arch"] monkeypatch.setattr(sys, "argv", argv) monkeypatch.setattr(fema, "Parser", lambda *a, **kw: None) @@ -282,27 +276,26 @@ def sort_subset(subset, length): assert "flav" in captured.out -def test_main_requires_feature_or_cname(monkeypatch): +def test_main_requires_cname(monkeypatch): # Arrange monkeypatch.setattr(sys, "argv", ["prog", "arch"]) monkeypatch.setattr(fema, "Parser", lambda *a, **kw: None) # Act / Assert - with pytest.raises(AssertionError): + with pytest.raises(SystemExit): fema.main() def test_main_raises_no_arch_no_default(monkeypatch): # Arrange # args.type == 'cname, arch is None and no default_arch set - argv = ["prog", "--features", "f1", "cname"] + argv = ["prog", "--cname", "flav", "cname"] monkeypatch.setattr(sys, "argv", argv) monkeypatch.setattr( fema, "Parser", - lambda *a, **kw: types.SimpleNamesapce(filter=lambda *a, **k: None), + lambda *a, **kw: types.SimpleNamespace(filter=lambda *a, **k: None), ) - monkeypatch.setattr(fema, "CName", lambda *a, **kw: None) # Act / Assert with pytest.raises(RuntimeError, match="Architecture could not be determined"): diff --git a/tests/features/test_metadata_main.py b/tests/features/test_metadata_main.py new file mode 100644 index 00000000..26c0f568 --- /dev/null +++ b/tests/features/test_metadata_main.py @@ -0,0 +1,89 @@ +import logging +import sys +import types +from pathlib import Path +from tempfile import TemporaryDirectory + +import pytest + +import gardenlinux.features.metadata_main as metadata_main +from gardenlinux.constants import ( + GL_BUG_REPORT_URL, + GL_COMMIT_SPECIAL_VALUES, + GL_DISTRIBUTION_NAME, + GL_HOME_URL, + GL_RELEASE_ID, + GL_SUPPORT_URL, +) +from gardenlinux.features import CName + + +def get_container_amd64_release_metadata(version, commit_hash): + return f""" +ID={GL_RELEASE_ID} +NAME="{GL_DISTRIBUTION_NAME}" +PRETTY_NAME="{GL_DISTRIBUTION_NAME} today" +IMAGE_VERSION=today +VARIANT_ID="container-amd64" +HOME_URL="{GL_HOME_URL}" +SUPPORT_URL="{GL_SUPPORT_URL}" +BUG_REPORT_URL="{GL_BUG_REPORT_URL}" +GARDENLINUX_CNAME="container-amd64-today-local" +GARDENLINUX_FEATURES="_slim,base,container" +GARDENLINUX_FEATURES_PLATFORMS="container" +GARDENLINUX_FEATURES_ELEMENTS="base" +GARDENLINUX_FEATURES_FLAGS="_slim" +GARDENLINUX_VERSION="today" +GARDENLINUX_COMMIT_ID="local" +GARDENLINUX_COMMIT_ID_LONG="local" +""".strip() + +def test_main_output(monkeypatch, capsys): + """ + Test successful "output-release-metadata" + """ + # Arrange + argv = ["prog", "--cname", "container-amd64", "--version", "today", "--commit", "local", "output-release-metadata"] + monkeypatch.setattr(sys, "argv", argv) + + # Act + metadata_main.main() + + # Assert + expected = get_container_amd64_release_metadata("today", "local") + assert expected == capsys.readouterr().out.strip() + +def test_main_write(monkeypatch, capsys): + """ + Test successful "write" + """ + # Arrange + with TemporaryDirectory() as tmpdir: + os_release_file = Path(tmpdir, "os_release") + argv = ["prog", "--cname", "container-amd64", "--version", "today", "--commit", "local", "--release-file", str(os_release_file), "write"] + monkeypatch.setattr(sys, "argv", argv) + + # Act + metadata_main.main() + + # Assert + expected = get_container_amd64_release_metadata("today", "local") + assert expected == os_release_file.open("r").read() + +def test_main_validation(monkeypatch): + """ + Test validation between release metadata and arguments given + """ + # Arrange + with TemporaryDirectory() as tmpdir: + os_release_file = Path(tmpdir, "os_release") + + with os_release_file.open("w") as fp: + fp.write(get_container_amd64_release_metadata("today", "local")) + + argv = ["prog", "--cname", "base-python-amd64", "--version", "today", "--commit", "local", "--release-file", str(os_release_file), "output-release-metadata"] + monkeypatch.setattr(sys, "argv", argv) + + # Act / Assert + with pytest.raises(AssertionError): + metadata_main.main() diff --git a/tests/s3/conftest.py b/tests/s3/conftest.py index 401e61f3..b2a8e646 100644 --- a/tests/s3/conftest.py +++ b/tests/s3/conftest.py @@ -22,7 +22,7 @@ class S3Env: def make_cname( - flavor: str = "testcname", + flavor: str = "container", arch: str = "amd64", version: str = "1234.1", commit: str = "abc123",