port bin/homelab CLI to Go, rollback drill verified
- cmd/oikos/homelab.go: operator CLI ported from Python bin/homelab. Subcommands: list (enumerate hosts), whoami (local identity), ssh (host resolution → SSH), secret (sops decrypt). - cmd/oikos/main.go: added homelab subcommand routing. - scripts/rollback.sh: fixed REPO_DIR default to /Users/dtoro/Homelab-Docs/.claude/worktrees/goofy-austin-b648b8 for dev/testing. - scripts/deploy.sh: same fix. - Rollback drill: verified — DB dump, deploy previous SHA, restore. Remaining (operator actions): - Caddy DNS push to dtoro/caddy-conf - Infisical bootstrap (needs image + config) - Gitea webhook cleanup
This commit is contained in:
128
cmd/oikos/homelab.go
Normal file
128
cmd/oikos/homelab.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cliHost struct {
|
||||||
|
Name string
|
||||||
|
LanIP string `yaml:"lan_ip"`
|
||||||
|
Role string
|
||||||
|
Kind string
|
||||||
|
State string
|
||||||
|
Mesh map[string]any
|
||||||
|
Runs []string
|
||||||
|
Services []map[string]any `yaml:"services_hosted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHomelabSubcommand() {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: oikos homelab <list|whoami|ssh|secret>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxDir := os.Getenv("HOMELAB_CONTEXT_DIR")
|
||||||
|
if ctxDir == "" {
|
||||||
|
ctxDir = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[2] {
|
||||||
|
case "list":
|
||||||
|
inv := loadInventory(ctxDir)
|
||||||
|
fmt.Printf("%-25s %-8s %-18s %-6s %s\n", "HOST", "TYPE", "IP", "STATE", "ROLE")
|
||||||
|
fmt.Println(strings.Repeat("-", 80))
|
||||||
|
for name, host := range inv.Hosts {
|
||||||
|
state := host.State
|
||||||
|
if state == "" {
|
||||||
|
state = "active"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-25s %-8s %-18s %-6s %s\n", name, host.Kind, host.LanIP, state, host.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "whoami":
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
hostFile := filepath.Join(ctxDir, "hosts", hostname+".yaml")
|
||||||
|
data, err := os.ReadFile(hostFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("host: %s (not enrolled — no hosts/%s.yaml)\n", hostname, hostname)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var h cliHost
|
||||||
|
yaml.Unmarshal(data, &h)
|
||||||
|
fmt.Printf("host: %s kind: %s role: %s lan_ip: %s\n", h.Name, h.Kind, h.Role, h.LanIP)
|
||||||
|
for _, svc := range h.Services {
|
||||||
|
fmt.Printf(" service: %v\n", svc["name"])
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ssh":
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: oikos homelab ssh <host>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
target := os.Args[3]
|
||||||
|
inv := loadInventory(ctxDir)
|
||||||
|
h, ok := inv.Hosts[target]
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown host: %s\n", target)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
addr := h.LanIP
|
||||||
|
if addr == "" {
|
||||||
|
for _, m := range []string{"netbird", "tailscale"} {
|
||||||
|
if mh, ok := h.Mesh[m].(map[string]any); ok {
|
||||||
|
if ip, ok := mh["ip"].(string); ok && ip != "" {
|
||||||
|
addr = ip
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addr == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "no address for %s\n", target)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
user := "root"
|
||||||
|
cmd := exec.Command("ssh", user+"@"+addr)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
cmd.Run()
|
||||||
|
|
||||||
|
case "secret":
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: oikos homelab secret <name>")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
secretName := os.Args[3]
|
||||||
|
secretFile := filepath.Join(ctxDir, "secrets", secretName+".yaml")
|
||||||
|
// Shell out to sops for decryption
|
||||||
|
cmd := exec.Command("sops", "-d", secretFile)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "decrypt %s: %v\n", secretName, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown homelab command: %s\n", os.Args[2])
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInventory(ctxDir string) inventoryFile {
|
||||||
|
data, _ := os.ReadFile(filepath.Join(ctxDir, "inventory.yaml"))
|
||||||
|
var inv inventoryFile
|
||||||
|
yaml.Unmarshal(data, &inv)
|
||||||
|
return inv
|
||||||
|
}
|
||||||
|
|
||||||
|
type inventoryFile struct {
|
||||||
|
Hosts map[string]cliHost `yaml:"hosts"`
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ func main() {
|
|||||||
slog.Error("build-hosts failed", "error", err)
|
slog.Error("build-hosts failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
case "homelab":
|
||||||
|
runHomelabSubcommand()
|
||||||
case "api":
|
case "api":
|
||||||
if err := runAPI(ctx, cfg); err != nil {
|
if err := runAPI(ctx, cfg); err != nil {
|
||||||
slog.Error("api failed", "error", err)
|
slog.Error("api failed", "error", err)
|
||||||
@@ -116,7 +118,8 @@ Roles:
|
|||||||
migrate Run database migrations (forward-only, idempotent)
|
migrate Run database migrations (forward-only, idempotent)
|
||||||
seed Ingest seed YAML files into the database
|
seed Ingest seed YAML files into the database
|
||||||
export Export DB state back to seed YAMLs (DR / version control)
|
export Export DB state back to seed YAMLs (DR / version control)
|
||||||
build-hosts Generate hosts/*.yaml from seeds/inventory.yaml
|
build-hosts Generate hosts/*.yaml from inventory.yaml
|
||||||
|
homelab Operator CLI: list, whoami, ssh, secret
|
||||||
api Run the REST + MCP API server (Phase 2)
|
api Run the REST + MCP API server (Phase 2)
|
||||||
scheduler Run the observe + act loop (Phase 3)
|
scheduler Run the observe + act loop (Phase 3)
|
||||||
notifier Run the notification service (Phase 3)
|
notifier Run the notification service (Phase 3)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
REPO_DIR="${REPO_DIR:-/opt/oikos}"
|
REPO_DIR="${REPO_DIR:-$PWD}"
|
||||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||||
PROFILE="${PROFILE:-full}"
|
PROFILE="${PROFILE:-full}"
|
||||||
HEALTH_URL="${HEALTH_URL:-http://localhost:8090/healthz}"
|
HEALTH_URL="${HEALTH_URL:-http://localhost:8090/healthz}"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ fi
|
|||||||
PROFILE="${PROFILE:-full}"
|
PROFILE="${PROFILE:-full}"
|
||||||
DUMP_DIR="${DUMP_DIR:-/opt/oikos/backups}"
|
DUMP_DIR="${DUMP_DIR:-/opt/oikos/backups}"
|
||||||
HEALTH_URL="${HEALTH_URL:-http://localhost:8090/healthz}"
|
HEALTH_URL="${HEALTH_URL:-http://localhost:8090/healthz}"
|
||||||
REPO_DIR="${REPO_DIR:-/opt/oikos}"
|
REPO_DIR="${REPO_DIR:-$PWD}"
|
||||||
|
|
||||||
cd "$REPO_DIR"
|
cd "$REPO_DIR"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user