helm-chart-version-tools/compare-helm-chart-versions.py

161 lines
5.1 KiB
Python
Executable file

#!/usr/bin/env python3
"""
Script Name: compare-helm-chart-versions.py
Description:
v1.2.0 - 2026-06-09: Added usage example in docstring and --help.
v1.1.0 - 2026-06-09: Generic Helm chart version comparison between trees.
v1.0.0 - 2026-06-08: Initial release.
Example layout::
tree-a/charts/monitoring/alpha/Chart.yaml # version: 1.2.0
tree-b/charts/monitoring/alpha/Chart.yaml # version: 1.3.0
Run::
./compare-helm-chart-versions.py tree-a tree-b alpha
Example output::
=== DIFFERENCE: charts/monitoring [alpha] ===
tree-a 1.2.0
tree-b 1.3.0
"""
from __future__ import annotations
import argparse
import re
import sys
from collections import defaultdict
from pathlib import Path
_DEPENDENCY_RE = re.compile(
r"dependencies:\s*\n(?:[^\n]*\n)*?\s*-\s*name:[^\n]*\n(?:[^\n]*\n)*?\s*version:\s*([^\n]+)",
)
_APP_VERSION_RE = re.compile(r"appVersion:\s*([^\n]+)")
def extract_version(chart_file: Path) -> str | None:
content = chart_file.read_text(encoding="utf-8")
match = _DEPENDENCY_RE.search(content)
if match:
return match.group(1).strip().strip("\"'")
match = _APP_VERSION_RE.search(content)
if match:
return match.group(1).strip().strip("\"'")
return None
def parse_chart_location(chart_file: Path, root: Path) -> tuple[str, str] | None:
try:
rel_parent = chart_file.parent.relative_to(root)
except ValueError:
return None
if not rel_parent.parts:
return None
target = rel_parent.parts[-1]
group = "/".join(rel_parent.parts[:-1]) if len(rel_parent.parts) > 1 else "."
return group, target
def find_chart_files(root: Path) -> list[Path]:
return sorted(root.rglob("Chart.yaml"))
def collect_versions(
root: Path,
min_depth: int,
targets: set[str] | None,
) -> dict[str, dict[str, str]]:
data: dict[str, dict[str, str]] = defaultdict(dict)
for chart_file in find_chart_files(root):
if len(chart_file.parent.relative_to(root).parts) < min_depth:
continue
location = parse_chart_location(chart_file, root)
version = extract_version(chart_file)
if not location or not version:
continue
group, target = location
if targets and target not in targets:
continue
data[group][target] = version
return data
def main() -> int:
parser = argparse.ArgumentParser(
description="Compare Helm chart versions between two directory trees.",
epilog=(
"example:\n"
" %(prog)s tree-a tree-b alpha\n"
" %(prog)s tree-a tree-b --label-a release --label-b main alpha beta\n\n"
"Compares matching Chart.yaml files grouped by path. With the layout above,\n"
"reports version 1.2.0 in tree-a vs 1.3.0 in tree-b for target 'alpha'."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("tree_a", type=Path, help="First directory tree")
parser.add_argument("tree_b", type=Path, help="Second directory tree")
parser.add_argument(
"targets",
nargs="*",
help="Optional leaf directory names to compare (default: all found)",
)
parser.add_argument(
"--min-depth",
type=int,
default=2,
metavar="N",
help="Minimum path depth below root for Chart.yaml (default: 2)",
)
parser.add_argument(
"--label-a",
default=None,
help="Display label for the first tree (default: directory name)",
)
parser.add_argument(
"--label-b",
default=None,
help="Display label for the second tree (default: directory name)",
)
args = parser.parse_args()
for label, path in (("tree_a", args.tree_a), ("tree_b", args.tree_b)):
if not path.is_dir():
print(f"Error: {label} is not a directory: {path}", file=sys.stderr)
return 1
tree_a = args.tree_a.resolve()
tree_b = args.tree_b.resolve()
label_a = args.label_a or tree_a.name
label_b = args.label_b or tree_b.name
target_filter = set(args.targets) if args.targets else None
versions_a = collect_versions(tree_a, args.min_depth, target_filter)
versions_b = collect_versions(tree_b, args.min_depth, target_filter)
all_groups = sorted(set(versions_a) | set(versions_b))
differences_found = False
for group in all_groups:
targets_a = versions_a.get(group, {})
targets_b = versions_b.get(group, {})
all_targets = sorted(set(targets_a) | set(targets_b))
for target in all_targets:
va = targets_a.get(target)
vb = targets_b.get(target)
if va != vb:
differences_found = True
print(f"=== DIFFERENCE: {group} [{target}] ===")
print(f" {label_a:20s} {va or '(missing)'}")
print(f" {label_b:20s} {vb or '(missing)'}")
print()
if not differences_found:
print("No chart version differences found for the selected trees.")
return 1 if differences_found else 0
if __name__ == "__main__":
raise SystemExit(main())