Files
oikos/cmd/oikos/main.go
dtoro 0e3cbceeae
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
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
2026-07-07 19:07:54 +02:00

377 lines
9.1 KiB
Go

package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"net/http"
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/db"
"github.com/dtoro/oikos/internal/httpapi"
"github.com/dtoro/oikos/internal/notifier"
"github.com/dtoro/oikos/internal/observability"
"github.com/dtoro/oikos/internal/scheduler"
"github.com/dtoro/oikos/internal/secrets"
"github.com/jackc/pgx/v5"
)
var schedulerRunner = scheduler.RunnerForMain()
var notifierRunner = notifier.RunnerForMain()
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(1)
}
role := os.Args[1]
cfg := config.FromEnv()
// Structured logging (slog)
logger := observability.NewLogger(cfg.Debug)
slog.SetDefault(logger)
slog.Info("starting oikos", "role", role, "config", cfg)
ctx, cancel := signal.NotifyContext(context.Background(),
syscall.SIGTERM, syscall.SIGINT)
defer cancel()
switch role {
case "migrate":
if err := runMigrate(ctx, cfg); err != nil {
slog.Error("migrate failed", "error", err)
os.Exit(1)
}
case "seed":
if err := runSeed(ctx, cfg); err != nil {
slog.Error("seed failed", "error", err)
os.Exit(1)
}
case "export":
if err := runExport(ctx, cfg); err != nil {
slog.Error("export failed", "error", err)
os.Exit(1)
}
case "build-hosts":
if err := runBuildHosts(); err != nil {
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)
os.Exit(1)
}
case "scheduler":
runWithPool(ctx, cfg, "scheduler", schedulerRunner)
case "notifier":
runWithPool(ctx, cfg, "notifier", notifierRunner)
case "all":
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("connect db", "error", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Migrate(ctx); err != nil {
slog.Error("migrate", "error", err)
os.Exit(1)
}
go schedulerRunner(ctx, pool, cfg)
go notifierRunner(ctx, pool, cfg)
slog.Info("all: starting api with scheduler + notifier in background")
if err := httpapi.ListenAndServe(ctx, pool, cfg); err != nil {
slog.Error("api failed", "error", err)
os.Exit(1)
}
case "version":
fmt.Println("oikos dev (Phase 1)")
case "secret":
runSecret(ctx, cfg)
case "help", "--help", "-h":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown role: %s\n", role)
usage()
os.Exit(1)
}
}
func usage() {
fmt.Println(`oikos — the homelab OS
Usage: oikos <role> [flags]
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 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)
all Run all roles in one process (dev mode)
secret Secret management (Phase 5)
version Print version info
Environment:
OIKOS_DATABASE_URL Postgres connection string
OIKOS_API_LISTEN API listen address (default :8090)
OIKOS_ENV Environment (dev, prod)
OIKOS_DEBUG Enable verbose logging (true/1)
OIKOS_SEEDS_DIR Path to seeds directory (default: seeds)
OIKOS_MCP_BEARER_TOKEN Shared secret for MCP auth`)
}
func runMigrate(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
slog.Info("running migrations")
if err := pool.Migrate(ctx); err != nil {
return err
}
slog.Info("migrations complete")
return nil
}
func runSeed(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
// Ensure migrations are applied first
if err := pool.Migrate(ctx); err != nil {
return fmt.Errorf("migrations: %w", err)
}
seedsDir := cfg.SeedsDir
if seedsDir == "" {
seedsDir = "seeds"
}
// Ingest ontology seed
ontoContent, err := os.ReadFile(seedsDir + "/ontology.yaml")
if err != nil {
return fmt.Errorf("read ontology seed: %w", err)
}
err = pool.SeedIngest(ctx, "ontology.yaml", ontoContent,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
r, err := db.IngestOntologySeed(ctx, tx, data)
if err != nil {
return err
}
slog.Info("ontology ingested",
"lifecycles", r.Lifecycles,
"entity_types", r.EntityTypes,
"relationship_types", r.RelationshipTypes)
return nil
})
if err != nil {
return err
}
// Ingest inventory seed
invContent, err := os.ReadFile(seedsDir + "/inventory.yaml")
if err != nil {
return fmt.Errorf("read inventory seed: %w", err)
}
err = pool.SeedIngest(ctx, "inventory.yaml", invContent,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
r, err := db.IngestInventorySeed(ctx, tx, data)
if err != nil {
return err
}
slog.Info("inventory ingested",
"entities", r.Entities,
"relationships", r.Relationships)
return nil
})
if err != nil {
return err
}
// Ingest policy seed
polContent, err := os.ReadFile(seedsDir + "/policy.yaml")
if err != nil {
return fmt.Errorf("read policy seed: %w", err)
}
err = pool.SeedIngest(ctx, "policy.yaml", polContent,
func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
r, err := db.IngestPolicySeed(ctx, tx, data)
if err != nil {
return err
}
slog.Info("policy ingested",
"risk_classes", r.RiskClasses,
"approval_rules", r.ApprovalRules,
"autonomy_settings", r.AutonomySettings)
return nil
})
if err != nil {
return err
}
slog.Info("seed ingest complete")
return nil
}
func runAPI(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
if err := pool.Migrate(ctx); err != nil {
return fmt.Errorf("migrations: %w", err)
}
err = httpapi.ListenAndServe(ctx, pool, cfg)
if err == http.ErrServerClosed {
return nil
}
return err
}
func runWithPool(ctx context.Context, cfg config.Config, name string, fn func(context.Context, *db.Pool, config.Config)) {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
slog.Error("connect db", "role", name, "error", err)
os.Exit(1)
}
defer pool.Close()
if err := pool.Migrate(ctx); err != nil {
slog.Error("migrate", "role", name, "error", err)
os.Exit(1)
}
fn(ctx, pool, cfg)
}
func runSecret(ctx context.Context, cfg config.Config) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: oikos secret <list|migrate|export-sops>")
os.Exit(1)
}
sub := os.Args[2]
secretsDir := cfg.SecretsDir
if secretsDir == "" {
secretsDir = "secrets"
}
sopsBackend := secrets.NewSOPSBackend(secretsDir)
switch sub {
case "list":
keys, err := sopsBackend.List(ctx)
if err != nil {
slog.Error("secret list", "error", err)
os.Exit(1)
}
for _, k := range keys {
fmt.Println(k)
}
case "migrate":
infCfg := secrets.InfisicalConfig{
SiteURL: cfg.InfisicalSiteURL,
ClientID: cfg.InfisicalClientID,
ClientSecret: cfg.InfisicalClientSecret,
ProjectID: cfg.InfisicalProjectID,
SecretPath: "/",
Env: cfg.InfisicalEnv,
}
if infCfg.Env == "" {
infCfg.Env = "dev"
}
if infCfg.SiteURL == "" {
fmt.Fprintln(os.Stderr, "error: OIKOS_INFISICAL_SITE_URL not set")
os.Exit(1)
}
infBackend := secrets.NewInfisicalBackend(infCfg)
keys, err := sopsBackend.List(ctx)
if err != nil {
slog.Error("migrate: read sops", "error", err)
os.Exit(1)
}
migrated, failed := 0, 0
for _, key := range keys {
val, err := sopsBackend.Get(ctx, key)
if err != nil {
slog.Warn("migrate: skip", "key", key, "error", err)
failed++
continue
}
sKey := strings.ReplaceAll(key, "/", "_")
if err := infBackend.Set(ctx, sKey, val); err != nil {
slog.Warn("migrate: push failed", "key", sKey, "error", err)
failed++
continue
}
migrated++
fmt.Printf("migrated: %s → %s\n", key, sKey)
}
fmt.Printf("migrated %d, failed %d\n", migrated, failed)
case "export-sops":
keys, err := sopsBackend.List(ctx)
if err != nil {
slog.Error("export-sops: read", "error", err)
os.Exit(1)
}
fmt.Printf("# SOPS DR fallback — %d keys — export date: now\n", len(keys))
fmt.Printf("# Store in a secure offline location.\n\n")
for _, k := range keys {
fmt.Printf("%s: <sops-encrypted>\n", k)
}
fmt.Printf("\n# To restore: sops -d secrets/*.yaml\n")
default:
fmt.Fprintf(os.Stderr, "unknown secret command: %s\n", sub)
os.Exit(1)
}
}
func runExport(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
exports, err := db.ExportToYAML(ctx, pool)
if err != nil {
return err
}
for name, content := range exports {
path := cfg.SeedsDir + "/" + name
if err := os.WriteFile(path, content, 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
slog.Info("exported", "file", path, "bytes", len(content))
}
return nil
}