package main import ( "context" "fmt" "log/slog" "net/http" "os" "os/signal" "strings" "syscall" "github.com/dtoro/oikos/internal/config" "github.com/dtoro/oikos/internal/db" "github.com/dtoro/oikos/internal/httpapi" "github.com/dtoro/oikos/internal/knowledge" "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 "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 [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) api Run the REST + MCP API server scheduler Run the observe loop notifier Run the notification service (Matrix alerts) all Run all roles in one process (dev mode) secret Secret management (Infisical) knowledge Convert wiki to knowledge seed (one-shot) version Print version info The operator interface is Nomos (MCP agent) — no CLI needed. 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 } // Ingest knowledge seed (documents, investigations, runbooks) knContent, err := os.ReadFile(seedsDir + "/knowledge.yaml") if err != nil { if os.IsNotExist(err) { slog.Info("knowledge seed not found, skipping") } else { return fmt.Errorf("read knowledge seed: %w", err) } } else { err = pool.SeedIngest(ctx, "knowledge.yaml", knContent, func(ctx context.Context, tx pgx.Tx, data map[string]any) error { r, err := knowledge.Ingest(ctx, tx, data) if err != nil { return err } slog.Info("knowledge ingested", "documents", r.Documents, "investigations", r.Investigations, "runbooks", r.Runbooks) 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 ") os.Exit(1) } sub := os.Args[2] secretsDir := cfg.SecretsDir if secretsDir == "" { secretsDir = "archive/secrets-sops-backup" } 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: \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 }