#!/usr/bin/env bash set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUDGET_FILE="${HOMELAB_RESOURCE_BUDGET_FILE:-${REPO_ROOT}/infra/resource-budgets.yml}" KUBECONFIG_PATH="${KUBECONFIG:-${LAB_KUBECONFIG_PATH:-/home/jv/.kube/config}}" DETAILS=false MAX_EXAMPLES="${LAB_RESOURCE_BUDGET_EXAMPLES:-12}" failures=0 warnings=0 usage() { cat <<'EOF' Usage: ./jeannie resource-budget [--details] Print host and Kubernetes resource budget checks. Options: --details Show every pod/container missing requests or limits. EOF } while [ "$#" -gt 0 ]; do case "$1" in --details|--verbose) DETAILS=true ;; -h|--help|help) usage exit 0 ;; *) usage >&2 exit 2 ;; esac shift done section() { printf '\n== %s ==\n' "$1" } warn() { warnings=$((warnings + 1)) printf 'warn - %s\n' "$1" } fail() { failures=$((failures + 1)) printf 'fail - %s\n' "$1" } have() { command -v "$1" >/dev/null 2>&1 } host_budget() { section "Host Budget" if have free; then free -m | awk '/^Mem:/ { used_pct = int(($3 / $2) * 100) printf "Debian memory used %s%%\n", used_pct if (used_pct > 80) exit 2 }' || warn "Debian memory usage is above the default 80% budget" else warn "free command unavailable" fi if have df; then df -P / /data 2>/dev/null | awk 'NR > 1 { gsub("%", "", $5); free = 100 - $5; printf "%-25s free=%s%%\n", $6, free }' || true else warn "df command unavailable" fi } kubernetes_budget() { section "Kubernetes Budget" if ! have kubectl || [ ! -s "$KUBECONFIG_PATH" ]; then warn "kubectl or kubeconfig unavailable; skipping Kubernetes budget checks" return 0 fi if ! kubectl --kubeconfig "$KUBECONFIG_PATH" get --raw=/readyz >/dev/null 2>&1; then warn "Kubernetes API unavailable; skipping Kubernetes budget checks" return 0 fi python3 - "$BUDGET_FILE" "$KUBECONFIG_PATH" "$DETAILS" "$MAX_EXAMPLES" <<'PY' import json import subprocess import sys from collections import Counter, defaultdict budget_file, kubeconfig, details_arg, max_examples_arg = sys.argv[1:5] show_details = details_arg.lower() == "true" max_examples = int(max_examples_arg) try: import yaml except ImportError: yaml = None def load_budget(path): if yaml: with open(path, encoding="utf-8") as handle: return yaml.safe_load(handle) or {} return { "cluster": { "max_total_cpu_millicores": 9000, "max_total_memory_mib": 22000, "require_requests": True, "require_limits": True, }, "namespaces": {}, } def cpu_to_m(value): if not value: return 0 text = str(value) if text.endswith("m"): return int(text[:-1]) return int(float(text) * 1000) def mem_to_mib(value): if not value: return 0 text = str(value) units = { "Ki": 1 / 1024, "Mi": 1, "Gi": 1024, "Ti": 1024 * 1024, "K": 1 / 1000, "M": 1000 / 1024, "G": 1000 * 1000 / 1024, } for suffix, multiplier in units.items(): if text.endswith(suffix): return int(float(text[: -len(suffix)]) * multiplier) return int(float(text) / (1024 * 1024)) budget = load_budget(budget_file) cluster_budget = budget.get("cluster", {}) namespace_budgets = budget.get("namespaces", {}) raw = subprocess.check_output( ["kubectl", "--kubeconfig", kubeconfig, "get", "pods", "-A", "-o", "json"], text=True, ) pods = json.loads(raw).get("items", []) namespace_totals = {} missing = [] missing_by_namespace = defaultdict(Counter) for pod in pods: ns = pod["metadata"]["namespace"] pod_name = pod["metadata"]["name"] namespace_totals.setdefault(ns, {"cpu": 0, "memory": 0}) for container in pod.get("spec", {}).get("containers", []): name = container["name"] resources = container.get("resources", {}) requests = resources.get("requests", {}) limits = resources.get("limits", {}) namespace_totals[ns]["cpu"] += cpu_to_m(requests.get("cpu")) namespace_totals[ns]["memory"] += mem_to_mib(requests.get("memory")) if cluster_budget.get("require_requests", True) and ("cpu" not in requests or "memory" not in requests): missing.append(f"{ns}/{pod_name}:{name} missing requests") missing_by_namespace[ns]["requests"] += 1 if cluster_budget.get("require_limits", True) and ("cpu" not in limits or "memory" not in limits): missing.append(f"{ns}/{pod_name}:{name} missing limits") missing_by_namespace[ns]["limits"] += 1 total_cpu = sum(item["cpu"] for item in namespace_totals.values()) total_memory = sum(item["memory"] for item in namespace_totals.values()) print(f"cluster_requests_cpu_m={total_cpu}") print(f"cluster_requests_memory_mib={total_memory}") exit_code = 0 if total_cpu > int(cluster_budget.get("max_total_cpu_millicores", 9000)): print("fail - cluster CPU requests exceed budget") exit_code = 1 if total_memory > int(cluster_budget.get("max_total_memory_mib", 22000)): print("fail - cluster memory requests exceed budget") exit_code = 1 for ns, totals in sorted(namespace_totals.items()): ns_budget = namespace_budgets.get(ns) if not ns_budget: continue print(f"{ns}: cpu_m={totals['cpu']} memory_mib={totals['memory']}") if totals["cpu"] > int(ns_budget.get("max_cpu_millicores", 999999)): print(f"fail - {ns} CPU requests exceed budget") exit_code = 1 if totals["memory"] > int(ns_budget.get("max_memory_mib", 999999)): print(f"fail - {ns} memory requests exceed budget") exit_code = 1 if missing: print(f"missing_resource_policy: total={len(missing)}") print("by_namespace:") for ns, counts in sorted( missing_by_namespace.items(), key=lambda item: (item[1]["requests"] + item[1]["limits"], item[0]), reverse=True, ): print(f" {ns}: requests_missing={counts['requests']} limits_missing={counts['limits']}") if show_details: print("details:") for item in missing: print(f" {item}") else: print("examples:") for item in missing[:max_examples]: print(f" {item}") if len(missing) > max_examples: print(f" ... {len(missing) - max_examples} more") print(" run ./jeannie resource-budget --details for the full list") print("next_steps:") print(" 1. Add requests before limits for app workloads you own.") print(" 2. Leave kube-system/control-plane static pods for a separate pass.") print(" 3. Use ./jeannie control-plane taint after Pimox workers are Ready.") exit_code = 1 raise SystemExit(exit_code) PY } section "Policy" if [ -s "$BUDGET_FILE" ]; then printf 'budget_file=%s\n' "$BUDGET_FILE" else fail "budget file is missing: $BUDGET_FILE" fi host_budget kubernetes_budget || failures=$((failures + 1)) section "Summary" printf 'failures=%s warnings=%s\n' "$failures" "$warnings" if [ "$failures" -gt 0 ]; then exit 1 fi