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 *.tfstate.backup
.terraform/ .terraform/
.lab/ .lab/
bin/jeannie-core
bootstrap/host/inventory/ bootstrap/host/inventory/
# Ignore local archive dumps and backups # Ignore local archive dumps and backups

View File

@ -110,6 +110,8 @@ On the Debian host:
minor, currently `v1.36` minor, currently `v1.36`
- SSH access to worker nodes - SSH access to worker nodes
- SSH access to the OCI edge host - 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 - enough persistent storage for `/data/openebs/local` and Docker's data root
After a clean Debian reinstall, bootstrap the host package set and desktop 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 The default kubeconfig path is `/home/jv/.kube/config`. Override it with
`KUBECONFIG_PATH` or `TF_VAR_kubeconfig_path` when needed. `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 ## Version Discipline
The lab should converge on one Kubernetes minor across the API server and every 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` minor, currently `v1.36`
- SSH access to worker nodes - SSH access to worker nodes
- SSH access to the OCI edge host - 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 - enough persistent storage for `/data/openebs/local` and Docker's data root
After a clean Debian reinstall, bootstrap the host package set and desktop 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 The default kubeconfig path is `/home/jv/.kube/config`. Override it with
`KUBECONFIG_PATH` or `TF_VAR_kubeconfig_path` when needed. `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 ## Version Discipline
The lab should converge on one Kubernetes minor across the API server and every 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 - curl
- ffmpeg - ffmpeg
- git - git
- golang-go
- gnupg - gnupg
- gpg - gpg
- lynis - 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 `10`. Compact steps show elapsed time and the latest sanitized log line while
the full output continues writing to the log file. 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` `LAB_STATUS_DETAILS=true`
: With `status --verbose`, append detailed host, Docker, Kubernetes, Pimox, RPi, : With `status --verbose`, append detailed host, Docker, Kubernetes, Pimox, RPi,
Tailscale, and HTTP tables after the normal status cascade. 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 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() { detect_route_interface() {
local target="$1" local target="$1"
@ -4288,9 +4305,10 @@ up() {
echo "Deploying the homelab infrastructure..." echo "Deploying the homelab infrastructure..."
jeannie_log_start "up" jeannie_log_start "up"
jeannie_step_plan 15 jeannie_step_plan 16
run_step "Early preflight" homelab_preflight early run_step "Early preflight" homelab_preflight early
run_step "Go toolchain" ensure_go_toolchain
run_step "Gitea deploy" deploy_gitea run_step "Gitea deploy" deploy_gitea
run_step "Gitea repo bootstrap" bootstrap_gitea_repo run_step "Gitea repo bootstrap" bootstrap_gitea_repo
run_step "RPi services" deploy_rpi_services run_step "RPi services" deploy_rpi_services

View File

@ -1,20 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
MODE="compact" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ONLY="all" CORE_BIN="${JEANNIE_CORE_BIN:-${REPO_ROOT}/.lab/bin/jeannie-core}"
TITLE="${REPORT_TITLE:-Jeannie Report}"
INPUT_FILE=""
cleanup() { core_sources_newer() {
if [ -n "$INPUT_FILE" ] && [ -f "$INPUT_FILE" ]; then local source
rm -f "$INPUT_FILE"
while IFS= read -r source; do
if [[ "${source}" -nt "${CORE_BIN}" ]]; then
return 0
fi fi
done < <(find "${REPO_ROOT}/cmd/jeannie-core" -name '*.go' -type f)
return 1
} }
trap cleanup EXIT
usage() { usage() {
cat <<EOF cat <<'EOF'
Usage: report-render [--title TITLE] [--details] [--only failures|warnings|problems|all] [--json] Usage: report-render [--title TITLE] [--details] [--only failures|warnings|problems|all] [--json]
Reads TSV rows from stdin: Reads TSV rows from stdin:
@ -24,144 +26,22 @@ Statuses: ok, warn, fail, skip
EOF EOF
} }
while (($# > 0)); do if (($# > 0)); then
case "$1" in 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 usage
exit 0 exit 0
;; ;;
*)
usage >&2
exit 1
;;
esac esac
done fi
INPUT_FILE="$(mktemp "${TMPDIR:-/tmp}/jeannie-report.XXXXXX")" if [[ ! -x "${CORE_BIN}" || "${REPO_ROOT}/go.mod" -nt "${CORE_BIN}" ]] || core_sources_newer; then
cat >"$INPUT_FILE" 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' exec "${CORE_BIN}" report-render "$@"
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

View File

@ -78,6 +78,15 @@ validate_tofu_fmt() {
tofu fmt -check -recursive "${REPO_ROOT}/bootstrap" 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() { validate_security_static() {
"${REPO_ROOT}/scripts/validate-tailnet-policy" "${REPO_ROOT}/scripts/validate-tailnet-policy"
if command -v trivy >/dev/null 2>&1; then if command -v trivy >/dev/null 2>&1; then
@ -99,6 +108,7 @@ main() {
run_step "Bash syntax" validate_bash_syntax run_step "Bash syntax" validate_bash_syntax
run_step "Shellcheck" validate_shellcheck run_step "Shellcheck" validate_shellcheck
run_step "YAML parse" validate_yaml run_step "YAML parse" validate_yaml
run_step "Go checks" validate_go
run_step "OpenTofu formatting" validate_tofu_fmt run_step "OpenTofu formatting" validate_tofu_fmt
run_step "Security static checks" validate_security_static run_step "Security static checks" validate_security_static
} }