From fa2135dd7b3cc3436d354a47d82002608524bcb6 Mon Sep 17 00:00:00 2001 From: Michael Hotan Date: Thu, 23 Apr 2026 06:28:56 +1000 Subject: [PATCH] Add render-and-diff scripts for comparing chart changes between versions Renders helm templates at two git refs using temporary worktrees and structurally diffs the ConfigMap output by full key path. Supports arbitrary --values layering to mirror ArgoCD exactly. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 28 +++++ scripts/compare-manifests.py | 224 +++++++++++++++++++++++++++++++++++ scripts/render-and-diff.sh | 177 +++++++++++++++++++++++++++ 3 files changed, 429 insertions(+) create mode 100755 scripts/compare-manifests.py create mode 100755 scripts/render-and-diff.sh diff --git a/README.md b/README.md index 62c50502..9fbacf1d 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,31 @@ uctl get cluster ----------- ------- --------------- ----------- 1 rows ``` + +## Debugging Chart Changes + +Use `scripts/render-and-diff.sh` to render Helm templates at two git refs and structurally compare the output. This mirrors ArgoCD's exact values layering so you can verify what will change before deploying. + +```bash +# Compare a release tag against main (uses tests/values/controlplane.aws.yaml by default) +./scripts/render-and-diff.sh controlplane-2026.4.7 main + +# With your environment's terraform-generated values +./scripts/render-and-diff.sh controlplane-2026.4.7 main \ + --values /path/to/control-plane/values.yaml \ + --values /path/to/control-plane/values-union.yaml \ + --values /path/to/gitops/values.yaml + +# Compare dataplane chart +./scripts/render-and-diff.sh dataplane-2026.4.7 main --chart dataplane + +# Full text diff instead of structural +./scripts/render-and-diff.sh controlplane-2026.4.7 main --text + +# Diff all resource types, not just ConfigMaps +./scripts/render-and-diff.sh controlplane-2026.4.7 main --all +``` + +The structural diff (`scripts/compare-manifests.py`) parses multi-document YAML, matches resources by `(kind, name)`, and deep-diffs ConfigMap data reporting full key paths — so you see exactly which config values changed rather than sifting through whitespace and annotation noise. + +Requires: `helm`, `python3`, `PyYAML` (`pip install pyyaml`). diff --git a/scripts/compare-manifests.py b/scripts/compare-manifests.py new file mode 100755 index 00000000..86d5df9d --- /dev/null +++ b/scripts/compare-manifests.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Structurally compare two Helm-rendered manifest files. + +Parses both files as multi-document YAML, matches resources by (kind, name), +and deep-compares the parsed config data inside ConfigMaps. Reports differences +by full key path so nesting errors are immediately visible. + +For non-ConfigMap resources, reports added/removed resources. + +Usage: + python3 scripts/compare-manifests.py old.yaml new.yaml + python3 scripts/compare-manifests.py --all old.yaml new.yaml # diff all resources, not just ConfigMaps + +Examples: + # Compare a release tag against main + ./scripts/render-and-diff.sh controlplane-2026.4.7 main --values tests/values/controlplane.aws.yaml + + # Or render manually and compare + helm template charts/controlplane ... > /tmp/old.yaml + git checkout main + helm template charts/controlplane ... > /tmp/new.yaml + python3 scripts/compare-manifests.py /tmp/old.yaml /tmp/new.yaml +""" +import argparse +import sys + +import yaml + + +def parse_manifests(path): + """Parse multi-doc YAML into dict keyed by (kind, name).""" + manifests = {} + with open(path) as f: + for doc in yaml.safe_load_all(f): + if not doc or not isinstance(doc, dict): + continue + kind = doc.get("kind", "?") + name = doc.get("metadata", {}).get("name", "?") + manifests[(kind, name)] = doc + return manifests + + +def deep_diff(old, new, path=""): + """Recursively diff two structures, yielding (path, old_val, new_val).""" + if type(old) != type(new): + yield (path or "", old, new) + return + + if isinstance(old, dict): + all_keys = set(old.keys()) | set(new.keys()) + for key in sorted(all_keys): + child_path = f"{path}.{key}" if path else key + if key not in new: + yield (child_path, old[key], "") + elif key not in old: + yield (child_path, "", new[key]) + else: + yield from deep_diff(old[key], new[key], child_path) + elif isinstance(old, list): + if old != new: + yield (path or "", old, new) + else: + if old != new: + yield (path or "", old, new) + + +def parse_configmap_data(configmap): + """Parse embedded YAML strings in a ConfigMap's data field.""" + data = configmap.get("data", {}) + parsed = {} + for key, value in data.items(): + if isinstance(value, str): + try: + parsed[key] = yaml.safe_load(value) + except yaml.YAMLError: + parsed[key] = value + else: + parsed[key] = value + return parsed + + +# Paths that change on every render and should be ignored +NOISE_PATHS = {"configChecksum", "labels", "annotations", "helm.sh/chart"} + +# ConfigMaps containing Grafana dashboard JSON — diff as added/removed only +DASHBOARD_PREFIXES = ("dashboard-", "controlplane-dashboard-") + + +def is_noise(path): + return any(n in path for n in NOISE_PATHS) + + +def is_dashboard_configmap(name): + return any(name.startswith(p) or name.endswith("-dashboard") for p in DASHBOARD_PREFIXES) + + +def format_val(val): + if isinstance(val, (dict, list)): + return yaml.dump(val, default_flow_style=True).strip() + return repr(val) + + +def diff_configmaps(old_manifests, new_manifests): + """Structurally diff ConfigMaps. Returns number of real differences.""" + configmap_keys = sorted(set( + k for k in (set(old_manifests) | set(new_manifests)) if k[0] == "ConfigMap" + )) + + total = 0 + for key in configmap_keys: + kind, name = key + old_cm = old_manifests.get(key) + new_cm = new_manifests.get(key) + + if old_cm is None: + print(f"\n + ConfigMap/{name}: NEW") + total += 1 + continue + if new_cm is None: + print(f"\n - ConfigMap/{name}: REMOVED") + total += 1 + continue + + # Dashboard ConfigMaps contain huge JSON blobs — just flag changed, don't deep-diff + if is_dashboard_configmap(name): + old_data_raw = old_cm.get("data", {}) + new_data_raw = new_cm.get("data", {}) + if old_data_raw != new_data_raw: + print(f"\n ConfigMap/{name}: CHANGED (dashboard — use --text for full diff)") + total += 1 + continue + + old_data = parse_configmap_data(old_cm) + new_data = parse_configmap_data(new_cm) + + diffs = [(p, o, n) for p, o, n in deep_diff(old_data, new_data) if not is_noise(p)] + if not diffs: + continue + + print(f"\n ConfigMap/{name}: {len(diffs)} difference(s)") + for path, old_val, new_val in diffs: + total += 1 + print(f" {path}:") + print(f" old: {format_val(old_val)}") + print(f" new: {format_val(new_val)}") + + return total + + +def diff_all_resources(old_manifests, new_manifests): + """Report added/removed/changed resources of all kinds. Returns diff count.""" + all_keys = sorted(set(old_manifests) | set(new_manifests)) + total = 0 + + for key in all_keys: + kind, name = key + if kind == "ConfigMap": + continue # handled separately + + old_res = old_manifests.get(key) + new_res = new_manifests.get(key) + + if old_res is None: + print(f"\n + {kind}/{name}: NEW") + total += 1 + elif new_res is None: + print(f"\n - {kind}/{name}: REMOVED") + total += 1 + else: + diffs = [(p, o, n) for p, o, n in deep_diff(old_res, new_res) if not is_noise(p)] + if diffs: + print(f"\n {kind}/{name}: {len(diffs)} difference(s)") + for path, old_val, new_val in diffs: + total += 1 + print(f" {path}:") + print(f" old: {format_val(old_val)}") + print(f" new: {format_val(new_val)}") + + return total + + +def main(): + parser = argparse.ArgumentParser( + description="Structurally compare two Helm-rendered manifest files.", + epilog="See scripts/render-and-diff.sh for automated render + compare workflow.", + ) + parser.add_argument("old", help="Baseline manifest file (e.g. from release tag)") + parser.add_argument("new", help="New manifest file (e.g. from main or feature branch)") + parser.add_argument("--all", action="store_true", + help="Diff all resource types, not just ConfigMaps") + args = parser.parse_args() + + old_manifests = parse_manifests(args.old) + new_manifests = parse_manifests(args.new) + + total = 0 + + print("=== ConfigMap structural diff ===") + total += diff_configmaps(old_manifests, new_manifests) + + if args.all: + print("\n=== Other resources ===") + total += diff_all_resources(old_manifests, new_manifests) + + # Summary + old_keys = set(old_manifests) + new_keys = set(new_manifests) + added = new_keys - old_keys + removed = old_keys - new_keys + + print(f"\n--- Summary ---") + print(f"Resources: {len(old_keys)} old, {len(new_keys)} new" + f" (+{len(added)} added, -{len(removed)} removed)") + + if total == 0: + print("Result: no structural differences found.") + else: + print(f"Result: {total} structural difference(s) found.") + + sys.exit(0 if total == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/render-and-diff.sh b/scripts/render-and-diff.sh new file mode 100755 index 00000000..65a0c47c --- /dev/null +++ b/scripts/render-and-diff.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# +# Render Helm templates at two git refs and structurally diff the output. +# +# Mirrors the ArgoCD values layering for selfhosted deployments so you can +# verify exactly what will change before deploying. +# +# Usage: +# ./scripts/render-and-diff.sh [options] +# +# Examples: +# # Compare latest release tag against main +# ./scripts/render-and-diff.sh controlplane-2026.4.7 main +# +# # Compare two branches for a specific chart +# ./scripts/render-and-diff.sh main mike/feature --chart dataplane +# +# # Use custom values (e.g. terraform-generated) +# ./scripts/render-and-diff.sh controlplane-2026.4.7 main \ +# --values /path/to/terraform/control-plane/values.yaml +# +# # Compare with test fixture values +# ./scripts/render-and-diff.sh controlplane-2026.4.7 main \ +# --values tests/values/controlplane.aws.yaml +# +# # Diff all resources, not just ConfigMaps +# ./scripts/render-and-diff.sh controlplane-2026.4.7 main --all +# +# # Text diff instead of structural +# ./scripts/render-and-diff.sh controlplane-2026.4.7 main --text +# +# Requires: helm, python3, PyYAML (pip install pyyaml) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Defaults +CHART="controlplane" +VALUES_FILES=() +DIFF_ALL=false +TEXT_DIFF=false +NAMESPACE="" +EXTRA_HELM_ARGS=() + +usage() { + sed -n '2,/^[^#]/{ /^#/s/^# \{0,1\}//p }' "$0" + exit "${1:-0}" +} + +# Parse args +if [ $# -lt 2 ]; then + usage 1 +fi + +OLD_REF="$1" +NEW_REF="$2" +shift 2 + +while [ $# -gt 0 ]; do + case "$1" in + --chart) + CHART="$2"; shift 2 ;; + --values) + VALUES_FILES+=("$2"); shift 2 ;; + --namespace) + NAMESPACE="$2"; shift 2 ;; + --all) + DIFF_ALL=true; shift ;; + --text) + TEXT_DIFF=true; shift ;; + --help|-h) + usage 0 ;; + --set|--set-string) + EXTRA_HELM_ARGS+=("$1" "$2"); shift 2 ;; + *) + echo "Unknown option: $1" >&2; usage 1 ;; + esac +done + +# Set namespace default based on chart +if [ -z "$NAMESPACE" ]; then + case "$CHART" in + controlplane) NAMESPACE="controlplane" ;; + dataplane) NAMESPACE="dataplane" ;; + *) NAMESPACE="union" ;; + esac +fi + +# If no values files specified, use the default test fixture +if [ ${#VALUES_FILES[@]} -eq 0 ]; then + DEFAULT_VALUES="$REPO_ROOT/tests/values/${CHART}.aws.yaml" + if [ -f "$DEFAULT_VALUES" ]; then + VALUES_FILES=("$DEFAULT_VALUES") + echo "Using default test values: tests/values/${CHART}.aws.yaml" + else + echo "Warning: no --values specified and no default test fixture found at $DEFAULT_VALUES" + fi +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +render() { + local ref="$1" + local outfile="$2" + + # Create a worktree for the ref + local worktree="$TMPDIR/worktree-$(echo "$ref" | tr '/' '-')" + git -C "$REPO_ROOT" worktree add --quiet --detach "$worktree" "$ref" 2>/dev/null + + # Build values flags — resolve paths relative to CWD (not worktree) + local values_args=() + for vf in "${VALUES_FILES[@]}"; do + local abs_path + if [[ "$vf" = /* ]]; then + abs_path="$vf" + elif [[ "$vf" = tests/* ]]; then + # Test fixtures: use from worktree so they match the ref + abs_path="$worktree/$vf" + else + abs_path="$(cd "$REPO_ROOT" && realpath "$vf")" + fi + + if [ ! -f "$abs_path" ]; then + echo "Warning: values file not found at ref $ref: $vf (skipping)" >&2 + continue + fi + values_args+=(--values "$abs_path") + done + + # Build chart dependencies + helm dependency build "$worktree/charts/$CHART" --skip-refresh >/dev/null 2>&1 || true + + # Add controlplane-specific defaults + local chart_args=() + if [ "$CHART" = "controlplane" ]; then + chart_args+=(--set secrets.admin.clientSecret=test-secret) + fi + + helm template "$worktree/charts/$CHART" \ + --name-template "$CHART" \ + --namespace "$NAMESPACE" \ + --kube-version 1.32 \ + "${values_args[@]}" \ + "${chart_args[@]}" \ + "${EXTRA_HELM_ARGS[@]}" \ + > "$outfile" + + git -C "$REPO_ROOT" worktree remove --force "$worktree" 2>/dev/null +} + +echo "Rendering $CHART chart..." +echo " old: $OLD_REF" +echo " new: $NEW_REF" +echo "" + +OLD_OUT="$TMPDIR/old.yaml" +NEW_OUT="$TMPDIR/new.yaml" + +render "$OLD_REF" "$OLD_OUT" +echo " [ok] $OLD_REF rendered" + +render "$NEW_REF" "$NEW_OUT" +echo " [ok] $NEW_REF rendered" +echo "" + +if $TEXT_DIFF; then + echo "=== Text diff ===" + diff -u "$OLD_OUT" "$NEW_OUT" || true +else + COMPARE_ARGS=() + if $DIFF_ALL; then + COMPARE_ARGS+=(--all) + fi + python3 "$SCRIPT_DIR/compare-manifests.py" "$OLD_OUT" "$NEW_OUT" "${COMPARE_ARGS[@]}" +fi