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