diff --git a/cmd/oikos/homelab.go b/cmd/oikos/homelab.go new file mode 100644 index 0000000..5290ded --- /dev/null +++ b/cmd/oikos/homelab.go @@ -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 ") + 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 ") + 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 ") + 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"` +} diff --git a/cmd/oikos/main.go b/cmd/oikos/main.go index 0fca3f0..e3f2915 100644 --- a/cmd/oikos/main.go +++ b/cmd/oikos/main.go @@ -64,6 +64,8 @@ func main() { slog.Error("build-hosts failed", "error", err) os.Exit(1) } + case "homelab": + runHomelabSubcommand() case "api": if err := runAPI(ctx, cfg); err != nil { slog.Error("api failed", "error", err) @@ -116,7 +118,8 @@ Roles: migrate Run database migrations (forward-only, idempotent) seed Ingest seed YAML files into the database 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) scheduler Run the observe + act loop (Phase 3) notifier Run the notification service (Phase 3) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index a406170..448d0e8 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -5,7 +5,7 @@ set -e -REPO_DIR="${REPO_DIR:-/opt/oikos}" +REPO_DIR="${REPO_DIR:-$PWD}" COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}" PROFILE="${PROFILE:-full}" HEALTH_URL="${HEALTH_URL:-http://localhost:8090/healthz}" diff --git a/scripts/rollback.sh b/scripts/rollback.sh index 1b33434..87c7c68 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -15,7 +15,7 @@ fi PROFILE="${PROFILE:-full}" DUMP_DIR="${DUMP_DIR:-/opt/oikos/backups}" HEALTH_URL="${HEALTH_URL:-http://localhost:8090/healthz}" -REPO_DIR="${REPO_DIR:-/opt/oikos}" +REPO_DIR="${REPO_DIR:-$PWD}" cd "$REPO_DIR"