diff --git a/pyproject.toml b/pyproject.toml index 5588c490..6839d2e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python_gardenlinux_lib" -version = "0.5.0" +version = "0.6.0" description = "Contains tools to work with the features directory of gardenlinux, for example deducting dependencies from feature sets or validating cnames" authors = ["Garden Linux Maintainers "] license = "Apache-2.0" diff --git a/src/python_gardenlinux_lib/__init__.py b/src/python_gardenlinux_lib/__init__.py index e69de29b..fc1d800f 100644 --- a/src/python_gardenlinux_lib/__init__.py +++ b/src/python_gardenlinux_lib/__init__.py @@ -0,0 +1,4 @@ +from .git import Git +from .version import Version + +__all__ = ["Git", "Version"] diff --git a/src/python_gardenlinux_lib/features/parse_features.py b/src/python_gardenlinux_lib/features/parse_features.py index 5c6239cc..e42234c7 100644 --- a/src/python_gardenlinux_lib/features/parse_features.py +++ b/src/python_gardenlinux_lib/features/parse_features.py @@ -140,6 +140,21 @@ def get_features_dict(cname: str, gardenlinux_root: str) -> dict: return features_by_type +def get_features_list(cname: str, gardenlinux_root: str) -> list: + """ + :param str cname: the target cname to get the feature dict for + :param str gardenlinux_root: path of garden linux src root + :return: list of features for a given cname + + """ + feature_base_dir = f"{gardenlinux_root}/features" + input_features = __reverse_cname_base(cname) + feature_graph = read_feature_files(feature_base_dir) + graph = filter_graph(feature_graph, input_features) + features = __reverse_sort_nodes(graph) + return features + + def get_features(cname: str, gardenlinux_root: str) -> str: """ :param str cname: the target cname to get the feature set for diff --git a/src/python_gardenlinux_lib/git/__init__.py b/src/python_gardenlinux_lib/git/__init__.py new file mode 100644 index 00000000..9dea2260 --- /dev/null +++ b/src/python_gardenlinux_lib/git/__init__.py @@ -0,0 +1,3 @@ +from .git import Git + +__all__ = ["Git"] diff --git a/src/python_gardenlinux_lib/git/git.py b/src/python_gardenlinux_lib/git/git.py new file mode 100755 index 00000000..ef1d77cb --- /dev/null +++ b/src/python_gardenlinux_lib/git/git.py @@ -0,0 +1,32 @@ +import subprocess +from pathlib import Path +import sys + +from ..logger import LoggerSetup + + +class Git: + """Git operations handler.""" + + def __init__(self, logger=None): + """Initialize Git handler. + + Args: + logger: Optional logger instance + """ + self.log = logger or LoggerSetup.get_logger("gardenlinux.git") + + def get_root(self): + """Get the root directory of the current Git repository.""" + try: + root_dir = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], text=True + ).strip() + self.log.debug(f"Git root directory: {root_dir}") + return Path(root_dir) + except subprocess.CalledProcessError as e: + self.log.error( + "Not a git repository or unable to determine root directory." + ) + self.log.debug(f"Git command failed with: {e}") + sys.exit(1) diff --git a/src/python_gardenlinux_lib/logger.py b/src/python_gardenlinux_lib/logger.py new file mode 100644 index 00000000..0a57d2cb --- /dev/null +++ b/src/python_gardenlinux_lib/logger.py @@ -0,0 +1,33 @@ +import logging + + +class LoggerSetup: + """Handles logging configuration for the gardenlinux library.""" + + @staticmethod + def get_logger(name, level=None): + """Create and configure a logger. + + Args: + name: Name for the logger, typically in format 'gardenlinux.module' + level: Logging level, defaults to INFO if not specified + + Returns: + Configured logger instance + """ + logger = logging.getLogger(name) + + # Only add handler if none exists to prevent duplicate handlers + if not logger.handlers: + handler = logging.StreamHandler() + formatter = logging.Formatter("%(levelname)s: %(message)s") + handler.setFormatter(formatter) + logger.addHandler(handler) + + # Set default level if specified + if level is not None: + logger.setLevel(level) + else: + logger.setLevel(logging.INFO) + + return logger diff --git a/src/python_gardenlinux_lib/version.py b/src/python_gardenlinux_lib/version.py new file mode 100644 index 00000000..a789ebb9 --- /dev/null +++ b/src/python_gardenlinux_lib/version.py @@ -0,0 +1,169 @@ +import re +import subprocess +from datetime import datetime, timezone +import requests +from pathlib import Path + +from .logger import LoggerSetup +from .features.parse_features import get_features + + +class Version: + """Handles version-related operations for Garden Linux.""" + + def __init__(self, git_root: Path, logger=None): + """Initialize Version handler. + + Args: + git_root: Path to the Git repository root + logger: Optional logger instance + """ + self.git_root = git_root + self.log = logger or LoggerSetup.get_logger("gardenlinux.version") + self.start_date = "Mar 31 00:00:00 UTC 2020" + + def get_minor_from_repo(self, major): + """Check repo.gardenlinux.io for highest available suite minor for given major. + + Args: + major: major version + Returns: + minor version + """ + minor = 0 + limit = 100 # Hard limit the search + repo_url = f"https://repo.gardenlinux.io/gardenlinux/dists/{major}.{{}}/Release" + + while minor <= limit: + try: + check_url = repo_url.format(minor) + response = requests.get(check_url) + if response.status_code != 200: + # No more versions found, return last successful minor + return minor - 1 + minor += 1 + except requests.RequestException as e: + self.log.debug(f"Error checking repo URL {check_url}: {e}") + return minor - 1 + + # If we hit the limit, return the last minor + return minor - 1 + + def get_version(self): + """Get version using same logic as garden-version bash script. + + Args: + version: version string + Returns: + version string + """ + + try: + # Check VERSION file + version_file = self.git_root / "VERSION" + if version_file.exists(): + version = version_file.read_text().strip() + # Remove comments and empty lines + version = re.sub(r"#.*$", "", version, flags=re.MULTILINE) + version = "\n".join( + line for line in version.splitlines() if line.strip() + ) + version = version.strip() + else: + version = "today" + + if not version: + version = "today" + + # Handle numeric versions (e.g., "27.1") + if re.match(r"^[0-9\.]*$", version): + major = version.split(".")[0] + if int(major) < 10000000: # Sanity check for major version + if "." in version: + return version # Return full version if minor is specified + else: + # Get latest minor version from repo + minor = self.get_minor_from_repo(major) + return f"{major}.{minor}" + + # Handle 'today' or 'experimental' + if version in ["today", "experimental"]: + # Calculate days since start date + start_timestamp = datetime.strptime( + self.start_date, "%b %d %H:%M:%S %Z %Y" + ).timestamp() + today_timestamp = datetime.now(timezone.utc).timestamp() + major = int((today_timestamp - start_timestamp) / (24 * 60 * 60)) + return version + + # Handle date input + try: + # Try to parse as date + input_date = datetime.strptime(version, "%Y%m%d") + start_date = datetime.strptime(self.start_date, "%b %d %H:%M:%S %Z %Y") + days_diff = (input_date - start_date).days + return f"{days_diff}.0" + except ValueError: + pass + + return version + + except Exception as e: + self.log.error(f"Error determining version: {e}") + return "local" + + def get_short_commit(self): + """Get short commit using same logic as the get_commit bash script. + + Returns: + short commit string + """ + try: + # Check if COMMIT file exists in git root + commit_file = self.git_root / "COMMIT" + if commit_file.exists(): + return commit_file.read_text().strip() + + # Check if git repo is clean + status_output = ( + subprocess.check_output( + ["git", "status", "--porcelain"], stderr=subprocess.DEVNULL + ) + .decode() + .strip() + ) + + if status_output: + self.log.info(f"git status:\n {status_output}") + # Dirty repo or not a git repo + return "local" + else: + # Clean repo - use git commit hash + return ( + subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], + stderr=subprocess.DEVNULL, + ) + .decode() + .strip() + ) + + except subprocess.CalledProcessError: + return "local" + + def get_cname(self, platform, features, arch): + """Get canonical name (cname) for Garden Linux image. + + Args: + platform: Platform identifier (e.g., 'kvm', 'aws') + features: List of features + arch: Architecture ('amd64' or 'arm64') + + Returns: + Generated cname string + """ + # Get version and commit + version = self.get_version() + commit = self.get_short_commit() + + return f"{platform}-{features}-{arch}-{version}-{commit}"