From d63e402012c7356a6da02feb4552a751868e4778 Mon Sep 17 00:00:00 2001 From: juvdiaz Date: Wed, 1 Jul 2026 08:49:43 -0600 Subject: [PATCH] Start Jeannie Go core migration --- .gitignore | 1 + README.md | 13 ++ README.md.tmpl | 13 ++ bootstrap/host/group_vars/debian_pc.yml | 1 + cmd/jeannie-core/main.go | 265 ++++++++++++++++++++++++ cmd/jeannie-core/main_test.go | 44 ++++ docs/jeannie.1.md | 4 + go.mod | 3 + jeannie | 20 +- scripts/report-render | 168 +++------------ scripts/validate-homelab | 10 + 11 files changed, 397 insertions(+), 145 deletions(-) create mode 100644 cmd/jeannie-core/main.go create mode 100644 cmd/jeannie-core/main_test.go create mode 100644 go.mod diff --git a/.gitignore b/.gitignore index 1f71413..619cf86 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.tfstate.backup .terraform/ .lab/ +bin/jeannie-core bootstrap/host/inventory/ # Ignore local archive dumps and backups diff --git a/README.md b/README.md index 1fd8ed8..2e9b504 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ On the Debian host: minor, currently `v1.36` - SSH access to worker nodes - SSH access to the OCI edge host +- Go toolchain for Jeannie core helpers; `./jeannie up` installs + `golang-go` on the Debian host if it is missing - enough persistent storage for `/data/openebs/local` and Docker's data root After a clean Debian reinstall, bootstrap the host package set and desktop @@ -130,6 +132,17 @@ Details are in `bootstrap/host/README.md`. The default kubeconfig path is `/home/jv/.kube/config`. Override it with `KUBECONFIG_PATH` or `TF_VAR_kubeconfig_path` when needed. +## Implementation Language Policy + +Jeannie keeps `./jeannie` as the stable command entrypoint. New durable +read-only orchestration, report rendering, inventory parsing, status/doctor +logic, and self-heal planning should prefer Go under `cmd/jeannie-core` because +it ships as one small binary and is easier to test than Bash. Bash remains for +thin compatibility wrappers and remote shell glue. Python remains acceptable for +AI/RAG, data-heavy utilities, or cases where an existing Python ecosystem is the +clear fit. New repo features should choose the smallest language that fits the +task instead of defaulting to Bash. + ## Version Discipline The lab should converge on one Kubernetes minor across the API server and every diff --git a/README.md.tmpl b/README.md.tmpl index 5b567d4..0750f48 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -110,6 +110,8 @@ On the Debian host: minor, currently `v1.36` - SSH access to worker nodes - SSH access to the OCI edge host +- Go toolchain for Jeannie core helpers; `./{{ main_script }} up` installs + `golang-go` on the Debian host if it is missing - enough persistent storage for `/data/openebs/local` and Docker's data root After a clean Debian reinstall, bootstrap the host package set and desktop @@ -130,6 +132,17 @@ Details are in `bootstrap/host/README.md`. The default kubeconfig path is `/home/jv/.kube/config`. Override it with `KUBECONFIG_PATH` or `TF_VAR_kubeconfig_path` when needed. +## Implementation Language Policy + +Jeannie keeps `./{{ main_script }}` as the stable command entrypoint. New durable +read-only orchestration, report rendering, inventory parsing, status/doctor +logic, and self-heal planning should prefer Go under `cmd/jeannie-core` because +it ships as one small binary and is easier to test than Bash. Bash remains for +thin compatibility wrappers and remote shell glue. Python remains acceptable for +AI/RAG, data-heavy utilities, or cases where an existing Python ecosystem is the +clear fit. New repo features should choose the smallest language that fits the +task instead of defaulting to Bash. + ## Version Discipline The lab should converge on one Kubernetes minor across the API server and every diff --git a/bootstrap/host/group_vars/debian_pc.yml b/bootstrap/host/group_vars/debian_pc.yml index 168e0de..11b5fd1 100644 --- a/bootstrap/host/group_vars/debian_pc.yml +++ b/bootstrap/host/group_vars/debian_pc.yml @@ -55,6 +55,7 @@ debian_pc_base_packages: - curl - ffmpeg - git + - golang-go - gnupg - gpg - lynis diff --git a/cmd/jeannie-core/main.go b/cmd/jeannie-core/main.go new file mode 100644 index 0000000..d826cb3 --- /dev/null +++ b/cmd/jeannie-core/main.go @@ -0,0 +1,265 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "sort" + "strings" +) + +type reportRow struct { + Status string `json:"status"` + Area string `json:"area"` + Check string `json:"check"` + Summary string `json:"summary"` + Detail string `json:"detail"` + Fix string `json:"fix"` + Graph string `json:"graph"` + Query string `json:"query"` +} + +func main() { + if len(os.Args) < 2 { + usage(os.Stderr) + os.Exit(2) + } + + switch os.Args[1] { + case "report-render": + if err := runReportRender(os.Args[2:], os.Stdin, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + case "help", "-h", "--help": + usage(os.Stdout) + default: + usage(os.Stderr) + os.Exit(2) + } +} + +func usage(out io.Writer) { + fmt.Fprintln(out, "Usage: jeannie-core report-render [--title TITLE] [--details] [--only failures|warnings|problems|all] [--json]") +} + +func runReportRender(args []string, in io.Reader, out io.Writer) error { + flags := flag.NewFlagSet("report-render", flag.ContinueOnError) + flags.SetOutput(io.Discard) + title := flags.String("title", envDefault("REPORT_TITLE", "Jeannie Report"), "") + details := flags.Bool("details", false, "") + only := flags.String("only", "all", "") + asJSON := flags.Bool("json", false, "") + if err := flags.Parse(args); err != nil { + usage(os.Stderr) + return err + } + + rows, err := parseReportRows(in) + if err != nil { + return err + } + rows = filterReportRows(rows, *only) + + if *asJSON { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + return encoder.Encode(map[string]any{"title": *title, "rows": rows}) + } + + renderReportText(out, *title, rows, *details, *only) + return nil +} + +func envDefault(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func parseReportRows(in io.Reader) ([]reportRow, error) { + data, err := io.ReadAll(in) + if err != nil { + return nil, err + } + + var rows []reportRow + for _, raw := range strings.Split(string(data), "\n") { + raw = strings.TrimSuffix(raw, "\r") + if strings.TrimSpace(raw) == "" { + continue + } + parts := strings.Split(raw, "\t") + for len(parts) < 8 { + parts = append(parts, "") + } + + row := reportRow{ + Status: normalizeStatus(parts[0]), + Area: strings.TrimSpace(parts[1]), + Check: strings.TrimSpace(parts[2]), + Summary: strings.TrimSpace(parts[3]), + Detail: strings.TrimSpace(parts[4]), + Fix: strings.TrimSpace(parts[5]), + Graph: strings.TrimSpace(parts[6]), + Query: strings.TrimSpace(parts[7]), + } + if row.Area == "" { + row.Area = "General" + } + if row.Status == "ok" { + row.Graph = "" + row.Query = "" + } + rows = append(rows, row) + } + return rows, nil +} + +func normalizeStatus(value string) string { + status := strings.ToLower(strings.TrimSpace(value)) + if status == "" { + return "skip" + } + return status +} + +func filterReportRows(rows []reportRow, only string) []reportRow { + var filtered []reportRow + for _, row := range rows { + if includeReportRow(row, only) { + filtered = append(filtered, row) + } + } + return filtered +} + +func includeReportRow(row reportRow, only string) bool { + switch only { + case "all": + return true + case "failures": + return row.Status == "fail" + case "warnings": + return row.Status == "warn" + case "problems": + return row.Status == "fail" || row.Status == "warn" + default: + return true + } +} + +func renderReportText(out io.Writer, title string, rows []reportRow, details bool, only string) { + counts := map[string]int{"fail": 0, "warn": 0, "ok": 0, "skip": 0} + for _, row := range rows { + counts[row.Status]++ + } + + fmt.Fprintln(out, title) + fmt.Fprintln(out, strings.Repeat("=", len(title))) + fmt.Fprintf(out, "fail=%d warn=%d ok=%d skip=%d\n", counts["fail"], counts["warn"], counts["ok"], counts["skip"]) + + if len(rows) == 0 { + switch only { + case "problems": + fmt.Fprintln(out, "\nNo failing or warning items.") + case "failures": + fmt.Fprintln(out, "\nNo failing items.") + case "warnings": + fmt.Fprintln(out, "\nNo warning items.") + default: + fmt.Fprintln(out, "\nNo report rows.") + } + return + } + + for _, area := range orderedReportAreas(rows) { + fmt.Fprintf(out, "\n== %s ==\n", area) + areaRows := rowsForReportArea(rows, area) + sort.SliceStable(areaRows, func(i, j int) bool { + leftRank := reportStatusRank(areaRows[i].Status) + rightRank := reportStatusRank(areaRows[j].Status) + if leftRank != rightRank { + return leftRank < rightRank + } + return areaRows[i].Check < areaRows[j].Check + }) + + for _, row := range areaRows { + fmt.Fprintf(out, "%-5s %-30s %s\n", row.Status, truncate(row.Check, 30), row.Summary) + if details || row.Status == "fail" || row.Status == "warn" { + if row.Detail != "" { + fmt.Fprintf(out, " detail: %s\n", row.Detail) + } + if row.Fix != "" { + fmt.Fprintf(out, " fix: %s\n", row.Fix) + } + if row.Graph != "" { + fmt.Fprintf(out, " graph: %s\n", row.Graph) + } + if row.Query != "" { + fmt.Fprintf(out, " query: %s\n", row.Query) + } + } + } + } + + for _, row := range rows { + if row.Status == "fail" || row.Status == "warn" { + fmt.Fprintln(out, "\nNext Fix") + if row.Fix != "" { + fmt.Fprintln(out, row.Fix) + } else { + fmt.Fprintf(out, "Investigate %s / %s\n", row.Area, row.Check) + } + return + } + } +} + +func orderedReportAreas(rows []reportRow) []string { + seen := map[string]bool{} + var areas []string + for _, row := range rows { + if !seen[row.Area] { + seen[row.Area] = true + areas = append(areas, row.Area) + } + } + return areas +} + +func rowsForReportArea(rows []reportRow, area string) []reportRow { + var areaRows []reportRow + for _, row := range rows { + if row.Area == area { + areaRows = append(areaRows, row) + } + } + return areaRows +} + +func reportStatusRank(status string) int { + switch status { + case "fail": + return 0 + case "warn": + return 1 + case "skip": + return 2 + case "ok": + return 3 + default: + return 9 + } +} + +func truncate(value string, max int) string { + if len(value) <= max { + return value + } + return value[:max] +} diff --git a/cmd/jeannie-core/main_test.go b/cmd/jeannie-core/main_test.go new file mode 100644 index 0000000..d0f9de3 --- /dev/null +++ b/cmd/jeannie-core/main_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestReportRenderProblemsOnly(t *testing.T) { + input := strings.Join([]string{ + "ok\tArea\tHealthy\tok\t\t\tgraph\tquery", + "warn\tArea\tWarning\tneeds attention\tdetail\tfix\tgraph\tquery", + "", + }, "\n") + + var output bytes.Buffer + if err := runReportRender([]string{"--title", "Unit Report", "--only", "problems"}, strings.NewReader(input), &output); err != nil { + t.Fatal(err) + } + + got := output.String() + if strings.Contains(got, "Healthy") { + t.Fatalf("problems-only report included ok row:\n%s", got) + } + for _, want := range []string{"Unit Report", "warn=1", "Warning", "Next Fix", "fix"} { + if !strings.Contains(got, want) { + t.Fatalf("report missing %q:\n%s", want, got) + } + } +} + +func TestReportRenderHidesOKGraphAndQuery(t *testing.T) { + input := "ok\tArea\tHealthy\tok\tdetail\tfix\tgraph\tquery\n" + + var output bytes.Buffer + if err := runReportRender([]string{"--title", "Unit Report", "--only", "all", "--details"}, strings.NewReader(input), &output); err != nil { + t.Fatal(err) + } + + got := output.String() + if strings.Contains(got, "graph:") || strings.Contains(got, "query:") { + t.Fatalf("ok row leaked graph/query links:\n%s", got) + } +} diff --git a/docs/jeannie.1.md b/docs/jeannie.1.md index 1dab753..a6dcd3f 100644 --- a/docs/jeannie.1.md +++ b/docs/jeannie.1.md @@ -487,6 +487,10 @@ full output is written to the log file. `10`. Compact steps show elapsed time and the latest sanitized log line while the full output continues writing to the log file. +`JEANNIE_CORE_BIN` +: Optional path to a prebuilt Go `jeannie-core` binary. Defaults to +`.lab/bin/jeannie-core`; wrappers build it on demand when Go is available. + `LAB_STATUS_DETAILS=true` : With `status --verbose`, append detailed host, Docker, Kubernetes, Pimox, RPi, Tailscale, and HTTP tables after the normal status cascade. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2894bd8 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module my-homelab-configs + +go 1.23 diff --git a/jeannie b/jeannie index 588bb5c..7e7728c 100755 --- a/jeannie +++ b/jeannie @@ -954,6 +954,23 @@ ensure_python3() { sudo apt-get install -y --no-install-recommends python3 } +ensure_go_toolchain() { + require_debian_server "go toolchain" + + if command -v go >/dev/null 2>&1; then + return 0 + fi + + echo "Installing Go toolchain for Jeannie core helpers..." + sudo apt-get update + sudo apt-get install -y --no-install-recommends golang-go + + if ! command -v go >/dev/null 2>&1; then + echo "go is still unavailable after installing golang-go." >&2 + exit 1 + fi +} + detect_route_interface() { local target="$1" @@ -4288,9 +4305,10 @@ up() { echo "Deploying the homelab infrastructure..." jeannie_log_start "up" - jeannie_step_plan 15 + jeannie_step_plan 16 run_step "Early preflight" homelab_preflight early + run_step "Go toolchain" ensure_go_toolchain run_step "Gitea deploy" deploy_gitea run_step "Gitea repo bootstrap" bootstrap_gitea_repo run_step "RPi services" deploy_rpi_services diff --git a/scripts/report-render b/scripts/report-render index 5c0f11c..de9d167 100755 --- a/scripts/report-render +++ b/scripts/report-render @@ -1,20 +1,22 @@ #!/usr/bin/env bash set -euo pipefail -MODE="compact" -ONLY="all" -TITLE="${REPORT_TITLE:-Jeannie Report}" -INPUT_FILE="" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CORE_BIN="${JEANNIE_CORE_BIN:-${REPO_ROOT}/.lab/bin/jeannie-core}" -cleanup() { - if [ -n "$INPUT_FILE" ] && [ -f "$INPUT_FILE" ]; then - rm -f "$INPUT_FILE" - fi +core_sources_newer() { + local source + + while IFS= read -r source; do + if [[ "${source}" -nt "${CORE_BIN}" ]]; then + return 0 + fi + done < <(find "${REPO_ROOT}/cmd/jeannie-core" -name '*.go' -type f) + return 1 } -trap cleanup EXIT usage() { - cat < 0)); do +if (($# > 0)); then case "$1" in - --title) - TITLE="${2:-}" - shift 2 - ;; - --details) - MODE="details" - shift - ;; - --only) - ONLY="${2:-all}" - shift 2 - ;; - --json) - MODE="json" - shift - ;; - -h|--help) + -h | --help) usage exit 0 ;; - *) - usage >&2 - exit 1 - ;; esac -done +fi -INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/jeannie-report.XXXXXX")" -cat >"$INPUT_FILE" +if [[ ! -x "${CORE_BIN}" || "${REPO_ROOT}/go.mod" -nt "${CORE_BIN}" ]] || core_sources_newer; then + if ! command -v go >/dev/null 2>&1; then + echo "go is required to build ${CORE_BIN}; run ./jeannie up or install golang-go." >&2 + exit 1 + fi + mkdir -p "$(dirname "${CORE_BIN}")" + GOCACHE="${GOCACHE:-${REPO_ROOT}/.lab/go-build-cache}" go build -o "${CORE_BIN}" "${REPO_ROOT}/cmd/jeannie-core" +fi -python3 - "$TITLE" "$MODE" "$ONLY" "$INPUT_FILE" <<'PY' -import json -import sys -from collections import OrderedDict - -title, mode, only, input_file = sys.argv[1:5] -rows = [] - -with open(input_file, encoding="utf-8") as handle: - for raw in handle: - raw = raw.rstrip("\n") - if not raw: - continue - parts = raw.split("\t") - while len(parts) < 8: - parts.append("") - status, area, check, summary, detail, fix, graph, query = parts[:8] - status = status.strip().lower() or "skip" - if status == "ok": - graph = "" - query = "" - rows.append( - { - "status": status, - "area": area.strip() or "General", - "check": check.strip(), - "summary": summary.strip(), - "detail": detail.strip(), - "fix": fix.strip(), - "graph": graph.strip(), - "query": query.strip(), - } - ) - -def include(row): - status = row["status"] - if only == "all": - return True - if only == "failures": - return status == "fail" - if only == "warnings": - return status == "warn" - if only == "problems": - return status in {"fail", "warn"} - return True - -rows = [row for row in rows if include(row)] - -if mode == "json": - print(json.dumps({"title": title, "rows": rows}, indent=2)) - raise SystemExit(0) - -counts = {"fail": 0, "warn": 0, "ok": 0, "skip": 0} -for row in rows: - counts[row["status"]] = counts.get(row["status"], 0) + 1 - -print(title) -print("=" * len(title)) -print( - f"fail={counts.get('fail', 0)} " - f"warn={counts.get('warn', 0)} " - f"ok={counts.get('ok', 0)} " - f"skip={counts.get('skip', 0)}" -) - -if not rows: - if only == "problems": - print("\nNo failing or warning items.") - elif only == "failures": - print("\nNo failing items.") - elif only == "warnings": - print("\nNo warning items.") - else: - print("\nNo report rows.") - raise SystemExit(0) - -groups = OrderedDict() -for row in rows: - groups.setdefault(row["area"], []).append(row) - -status_rank = {"fail": 0, "warn": 1, "skip": 2, "ok": 3} -status_label = {"fail": "fail", "warn": "warn", "ok": "ok", "skip": "skip"} - -for area, area_rows in groups.items(): - print(f"\n== {area} ==") - for row in sorted(area_rows, key=lambda item: (status_rank.get(item["status"], 9), item["check"])): - status = status_label.get(row["status"], row["status"]) - check = row["check"][:30] - summary = row["summary"] - print(f"{status:<5} {check:<30} {summary}") - if mode == "details" or row["status"] in {"fail", "warn"}: - if row["detail"]: - print(f" detail: {row['detail']}") - if row["fix"]: - print(f" fix: {row['fix']}") - if row["graph"]: - print(f" graph: {row['graph']}") - if row["query"]: - print(f" query: {row['query']}") - -problems = [row for row in rows if row["status"] in {"fail", "warn"}] -if problems: - first = problems[0] - print("\nNext Fix") - if first["fix"]: - print(first["fix"]) - else: - print(f"Investigate {first['area']} / {first['check']}") -PY +exec "${CORE_BIN}" report-render "$@" diff --git a/scripts/validate-homelab b/scripts/validate-homelab index 8457f7a..07d766c 100755 --- a/scripts/validate-homelab +++ b/scripts/validate-homelab @@ -78,6 +78,15 @@ validate_tofu_fmt() { tofu fmt -check -recursive "${REPO_ROOT}/bootstrap" } +validate_go() { + if ! command -v go >/dev/null 2>&1; then + echo "go not installed; skipping Go checks" + return 0 + fi + test -z "$(gofmt -l "${REPO_ROOT}/cmd")" + GOCACHE="${GOCACHE:-${REPO_ROOT}/.lab/go-build-cache}" go test ./... +} + validate_security_static() { "${REPO_ROOT}/scripts/validate-tailnet-policy" if command -v trivy >/dev/null 2>&1; then @@ -99,6 +108,7 @@ main() { run_step "Bash syntax" validate_bash_syntax run_step "Shellcheck" validate_shellcheck run_step "YAML parse" validate_yaml + run_step "Go checks" validate_go run_step "OpenTofu formatting" validate_tofu_fmt run_step "Security static checks" validate_security_static }