Initial release: Helm chart version drift tools.

This commit is contained in:
Illusive Scarecrow 2026-06-09 15:14:32 +03:00
commit ff77a18c93
5 changed files with 383 additions and 0 deletions

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# Secrets and credentials
.env
.env.*
*.pem
*.key
*.p12
credentials.json
*-credentials*
*secret*
*.token
# Local tooling
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
# Editor / OS
.DS_Store
*.swp
*~

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 illusive-scarecrow
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

44
README.md Normal file
View file

@ -0,0 +1,44 @@
# helm-chart-version-tools
Find and compare Helm chart version drift across directory trees.
## Problem
Umbrella or multi-environment Helm layouts often duplicate `Chart.yaml` under path
variants (for example `charts/monitoring/alpha/` vs `charts/monitoring/beta/`).
Versions can drift silently. These scripts scan a tree and report mismatches.
## Requirements
- Python 3.10+
- No third-party packages
## Scripts
| Script | Purpose |
|--------|---------|
| `check-helm-chart-versions.py` | Report version drift within one directory tree |
| `compare-helm-chart-versions.py` | Compare versions between two directory trees |
Charts are grouped by parent path; the leaf directory name is treated as the
variant label. Use `--min-depth` if your layout is deeper than the default.
## Usage
```bash
./check-helm-chart-versions.py /path/to/tree
./compare-helm-chart-versions.py /path/to/tree-a /path/to/tree-b alpha beta
```
Run `--help` on either script for examples and options. Exit code is `1` when
differences are found, `0` when versions match.
## Limits
- Reads `dependencies[].version` or `appVersion` from `Chart.yaml` only.
- Does not validate semver or contact chart repositories.
- Path grouping is structural, not Helm-release aware.
## License
MIT — see [LICENSE](LICENSE).

135
check-helm-chart-versions.py Executable file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
Script Name: check-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 drift checker.
v1.0.0 - 2026-06-08: Initial release.
Example layout::
charts/
monitoring/
alpha/Chart.yaml # dependencies version: 1.2.0
beta/Chart.yaml # dependencies version: 1.3.0
Run::
./check-helm-chart-versions.py charts
Example output::
=== MISALIGNMENT FOUND: monitoring ===
alpha 1.2.0
beta 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:
"""
Map a Chart.yaml path to (group, target).
group: parent directories relative to root, excluding the leaf directory
target: leaf directory name immediately containing Chart.yaml
"""
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 main() -> int:
parser = argparse.ArgumentParser(
description="Find Helm chart version drift across directory variants.",
epilog=(
"example:\n"
" %(prog)s charts\n"
" %(prog)s charts --min-depth 3\n\n"
"Given charts/monitoring/alpha/Chart.yaml (1.2.0) and\n"
"charts/monitoring/beta/Chart.yaml (1.3.0), reports a mismatch\n"
"under group 'monitoring'."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"root",
nargs="?",
default=".",
type=Path,
help="Directory tree to scan (default: current directory)",
)
parser.add_argument(
"--min-depth",
type=int,
default=2,
metavar="N",
help="Minimum path depth below root for Chart.yaml (default: 2)",
)
args = parser.parse_args()
root = args.root.resolve()
if not root.is_dir():
print(f"Error: not a directory: {root}", file=sys.stderr)
return 1
app_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) < args.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
app_data[group][target] = version
misalignments_found = False
for group in sorted(app_data):
targets = app_data[group]
if len(set(targets.values())) > 1:
misalignments_found = True
print(f"=== MISALIGNMENT FOUND: {group} ===")
for target in sorted(targets):
print(f" {target:20s} {targets[target]}")
print()
if not misalignments_found:
print("No version misalignments found.")
return 1 if misalignments_found else 0
if __name__ == "__main__":
raise SystemExit(main())

161
compare-helm-chart-versions.py Executable file
View file

@ -0,0 +1,161 @@
#!/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())