Compare commits

...

2 Commits

Author SHA1 Message Date
juvdiaz d63e402012 Start Jeannie Go core migration 2026-07-01 08:49:43 -06:00
juvdiaz 44d43f2b42 Show compact progress for long Jeannie steps 2026-07-01 08:34:36 -06:00
11 changed files with 489 additions and 156 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
@ -714,6 +727,11 @@ Worker indexes are stable. Index `1` maps to VMID `9010`, node name
so on. `LAB_PIMOX_SKIP_WORKER_INDEXES=1` leaves the first slot unmanaged while
allowing higher indexes to be automated.
Long-running `./jeannie up` steps keep compact output by default, but
the active step line refreshes with elapsed time and the latest log line. Set
`JEANNIE_PROGRESS_INTERVAL_SECONDS=5` for faster heartbeats, or
`JEANNIE_VERBOSE=true` to stream full command output.
Run a full cluster rebuild from the Debian server with:
```bash

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
@ -714,6 +727,11 @@ Worker indexes are stable. Index `1` maps to VMID `9010`, node name
so on. `LAB_PIMOX_SKIP_WORKER_INDEXES=1` leaves the first slot unmanaged while
allowing higher indexes to be automated.
Long-running `./{{ main_script }} up` steps keep compact output by default, but
the active step line refreshes with elapsed time and the latest log line. Set
`JEANNIE_PROGRESS_INTERVAL_SECONDS=5` for faster heartbeats, or
`JEANNIE_VERBOSE=true` to stream full command output.
Run a full cluster rebuild from the Debian server with:
```bash

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

@ -482,6 +482,15 @@ Defaults to `85`.
: Print full step output as it runs. Without this, noisy commands are compact and
full output is written to the log file.
`JEANNIE_PROGRESS_INTERVAL_SECONDS`
: Refresh interval for compact long-running step progress lines. Defaults to
`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

108
jeannie
View File

@ -796,6 +796,51 @@ jeannie_verbose_enabled() {
truthy "${JEANNIE_VERBOSE:-false}"
}
jeannie_step_prefix() {
local description="$1"
if ((JEANNIE_STEP_TOTAL > 0)); then
printf '[%d/%d] %-42s ' "${JEANNIE_STEP_INDEX}" "${JEANNIE_STEP_TOTAL}" "${description}"
else
printf '[%d] %-46s ' "${JEANNIE_STEP_INDEX}" "${description}"
fi
}
jeannie_compact_text() {
local text="$1"
local max_length="${2:-96}"
text="${text//$'\r'/ }"
text="${text//$'\n'/ }"
text="$(printf '%s' "${text}" | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//')"
if ((${#text} > max_length)); then
text="${text:0:max_length - 3}..."
fi
printf '%s' "${text}"
}
jeannie_step_progress() {
local description="$1"
local step_log="$2"
local started_at="$3"
local elapsed=$((SECONDS - started_at))
local last_line=""
if [[ -s "${step_log}" ]]; then
last_line="$(tail -n 1 "${step_log}" 2>/dev/null || true)"
last_line="$(jeannie_compact_text "${last_line}" 88)"
fi
printf '\r'
jeannie_step_prefix "${description}"
if [[ -n "${last_line}" ]]; then
printf 'running %ss | %s' "${elapsed}" "${last_line}"
else
printf 'running %ss' "${elapsed}"
fi
printf '\033[K'
}
redact_sensitive_output() {
sed -E \
-e 's/(GITEA_BOOTSTRAP_PASSWORD=)["'\'']?[^"'\''[:space:]]+["'\'']?/\1[REDACTED]/g' \
@ -812,17 +857,18 @@ redact_sensitive_output() {
run_step() {
local description="$1"
local step_log
local status_file
local status
local progress_pid
local progress_interval="${JEANNIE_PROGRESS_INTERVAL_SECONDS:-10}"
local started_at
shift
JEANNIE_STEP_INDEX=$((JEANNIE_STEP_INDEX + 1))
if ((JEANNIE_STEP_TOTAL > 0)); then
printf '[%d/%d] %-42s ' "${JEANNIE_STEP_INDEX}" "${JEANNIE_STEP_TOTAL}" "${description}"
else
printf '[%d] %-46s ' "${JEANNIE_STEP_INDEX}" "${description}"
fi
jeannie_step_prefix "${description}"
step_log="$(mktemp)"
status_file="$(mktemp)"
{
printf '\n== [%d/%d] %s ==\n' "${JEANNIE_STEP_INDEX}" "${JEANNIE_STEP_TOTAL}" "${description}"
printf 'Started: %s\n' "$(date -Is)"
@ -835,8 +881,24 @@ run_step() {
set -e
else
set +e
"$@" 2>&1 | redact_sensitive_output >"${step_log}"
status=${PIPESTATUS[0]}
(
set +e
"$@" 2>&1 | redact_sensitive_output >"${step_log}"
printf '%s\n' "${PIPESTATUS[0]}" >"${status_file}"
) &
progress_pid=$!
if ! [[ "${progress_interval}" =~ ^[0-9]+$ ]] || ((progress_interval < 1)); then
progress_interval=10
fi
started_at="${SECONDS}"
while kill -0 "${progress_pid}" >/dev/null 2>&1; do
sleep "${progress_interval}"
if kill -0 "${progress_pid}" >/dev/null 2>&1; then
jeannie_step_progress "${description}" "${step_log}" "${started_at}"
fi
done
wait "${progress_pid}" || true
status="$(cat "${status_file}" 2>/dev/null || printf '1')"
set -e
fi
@ -844,18 +906,22 @@ run_step() {
printf 'Finished: %s\n' "$(date -Is)" >>"${JEANNIE_LOG_FILE}"
if ((status == 0)); then
printf 'ok\n'
rm -f "${step_log}"
printf '\r'
jeannie_step_prefix "${description}"
printf 'ok\033[K\n'
rm -f "${step_log}" "${status_file}"
return 0
fi
printf 'failed\n\n'
printf '\r'
jeannie_step_prefix "${description}"
printf 'failed\033[K\n\n'
printf 'Step failed: %s\n' "${description}" >&2
printf 'Exit status: %s\n' "${status}" >&2
printf 'Full step output:\n' >&2
sed 's/^/ /' "${step_log}" >&2
printf '\nFull log: %s\n' "${JEANNIE_LOG_FILE}" >&2
rm -f "${step_log}"
rm -f "${step_log}" "${status_file}"
return "${status}"
}
@ -888,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"
@ -4222,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"
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 <<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)
-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
}