my-homelab-configs/scripts/explain

332 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SCORECARD_MAP="${HOMELAB_SCORECARD_EXPLAIN_MAP:-${REPO_ROOT}/infra/jeannie-explain/scorecard.tsv}"
FINDINGS_MAP="${HOMELAB_FINDINGS_EXPLAIN_MAP:-${REPO_ROOT}/infra/jeannie-explain/findings.tsv}"
EXPLAIN_INPUT_FILE=""
cleanup() {
if [ -n "$EXPLAIN_INPUT_FILE" ] && [ -f "$EXPLAIN_INPUT_FILE" ]; then
rm -f "$EXPLAIN_INPUT_FILE"
fi
}
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage:
./jeannie explain scorecard [--all]
./jeannie explain <read-only-command> [args...]
./jeannie explain output < file
Explain warnings, failures, and corrective findings from Jeannie output. This is
read-only and refuses mutating commands.
Examples:
./jeannie explain scorecard
./jeannie explain status
./jeannie explain capacity
./jeannie explain resource-budget --details
./jeannie status | ./jeannie explain output
EOF
}
is_read_only_command() {
case "$1" in
status|capacity|capacity-advisor|capacity-limits|recover-plan|incident|safety-case|agent-sandbox|scorecard|gitops-status|promote|cert-check|backup-status|synthetic-checks|resource-budget|route-inventory|golden-ledger|workers|change-journal|map|validate|access-audit|doctor-versions|doctor-edge|doctor-gitea|doctor-rpi|doctor-cluster|preflight|doctor-preapply|inventory-check|secrets-check|tailnet-policy-check|ai-check|ask|ai-evals|ai-scheduler|ai-memory|model-observe|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|red-blue|control-plane)
return 0
;;
*)
return 1
;;
esac
}
is_read_only_subcommand() {
local command_name="$1"
local subcommand="${2:-}"
case "$command_name" in
workers)
[ -z "$subcommand" ] || [ "$subcommand" = "list" ]
;;
golden-ledger)
[ -z "$subcommand" ] || [ "$subcommand" = "show" ] || [ "$subcommand" = "check" ]
;;
change-journal)
[ -z "$subcommand" ] || [ "$subcommand" = "list" ] || [ "$subcommand" = "path" ]
;;
control-plane)
[ -z "$subcommand" ] || [ "$subcommand" = "status" ] || [ "$subcommand" = "pods" ]
;;
*)
return 0
;;
esac
}
run_target() {
local command_name="$1"
shift || true
if ! is_read_only_command "$command_name" || ! is_read_only_subcommand "$command_name" "${1:-}"; then
echo "Refusing to explain by running mutating or unknown command: ./jeannie ${command_name} $*" >&2
echo "Use './jeannie explain output < saved-output.txt' for arbitrary pasted output." >&2
return 2
fi
"${REPO_ROOT}/jeannie" "$command_name" "$@" 2>&1 || true
}
explain_output() {
local topic="$1"
local show_all="$2"
local input_file="$3"
if [ ! -s "$FINDINGS_MAP" ]; then
echo "Missing findings explain map: $FINDINGS_MAP" >&2
return 1
fi
if [ ! -s "$SCORECARD_MAP" ]; then
echo "Missing scorecard explain map: $SCORECARD_MAP" >&2
return 1
fi
python3 - "$topic" "$show_all" "$input_file" "$SCORECARD_MAP" "$FINDINGS_MAP" <<'PY'
import csv
import re
import sys
topic, show_all_arg, input_file, scorecard_map, findings_map = sys.argv[1:6]
show_all = show_all_arg.lower() == "true"
def read_tsv(path, key):
with open(path, encoding="utf-8", newline="") as handle:
return {row[key].lower(): row for row in csv.DictReader(handle, delimiter="\t")}
def read_tsv_rows(path):
with open(path, encoding="utf-8", newline="") as handle:
return list(csv.DictReader(handle, delimiter="\t"))
scorecard_info = read_tsv(scorecard_map, "label")
finding_rows = read_tsv_rows(findings_map)
with open(input_file, encoding="utf-8") as handle:
text = handle.read()
lines = text.splitlines()
def split_commands(value):
return [item.strip() for item in (value or "").split(";") if item.strip() and item.strip() != "none"]
def print_info(info, indent=" "):
diagnose = split_commands(info.get("diagnose_commands"))
jeannie_fix = split_commands(info.get("jeannie_fix_commands"))
other_fix = split_commands(info.get("other_fix_commands"))
print(f"{indent}Why: {info.get('meaning') or 'No explain entry exists yet.'}")
if diagnose:
print(f"{indent}Next command: {diagnose[0]}")
if len(diagnose) > 1:
print(f"{indent}More checks:")
for command in diagnose[1:]:
print(f"{indent} {command}")
if jeannie_fix:
print(f"{indent}Jeannie fix command:")
for command in jeannie_fix:
print(f"{indent} {command}")
else:
print(f"{indent}Jeannie fix command: none yet")
if other_fix:
print(f"{indent}Code/config fix:")
for command in other_fix:
print(f"{indent} {command}")
print(f"{indent}Risk: {info.get('risk') or 'unknown'}")
print(f"{indent}Auto-fix safe: {info.get('auto_fix') or 'no'}")
if info.get("notes"):
print(f"{indent}Note: {info['notes']}")
def parse_scorecard():
rows = []
pattern = re.compile(r"^(?P<label>.{1,28}) (?P<status>pass|warn|fail)(?: - (?P<summary>.*))?$")
for line in lines:
match = pattern.match(line)
if not match:
continue
label = match.group("label").strip()
status = match.group("status").strip()
summary = (match.group("summary") or "").strip()
if not show_all and status == "pass":
continue
rows.append((label, status, summary))
return rows
def interesting_lines():
keywords = [
"fail",
"warn",
"error",
"missing",
"unavailable",
"unhealthy",
"degraded",
"outofsync",
"notready",
"pressure",
"stale",
"expired",
"timeout",
"timed out",
"refused",
"404",
"502",
"backoff",
"crashloop",
"imagepull",
"no servers could be reached",
"needs cleanup",
]
found = []
for line in lines:
lowered = line.lower()
if any(keyword in lowered for keyword in keywords):
found.append(line.strip())
return found
def match_findings(line):
lowered = line.lower()
matches = []
for info in finding_rows:
pattern = (info.get("pattern") or "").lower()
if pattern and pattern in lowered:
matches.append((pattern, info))
return matches
def best_finding(line):
matches = match_findings(line)
if not matches:
return None
return sorted(matches, key=lambda item: (len(item[0]), item[0]), reverse=True)[0]
def parse_report_line(line):
report_match = re.match(r"^(?P<status>ok|warn|fail|skip)\s+(?P<check>.+?)(?:\s{2,}(?P<summary>.+))?$", line)
if report_match:
return (
report_match.group("check").strip(),
report_match.group("status").strip(),
(report_match.group("summary") or "").strip(),
)
scorecard_match = re.match(r"^(?P<label>.{1,28}) (?P<status>pass|warn|fail)(?: - (?P<summary>.*))?$", line)
if scorecard_match:
return (
scorecard_match.group("label").strip(),
scorecard_match.group("status").strip(),
(scorecard_match.group("summary") or "").strip(),
)
return (line[:72], "", "")
print(f"Jeannie Explain: {topic}")
print("=" * (17 + len(topic)))
print("mode: read-only")
if topic == "scorecard":
rows = parse_scorecard()
if not rows:
print("\nNo scorecard warnings or failures found.")
for index, (label, status, summary) in enumerate(rows, start=1):
info = scorecard_info.get(label.lower(), {})
print(f"\n{index}. {label}")
print(f" Status: {status}")
if summary:
print(f" Observed: {summary}")
print_info(info, " ")
raise SystemExit(0)
found_lines = interesting_lines()
seen_patterns = set()
matched_any = False
for line in found_lines:
title, status, summary = parse_report_line(line)
match = best_finding(summary) if summary else None
if not match:
match = best_finding(line)
if not match:
continue
pattern, info = match
if pattern in seen_patterns:
continue
seen_patterns.add(pattern)
matched_any = True
print(f"\n{len(seen_patterns)}. {title}")
if status:
print(f" Status: {status}")
if summary:
print(f" Observed: {summary}")
else:
print(f" Observed: {line}")
print(f" Matched rule: {pattern}")
print_info(info, " ")
if not matched_any:
if found_lines:
print("\nPotential issues were found, but no specific explain rule matched yet.")
print("Lines:")
for line in found_lines[:20]:
print(f" {line}")
if len(found_lines) > 20:
print(f" ... {len(found_lines) - 20} more")
else:
print("\nNo warning/error/corrective lines found.")
PY
}
main() {
local topic="${1:-}"
local show_all=false
case "$topic" in
""|-h|--help|help)
usage
return 0
;;
esac
shift || true
EXPLAIN_INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/jeannie-explain.XXXXXX")"
if [ "$topic" = "output" ]; then
cat >"$EXPLAIN_INPUT_FILE"
explain_output "output" "$show_all" "$EXPLAIN_INPUT_FILE"
return
fi
if [ "$topic" = "scorecard" ]; then
while [ "$#" -gt 0 ]; do
case "$1" in
--all)
show_all=true
;;
-h|--help|help)
usage
return 0
;;
*)
usage >&2
return 2
;;
esac
shift
done
run_target scorecard >"$EXPLAIN_INPUT_FILE"
explain_output scorecard "$show_all" "$EXPLAIN_INPUT_FILE"
return
fi
run_target "$topic" "$@" >"$EXPLAIN_INPUT_FILE"
explain_output "$topic" "$show_all" "$EXPLAIN_INPUT_FILE"
}
main "$@"