#!/bin/bash # Linter for the catalog component install-script contract. # Scans every provisioning/catalog/components//cluster/install-.sh # and reports compliance with R1..R10 declared in COMPONENT_CONTRACT.md. # # Usage: # bash check-contract.sh # scan all components # bash check-contract.sh fleet_daemon nats # scan specific components # bash check-contract.sh --quiet # only print failures # # Exit code: # 0 — all scanned components compliant # 1 — at least one violation # 2 — invocation error (paths, etc.) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" COMPONENTS_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" # ── arg parsing ────────────────────────────────────────────────────────── quiet=false declare -a names=() for arg in "$@"; do case "$arg" in --quiet|-q) quiet=true ;; -h|--help) sed -n '2,15p' "$0" | sed 's/^# \?//' exit 0 ;; --*) echo "unknown option: $arg" >&2; exit 2 ;; *) names+=("$arg") ;; esac done if [ ${#names[@]} -eq 0 ]; then # Discover every component that has a cluster/install-*.sh. while IFS= read -r script; do comp="$(basename "$(dirname "$(dirname "$script")")")" # Skip pseudo-components (helpers, not real components). case "$comp" in _renderer|MIGRATION_CONCERNS.md) continue ;; esac names+=("$comp") done < <(find "$COMPONENTS_DIR" -mindepth 3 -maxdepth 3 -type f -name "install-*.sh" 2>/dev/null | sort) fi # ── clause checks ──────────────────────────────────────────────────────── # Each clause is a function clause_RN → exit 0 (PASS) or 1 (FAIL). # Stdout from a failing clause is the human-readable reason. clause_R1() { # strict shell + set -euo pipefail + SCRIPT_DIR grep -q '^#!/bin/bash' "$1" || { echo "missing #!/bin/bash shebang"; return 1; } grep -q 'set -euo pipefail' "$1" || { echo "missing 'set -euo pipefail'"; return 1; } grep -q 'SCRIPT_DIR=' "$1" || { echo "missing SCRIPT_DIR resolution"; return 1; } return 0 } clause_R2() { # source diagnostics.sh grep -qE 'source +"\$\{?SCRIPT_DIR\}?/diagnostics\.sh"' "$1" \ || { echo "does not source diagnostics.sh"; return 1; } return 0 } clause_R3() { # flag resolution grep -q 'PROVISIONING_FORCE' "$1" || { echo "missing PROVISIONING_FORCE handling"; return 1; } grep -q 'PROVISIONING_DEBUG' "$1" || { echo "missing PROVISIONING_DEBUG handling"; return 1; } grep -q 'PROVISIONING_VERBOSE' "$1" || { echo "missing PROVISIONING_VERBOSE handling"; return 1; } grep -q 'PRVNG_FORCE' "$1" || { echo "missing PRVNG_FORCE alias"; return 1; } grep -q 'PRVNG_DEBUG' "$1" || { echo "missing PRVNG_DEBUG alias"; return 1; } grep -q 'PRVNG_VERBOSE' "$1" || { echo "missing PRVNG_VERBOSE alias"; return 1; } return 0 } clause_R4() { # _log + _apply_stdin defined; no bare _kubectl apply outside helper body grep -qE '^_log\(\)' "$1" || { echo "missing _log() helper definition"; return 1; } grep -qE '^_apply_stdin\(\)' "$1" || { echo "missing _apply_stdin() helper definition"; return 1; } # awk tracks brace depth from `_apply_stdin() {` to its matching `}`, so we # can scan ONLY the lines outside that function for bare `_kubectl apply`. local violators violators=$(awk ' /^_apply_stdin\(\) *\{/ { inside = 1; depth = 1; next } inside { n = gsub(/\{/, "{") m = gsub(/\}/, "}") depth += n - m if (depth <= 0) { inside = 0 } next } /_kubectl[[:space:]]+apply[[:space:]]+-f[[:space:]]+-/ { printf(" line %d: %s\n", NR, $0) } ' "$1") if [ -n "$violators" ]; then echo "bare '_kubectl apply -f -' found outside _apply_stdin body — must use _apply_stdin instead:" printf '%s\n' "$violators" return 1 fi return 0 } clause_R5() { # register workload + install ERR trap grep -qE '_diag_register_(cluster_workload|systemd_unit)' "$1" \ || { echo "missing _diag_register_cluster_workload/systemd_unit call"; return 1; } grep -q '_diag_install_err_trap' "$1" \ || { echo "missing _diag_install_err_trap call"; return 1; } return 0 } clause_R6() { # FORCE delete branch for deployments / statefulsets # Soft check: presence of FORCE-conditional delete somewhere in the script. # Skip if the script has no Deployment/StatefulSet/DaemonSet at all # (CronJob-only components don't need this clause). if ! grep -qE 'kind: +(Deployment|StatefulSet|DaemonSet)' "$1"; then return 0 fi if grep -qE 'FORCE.*=.*true' "$1" && \ grep -qE '(_diag_force_delete_workload|_kubectl +delete +(deployment|statefulset|daemonset))' "$1"; then return 0 fi echo "Deployment/StatefulSet/DaemonSet present but no FORCE delete branch" return 1 } clause_R7() { # _diag_wait_for_* used instead of bare rollout status # Skip if no Deployment/StatefulSet/DaemonSet (nothing to roll out). if ! grep -qE 'kind: +(Deployment|StatefulSet|DaemonSet)' "$1"; then return 0 fi # The wrapper may live in the sibling -lib.sh — scan both. local lib="${1%/install-*.sh}/$(basename "${1%.*}" | sed 's/^install-//')-lib.sh" if grep -q '_diag_wait_for_' "$1"; then return 0; fi if [ -f "$lib" ] && grep -q '_diag_wait_for_' "$lib"; then return 0; fi echo "missing _diag_wait_for_* (in install or -lib.sh) — bare rollout loses diagnostic context" return 1 } clause_R8() { # never write secrets to disk # Heuristic: a SECRET-suffixed env var being redirected to a file path. # Targets: `... > /path`, `... >> /path`, `tee /path`, `cat >file <<<`. # Excludes: status echoes/printfs without redirection, --from-literal=, # kubectl create secret pipelines, and heredoc string-builder printf's. local violators violators=$(awk ' # Skip lines that are inside a kubectl create secret pipeline (heuristic). /kubectl[[:space:]]+create[[:space:]]+secret/ { next } /--from-literal=/ { next } # File redirection patterns with $SECRET, $TOKEN, $SEED, $KEY var names. /(\>|\>\>|tee[[:space:]]+)[[:space:]]*\/[A-Za-z0-9._\/-]+/ \ && /\$\{?[A-Z_]*(SECRET|TOKEN|SEED|PASSWORD|PRIVATE|HTPASSWD)/ { printf(" line %d: %s\n", NR, $0) } ' "$1") if [ -n "$violators" ]; then echo "potential secret write to disk detected:" printf '%s\n' "$violators" return 1 fi return 0 } clause_R10() { # dispatcher with full op set grep -q 'case "\$CMD_TSK"' "$1" || { echo "missing CMD_TSK dispatcher (case statement)"; return 1; } for op in install update delete purge health restart; do grep -qE "^[[:space:]]*${op}\)" "$1" || { echo "dispatcher missing branch for op: $op"; return 1; } done grep -q 'purge requires --confirm' "$1" || { echo "purge branch must guard with --confirm check"; return 1; } return 0 } # ── runner ─────────────────────────────────────────────────────────────── declare -A CLAUSES=( [R1]="strict shell + set guards" [R2]="sources diagnostics.sh" [R3]="flag resolution (PROVISIONING_/PRVNG_)" [R4]="_log + _apply_stdin defined; no bare _kubectl apply" [R5]="workload registration + ERR trap installed" [R6]="FORCE delete branch (cluster workloads)" [R7]="_diag_wait_for_* (not bare rollout status)" [R8]="no secrets written to disk" [R10]="dispatcher: install|update|delete|purge|health|restart + --confirm on purge" ) CLAUSE_ORDER=(R1 R2 R3 R4 R5 R6 R7 R8 R10) total=0 total_failed=0 total_pass=0 declare -a failed_components=() color_ok="$(printf '\033[32m')" color_fail="$(printf '\033[31m')" color_reset="$(printf '\033[0m')" color_dim="$(printf '\033[2m')" for comp in "${names[@]}"; do script="${COMPONENTS_DIR}/${comp}/cluster/install-${comp}.sh" if [ ! -f "$script" ]; then echo "${color_fail}[skip]${color_reset} ${comp}: no install-${comp}.sh found" >&2 continue fi total=$((total + 1)) comp_failed=0 declare -a comp_lines=() for cid in "${CLAUSE_ORDER[@]}"; do reason=$("clause_${cid}" "$script" 2>&1) || { comp_lines+=(" ${color_fail}✗ ${cid}${color_reset} ${CLAUSES[$cid]}") if [ -n "$reason" ]; then comp_lines+=(" ${color_dim}${reason}${color_reset}") fi comp_failed=1 continue } if [ "$quiet" = "false" ]; then comp_lines+=(" ${color_ok}✓ ${cid}${color_reset} ${CLAUSES[$cid]}") fi done if [ $comp_failed -eq 0 ]; then total_pass=$((total_pass + 1)) if [ "$quiet" = "false" ]; then echo "${color_ok}${comp}${color_reset}: all clauses pass" printf '%s\n' "${comp_lines[@]}" fi else total_failed=$((total_failed + 1)) failed_components+=("$comp") echo "${color_fail}${comp}${color_reset}: ${comp_failed} clause(s) failed" printf '%s\n' "${comp_lines[@]}" fi done echo "" echo "Summary: ${total} component(s) scanned, ${color_ok}${total_pass} pass${color_reset}, ${color_fail}${total_failed} fail${color_reset}" if [ $total_failed -gt 0 ]; then echo "" echo "Failing components: ${failed_components[*]}" echo "Run with --quiet to see only failures." echo "See COMPONENT_CONTRACT.md for the full clause specification." exit 1 fi exit 0