237 lines
9.8 KiB
Bash
237 lines
9.8 KiB
Bash
|
|
#!/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
|