332 lines
10 KiB
Bash
Executable File
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|scorecard|gitops-status|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|impact|review-last-change|security-scan|security-host|security-trivy|security-secrets|security-nuclei|security-web|security-logs|security-attack-path|prompt-injection-lab|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 "$@"
|