From 27024d422b52789344390c4b25c34a746c28e5b0 Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Sun, 25 May 2025 22:11:48 +0200 Subject: [PATCH 1/3] Move `CName` parser to a separate class Signed-off-by: Tobias Wolf --- src/gardenlinux/features/__init__.py | 3 +- src/gardenlinux/features/__main__.py | 79 ++++++++++---------------- src/gardenlinux/features/cname.py | 76 +++++++++++++++++++++++++ src/gardenlinux/features/cname_main.py | 49 ++++++---------- src/gardenlinux/features/parser.py | 51 ----------------- src/gardenlinux/oci/registry.py | 11 ++-- 6 files changed, 131 insertions(+), 138 deletions(-) create mode 100644 src/gardenlinux/features/cname.py diff --git a/src/gardenlinux/features/__init__.py b/src/gardenlinux/features/__init__.py index bff333bd..a04d4bdd 100644 --- a/src/gardenlinux/features/__init__.py +++ b/src/gardenlinux/features/__init__.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- +from .cname import CName from .parser import Parser -__all__ = ["Parser"] +__all__ = ["CName", "Parser"] diff --git a/src/gardenlinux/features/__main__.py b/src/gardenlinux/features/__main__.py index 4d3bde56..839e48cd 100644 --- a/src/gardenlinux/features/__main__.py +++ b/src/gardenlinux/features/__main__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +from .cname import CName from .parser import Parser from functools import reduce @@ -54,61 +55,43 @@ def main(): ), "Please provide either `--features` or `--cname` argument" arch = None - cname_base = None + flavor = None commit_id = None gardenlinux_root = path.dirname(args.feature_dir) version = None - if args.cname: - re_match = re.match( - "([a-zA-Z0-9]+([\\_\\-][a-zA-Z0-9]+)*?)(-([a-z0-9]+)(-([a-z0-9.]+)-([a-z0-9]+))*)?$", - args.cname, - ) + if args.arch is not None: + arch = args.arch - assert re_match, f"Not a valid GardenLinux canonical name {args.cname}" + if args.version is not None: + version = args.version - if re_match.lastindex == 1: - data_splitted = re_match[1].split("-", 1) + if arch is None or arch == "": + arch = args.default_arch + + if version is None or version == "": + version_data = get_version_and_commit_id_from_files(gardenlinux_root) + version = f"{version_data[0]}-{version_data[1]}" - cname_base = data_splitted[0] + if args.cname: + cname = CName(args.cname, arch=arch, version=version) - if len(data_splitted) > 1: - if args.arch is None: - arch = data_splitted[1] - else: - cname_base += "-" + data_splitted[1] - else: - arch = re_match[4] - cname_base = re_match[1] - commit_id = re_match[7] - version = re_match[6] + arch = cname.arch + flavor = cname.flavor + commit_id = cname.commit_id + version = cname.version - input_features = Parser.get_cname_as_feature_set(cname_base) + input_features = Parser.get_cname_as_feature_set(flavor) else: input_features = args.features - if args.arch is not None: - arch = args.arch - - if args.version is not None: - re_match = re.match("([a-z0-9.]+)(-([a-z0-9]+))?$", args.version) - assert re_match, f"Not a valid version {args.version}" - - commit_id = re_match[3] - version = re_match[1] - if arch is None or arch == "" and (args.type in ("cname", "arch")): - assert ( - args.default_arch - ), "Architecture could not be determined and no default architecture set" - arch = args.default_arch - - if not commit_id or not version: - version, commit_id = get_version_and_commit_id_from_files(gardenlinux_root) + raise RuntimeError( + "Architecture could not be determined and no default architecture set" + ) - if not version and (args.type in ("cname", "version")): - assert args.default_version, "version not specified and no default version set" - version = args.default_version + if version is None or version == "" and (args.type in ("cname", "version")): + raise RuntimeError("Version not specified and no default version set") feature_dir_name = path.basename(args.feature_dir) @@ -124,7 +107,7 @@ def main(): print(arch) elif args.type in ("cname_base", "cname", "graph"): graph = Parser(gardenlinux_root, feature_dir_name).filter( - cname_base, additional_filter_func=additional_filter_func + flavor, additional_filter_func=additional_filter_func ) sorted_features = Parser.sort_graph_nodes(graph) @@ -137,7 +120,7 @@ def main(): if args.type == "cname_base": print(cname_base) elif args.type == "cname": - cname = cname_base + cname = flavor if arch is not None: cname += f"-{arch}" @@ -147,16 +130,16 @@ def main(): print(cname) elif args.type == "graph": - print(graph_as_mermaid_markup(cname_base, graph)) + print(graph_as_mermaid_markup(flavor, graph)) elif args.type == "features": print( Parser(gardenlinux_root, feature_dir_name).filter_as_string( - cname_base, additional_filter_func=additional_filter_func + flavor, additional_filter_func=additional_filter_func ) ) elif args.type in ("flags", "elements", "platforms"): features_by_type = Parser(gardenlinux_root, feature_dir_name).filter_as_dict( - cname_base, additional_filter_func=additional_filter_func + flavor, additional_filter_func=additional_filter_func ) if args.type == "platforms": @@ -194,7 +177,7 @@ def get_minimal_feature_set(graph): return set([node for (node, degree) in graph.in_degree() if degree == 0]) -def graph_as_mermaid_markup(cname_base, graph): +def graph_as_mermaid_markup(flavor, graph): """ Generates a mermaid.js representation of the graph. This is helpful to identify dependencies between features. @@ -202,7 +185,7 @@ def graph_as_mermaid_markup(cname_base, graph): Syntax docs: https://mermaid.js.org/syntax/flowchart.html?id=flowcharts-basic-syntax """ - markup = f"---\ntitle: Dependency Graph for Feature {cname_base}\n---\ngraph TD;\n" + markup = f"---\ntitle: Dependency Graph for Feature {flavor}\n---\ngraph TD;\n" for u, v in graph.edges: markup += f" {u}-->{v};\n" return markup diff --git a/src/gardenlinux/features/cname.py b/src/gardenlinux/features/cname.py new file mode 100644 index 00000000..1d14866a --- /dev/null +++ b/src/gardenlinux/features/cname.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +from typing import Optional +import re + + +class CName(object): + def __init__(self, cname, arch=None, version=None): + self._arch = None + self._flavor = None + self._commit_id = None + self._version = None + + re_match = re.match( + "([a-zA-Z0-9]+([\\_\\-][a-zA-Z0-9]+)*?)(-([a-z0-9]+)(-([a-z0-9.]+)-([a-z0-9]+))*)?$", + cname, + ) + + assert re_match, f"Not a valid GardenLinux canonical name {cname}" + + if re_match.lastindex == 1: + self._flavor = re_match[1] + else: + self._commit_id = re_match[7] + self._flavor = re_match[1] + self._version = re_match[6] + + if re_match[2] is None: + self._flavor += re_match[3] + else: + self._arch = re_match[4] + + if self._arch is None and arch is not None: + self._arch = arch + + if self._version is None and version is not 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] + self._version = re_match[1] + + @property + def arch(self) -> Optional[str]: + return self._arch + + @property + def cname(self) -> str: + cname = self._flavor + + if self._arch is not None: + cname += f"-{self._arch}" + + if self._commit_id is not None: + cname += f"-{self.version_and_commit_id}" + + return cname + + @property + def commit_id(self) -> Optional[str]: + return self._commit_id + + @property + def flavor(self) -> str: + return self._flavor + + @property + def version(self) -> Optional[str]: + return self._version + + @property + def version_and_commit_id(self) -> Optional[str]: + if self._commit_id is None: + return None + + return f"{self._version}-{self._commit_id}" diff --git a/src/gardenlinux/features/cname_main.py b/src/gardenlinux/features/cname_main.py index 9f05fd23..6f80f641 100644 --- a/src/gardenlinux/features/cname_main.py +++ b/src/gardenlinux/features/cname_main.py @@ -12,6 +12,7 @@ get_version_and_commit_id_from_files, sort_subset, ) +from .cname import CName from .parser import Parser @@ -33,62 +34,44 @@ def main(): assert re_match, f"Not a valid GardenLinux canonical name {args.cname}" arch = None - commit_id = None gardenlinux_root = dirname(args.feature_dir) - version = None - - if re_match.lastindex == 1: - data_splitted = re_match[1].split("-", 1) - - cname_base = data_splitted[0] - - if len(data_splitted) > 1: - if args.arch is None: - arch = data_splitted[1] - else: - cname_base += "-" + data_splitted[1] - else: - arch = re_match[4] - cname_base = re_match[1] - commit_id = re_match[7] - version = re_match[6] + version = args.version if args.arch is not None: arch = args.arch - assert arch is not None and arch != "", "Architecture could not be determined" + if args.version is not None: + version = args.version - if not commit_id or not version: - version, commit_id = get_version_and_commit_id_from_files(gardenlinux_root) + if not version: + version_data = get_version_and_commit_id_from_files(gardenlinux_root) + version = f"{version_data[0]}-{version_data[1]}" - if args.version is not None: - re_match = re.match("([a-z0-9.]+)(-([a-z0-9]+))?$", args.version) - assert re_match, f"Not a valid version {args.version}" + cname = CName(args.cname, arch=arch, version=version) - commit_id = re_match[3] - version = re_match[1] + assert cname.arch, "Architecture could not be determined" feature_dir_name = basename(args.feature_dir) if gardenlinux_root == "": gardenlinux_root = "." - graph = Parser(gardenlinux_root, feature_dir_name).filter(cname_base) + graph = Parser(gardenlinux_root, feature_dir_name).filter(cname.flavor) 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 = get_cname_base(sorted_minimal_features) + generated_cname = get_cname_base(sorted_minimal_features) - if arch is not None: - cname += f"-{arch}" + if cname.arch is not None: + generated_cname += f"-{cname.arch}" - if commit_id is not None: - cname += f"-{version}-{commit_id}" + if cname.version_and_commit_id is not None: + generated_cname += f"-{cname.version_and_commit_id}" - print(cname) + print(generated_cname) if __name__ == "__main__": diff --git a/src/gardenlinux/features/parser.py b/src/gardenlinux/features/parser.py index a49d5a53..4b5cdb37 100644 --- a/src/gardenlinux/features/parser.py +++ b/src/gardenlinux/features/parser.py @@ -221,57 +221,6 @@ def get_cname_as_feature_set(cname): cname = cname.replace("_", "-_") return set(cname.split("-")) - @staticmethod - def get_flavor_from_cname(cname: str, get_arch: bool = True) -> str: - """ - Extracts the flavor from a canonical name. - - This method parses a Garden Linux canonical name (cname) and extracts - the flavor component, with or without the architecture suffix. - - Example canonical names: - - "aws-gardener_prod-amd64" - - "azure-gardener_prod_tpm2_trustedboot-amd64-1312.2-80ffcc87" - - The flavor is the platform plus feature string (e.g., "aws-gardener_prod") - - Args: - cname (str): Canonical name of an image - get_arch (bool): Whether to include the architecture in the returned flavor - If True: returns "aws-gardener_prod-amd64" - If False: returns "aws-gardener_prod" - - Returns: - str: The extracted flavor string, with or without architecture - """ - # Use regex to extract components from the canonical name - # This handles complex cnames with version and commit hash - re_match = re.match( - "([a-zA-Z0-9]+([\\_\\-][a-zA-Z0-9]+)*?)(-([a-z0-9]+)(-([a-z0-9.]+)-([a-z0-9]+))*)?$", - cname, - ) - - assert re_match, f"Not a valid GardenLinux canonical name {cname}" - - if re_match.lastindex == 1: - data_splitted = re_match[1].split("-", 1) - - flavor = data_splitted[0] - - if len(data_splitted) > 1: - if get_arch is True: - arch = data_splitted[1] - else: - flavor += "-" + data_splitted[1] - else: - arch = re_match[4] - flavor = re_match[1] - # Add architecture if requested - if get_arch and arch: - return f"{flavor}-{arch}" - else: - return flavor - @staticmethod def _get_filter_set_callable(filter_set, additional_filter_func): def filter_func(node): diff --git a/src/gardenlinux/oci/registry.py b/src/gardenlinux/oci/registry.py index c6c3430c..ead7f777 100644 --- a/src/gardenlinux/oci/registry.py +++ b/src/gardenlinux/oci/registry.py @@ -28,8 +28,9 @@ from oras.provider import Registry from oras.schemas import manifest as oras_manifest_schema -from gardenlinux.features import Parser from ..constants import OCI_ANNOTATION_SIGNATURE_KEY, OCI_ANNOTATION_SIGNED_STRING_KEY +from ..features import CName + from .checksum import ( calculate_sha256, verify_sha256, @@ -538,13 +539,15 @@ def push_image_manifest( if cleanup_blob and os.path.exists(file_path): os.remove(file_path) # This ends up in the manifest - flavor = Parser.get_flavor_from_cname(cname, get_arch=True) + parsed_cname = CName(cname, arch=architecture) + architecture = parsed_cname.arch + flavor = parsed_cname.flavor manifest_image["annotations"] = {} manifest_image["annotations"]["version"] = version manifest_image["annotations"]["cname"] = cname manifest_image["annotations"]["architecture"] = architecture manifest_image["annotations"]["feature_set"] = feature_set - manifest_image["annotations"]["flavor"] = flavor + manifest_image["annotations"]["flavor"] = f"${flavor}-${architecture}" manifest_image["annotations"]["commit"] = commit description = ( f"Image: {cname} " @@ -685,8 +688,6 @@ def push_from_dir( break file.close() - flavor = Parser.get_flavor_from_cname(cname, get_arch=True) - digest = self.push_image_manifest( architecture, cname, From a56640bb953839b74084fed18d5b76fef9e27a48 Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Mon, 26 May 2025 09:48:48 +0200 Subject: [PATCH 2/3] Move "features" related test to a sub-package Signed-off-by: Tobias Wolf --- tests/features/__init__.py | 0 .../test_features_parser.py} | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 tests/features/__init__.py rename tests/{test_get_features_dict.py => features/test_features_parser.py} (87%) diff --git a/tests/features/__init__.py b/tests/features/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_get_features_dict.py b/tests/features/test_features_parser.py similarity index 87% rename from tests/test_get_features_dict.py rename to tests/features/test_features_parser.py index 0649f8ed..dcabffd1 100644 --- a/tests/test_get_features_dict.py +++ b/tests/features/test_features_parser.py @@ -1,7 +1,7 @@ import pytest from gardenlinux.features import Parser -from .constants import GL_ROOT_DIR +from ..constants import GL_ROOT_DIR @pytest.mark.parametrize( @@ -58,13 +58,13 @@ ), ], ) -def test_get_features_dict(input_cname: str, expected_output: dict): +def test_parser_filter_as_dict(input_cname: str, expected_output: dict): """ - Tests if get_features_dict returns the dict with expected features. + Tests if parser_filter_as_dict returns the dict with expected features. If you discover that this test failed, you may want to verify if the included features have changed since writing this test. In this case, update the expected output accordingly. - You can print the output of get_features_dict so you have the dict in the expected format. + You can print the output of parser_filter_as_dict so you have the dict in the expected format. """ features_dict = Parser(GL_ROOT_DIR).filter_as_dict(input_cname) assert features_dict == expected_output From bcabb91fef23ee5330fff030f9dca2711d2fe3ad Mon Sep 17 00:00:00 2001 From: Tobias Wolf Date: Mon, 26 May 2025 09:49:16 +0200 Subject: [PATCH 3/3] Add test for `gardenlinux.features.CName` Signed-off-by: Tobias Wolf --- tests/features/test_cname.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/features/test_cname.py diff --git a/tests/features/test_cname.py b/tests/features/test_cname.py new file mode 100644 index 00000000..c77071f1 --- /dev/null +++ b/tests/features/test_cname.py @@ -0,0 +1,32 @@ +import pytest + +from gardenlinux.features import CName + + +@pytest.mark.parametrize( + "input_cname, expected_output", + [ + ( + "aws-gardener_prod", + "aws-gardener_prod", + ), + ( + "metal-khost_dev", + "metal-khost_dev", + ), + ( + "metal_pxe", + "metal_pxe", + ), + ], +) +def test_cname_flavor(input_cname: str, expected_output: dict): + """ + Tests if cname returns the dict with expected features. + + If you discover that this test failed, you may want to verify if the included + features have changed since writing this test. In this case, update the expected output accordingly. + You can print the output of cname so you have the dict in the expected format. + """ + cname = CName(input_cname) + assert cname.flavor == expected_output