phase 1: Go foundation — module, migrations, domain, seed ingest
Core deliverables: - Go module github.com/dtoro/oikos (Go 1.26.3) - cmd/oikos: single binary with role subcommands (migrate, seed, export) - 6 SQL migrations: ontology meta-schema, entity instances (UUID+slug, blast_radius recursive function), operations (signals/checks/approvals), cognition (classifications/executions/feedback/patterns/skills), policy, observability (TimescaleDB hypertables + CAGGs + retention) - Domain layer: entity, signal, execution, classification, pattern, skill, approval, check types + 11 sentinel errors + lifecycle state machines - DB layer: pgx pool, SQL splitter (handles 94436 and -- comments), migration runner, seed ingest (ontology+inventory+policy) with content-hash dedup - Config: env-based with defaults, secrets redaction - Observability: slog JSON logger with debug mode - Infrastructure: Makefile, docker-compose.yml, multi-stage Dockerfile (distroless, CGO_ENABLED=0) Verified end-to-end against timescale/timescaledb:2.17.2-pg16: - 6 migrations applied (65 SQL statements) - Seeds ingested: 6 lifecycles, 59 entity types, 46 relationship types, 111 entities, 144 relationships, 4 risk classes, 27 approval rules, 9 autonomy settings - Idempotent: second seed run is a no-op (content hash matches) Bugs fixed during implementation: - TimescaleDB CAGGs can't run in a transaction -> splitSQL() executes statements individually - Semicolons in -- comments treated as separators -> comment handling - YAML keys source/target didn't match code's source_type/target_type - yaml.Marshal produced YAML for JSONB columns -> json.Marshal
This commit is contained in:
217
cmd/oikos/main.go
Normal file
217
cmd/oikos/main.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/dtoro/oikos/internal/config"
|
||||
"github.com/dtoro/oikos/internal/db"
|
||||
"github.com/dtoro/oikos/internal/observability"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
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":
|
||||
slog.Info("api role not yet implemented (Phase 2)")
|
||||
os.Exit(1)
|
||||
case "scheduler":
|
||||
slog.Info("scheduler role not yet implemented (Phase 3)")
|
||||
os.Exit(1)
|
||||
case "notifier":
|
||||
slog.Info("notifier role not yet implemented (Phase 3)")
|
||||
os.Exit(1)
|
||||
case "all":
|
||||
slog.Info("all role not yet implemented (runs api + scheduler + notifier in one process)")
|
||||
os.Exit(1)
|
||||
case "version":
|
||||
fmt.Println("oikos dev (Phase 1)")
|
||||
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)
|
||||
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)
|
||||
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 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
|
||||
}
|
||||
Reference in New Issue
Block a user