Start Jeannie Go core migration

This commit is contained in:
juvdiaz 2026-07-01 08:49:43 -06:00
parent 44d43f2b42
commit d63e402012
11 changed files with 397 additions and 145 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
*.tfstate.backup
.terraform/
.lab/
bin/jeannie-core
bootstrap/host/inventory/
# Ignore local archive dumps and backups

View File

@ -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

View File

@ -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

View File

@ -55,6 +55,7 @@ debian_pc_base_packages:
- curl
- ffmpeg
- git
- golang-go
- gnupg
- gpg
- lynis

265
cmd/jeannie-core/main.go Normal file
View File

@ -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]
}

View File

@ -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)
}
}

View File

@ -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.

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module my-homelab-configs
go 1.23

20
jeannie
View File

@ -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

View File

@ -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"
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 <<EOF
cat <<'EOF'
Usage: report-render [--title TITLE] [--details] [--only failures|warnings|problems|all] [--json]
Reads TSV rows from stdin:
@ -24,144 +26,22 @@ Statuses: ok, warn, fail, skip
EOF
}
while (($# > 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)
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 "$@"

View File

@ -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
}