provisioning-catalog/components/_renderer/lib/check-contract.sh

237 lines
9.8 KiB
Bash
Raw Normal View History

#!/bin/bash
# Linter for the catalog component install-script contract.
# Scans every provisioning/catalog/components/<name>/cluster/install-<name>.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 <script_path> → 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 <name>-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 <name>-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