Initial release: Helm chart version drift tools.
This commit is contained in:
commit
ff77a18c93
5 changed files with 383 additions and 0 deletions
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
44
README.md
Normal 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
135
check-helm-chart-versions.py
Executable 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
161
compare-helm-chart-versions.py
Executable 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())
|
||||||
Loading…
Add table
Reference in a new issue