feat: Phase 7 — SeedService, SecretsService, ProvisioningService + ssh Provisioner

Seed ingest/export moves behind ports.SeedRepository (SeedRepo in the
postgres adapter; knowledge ingest absorbed from internal/knowledge,
package deleted). pct_create flow (defaults, template/VMID/gateway
pre-flights, pct create, graph registration) moves from httpapi's
approved-execution path into app.ProvisioningService + the ssh
provisioner adapter; CLI seed/export/secret become adapters over the
services. EntityCreateInput gains EnrolledAt. Plan status corrected:
phases 0-7 shipped, 8 + 9 gates open. VERSION 0.34.1.
This commit is contained in:
2026-08-16 09:11:26 +02:00
parent cd44501fa9
commit 60c0432d8b
26 changed files with 1565 additions and 689 deletions

View File

@@ -13,14 +13,15 @@ import (
"github.com/dtoro/oikos/internal/config"
"github.com/dtoro/oikos/internal/core/app"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/adapters/postgres"
"github.com/dtoro/oikos/internal/adapters/remote"
"github.com/dtoro/oikos/internal/adapters/ssh"
"github.com/dtoro/oikos/internal/execworker"
"github.com/dtoro/oikos/internal/httpapi"
"github.com/dtoro/oikos/internal/knowledge"
"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()
@@ -111,15 +112,9 @@ func main() {
go schedulerRunner(ctx, pool, cfg)
go execWorkerRunner(ctx, pool, cfg)
// Build composition-root dependencies (ADR 0016).
entityRepo := db.NewEntityRepo(pool)
onto := db.NewOntologyRepo(pool, time.Minute)
readModels := db.NewEntityReader(pool)
entities := app.NewEntityService(entityRepo, onto)
relService := app.NewRelationshipService(db.NewRelRepo(pool), onto)
slog.Info("all: starting api with scheduler + execution-worker in background")
if err := httpapi.ListenAndServe(ctx, pool, cfg, entities, entityRepo, readModels, relService); err != nil {
svc := buildAPIServices(pool)
if err := httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds); err != nil {
slog.Error("api failed", "error", err)
os.Exit(1)
}
@@ -177,6 +172,9 @@ func runMigrate(ctx context.Context, cfg config.Config) error {
return nil
}
// runSeed ingests the seed YAMLs via SeedService (Phase 7): the CLI is
// an adapter over the service; ordering, idempotency (content-hash
// no-op), and per-file logging live in the service/repository pair.
func runSeed(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
@@ -194,96 +192,7 @@ func runSeed(ctx context.Context, cfg config.Config) error {
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
return app.NewSeedService(db.NewSeedRepo(pool)).Ingest(ctx, seedsDir)
}
func runAPI(ctx context.Context, cfg config.Config) error {
@@ -297,18 +206,48 @@ func runAPI(ctx context.Context, cfg config.Config) error {
return fmt.Errorf("migrations: %w", err)
}
// Composition root — build the service dependencies (ADR 0016, plan §3.5).
svc := buildAPIServices(pool)
err = httpapi.ListenAndServe(ctx, pool, cfg, svc.entities, svc.entityRepo, svc.readModels, svc.relService, svc.provisioning, svc.seeds)
if err == http.ErrServerClosed {
return nil
}
return err
}
// apiServices is the composition-root dependency set for the API surface
// (ADR 0016, plan §3.5): repositories, the ssh executor and provisioner,
// the target resolver, and the use-case services built on them.
type apiServices struct {
entities *app.EntityService
entityRepo *db.EntityRepo
readModels ports.ReadModels
relService *app.RelationshipService
provisioning *app.ProvisioningService
seeds *app.SeedService
}
func buildAPIServices(pool *db.Pool) apiServices {
entityRepo := db.NewEntityRepo(pool)
onto := db.NewOntologyRepo(pool, time.Minute)
readModels := db.NewEntityReader(pool)
entities := app.NewEntityService(entityRepo, onto)
relService := app.NewRelationshipService(db.NewRelRepo(pool), onto)
err = httpapi.ListenAndServe(ctx, pool, cfg, entities, entityRepo, readModels, relService)
if err == http.ErrServerClosed {
return nil
executor := ssh.NewExecutor(ssh.FileSignerSource(), 5*time.Minute)
provisioner := ssh.NewProvisioner(executor)
resolver := remote.NewResolver(pool)
provisioning := app.NewProvisioningService(provisioner, resolver, entityRepo, db.NewRelRepo(pool))
seeds := app.NewSeedService(db.NewSeedRepo(pool))
return apiServices{
entities: entities,
entityRepo: entityRepo,
readModels: readModels,
relService: relService,
provisioning: provisioning,
seeds: seeds,
}
return err
}
func runWithPool(ctx context.Context, cfg config.Config, name string, fn func(context.Context, *db.Pool, config.Config)) {
@@ -329,13 +268,15 @@ func runWithPool(ctx context.Context, cfg config.Config, name string, fn func(co
func runSecret(ctx context.Context, cfg config.Config) {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: oikos secret <get|set|list|migrate|export-sops>")
fmt.Fprintln(os.Stderr, "usage: oikos secret <get|set|list|verify|audit|migrate|export-sops>")
os.Exit(1)
}
sub := os.Args[2]
// For get/set/list: use Infisical directly
// get/set/list go through SecretsService (Phase 7); the CLI is an
// adapter over the service. verify/audit/migrate/export-sops remain
// backend-specific diagnostics.
switch sub {
case "get":
if len(os.Args) < 4 {
@@ -343,8 +284,8 @@ func runSecret(ctx context.Context, cfg config.Config) {
os.Exit(1)
}
key := os.Args[3]
backend := newInfisicalBackendOrFail(cfg)
val, err := backend.Get(ctx, key)
svc := app.NewSecretsService(newInfisicalBackendOrFail(cfg))
val, err := svc.Get(ctx, key)
if err != nil {
slog.Error("secret get", "key", key, "error", err)
os.Exit(1)
@@ -358,16 +299,16 @@ func runSecret(ctx context.Context, cfg config.Config) {
}
key := os.Args[3]
value := os.Args[4]
backend := newInfisicalBackendOrFail(cfg)
if err := backend.Set(ctx, key, value); err != nil {
svc := app.NewSecretsService(newInfisicalBackendOrFail(cfg))
if err := svc.Set(ctx, key, value); err != nil {
slog.Error("secret set", "key", key, "error", err)
os.Exit(1)
}
fmt.Printf("stored: %s\n", key)
case "list":
backend := newInfisicalBackendOrFail(cfg)
keys, err := backend.List(ctx)
svc := app.NewSecretsService(newInfisicalBackendOrFail(cfg))
keys, err := svc.List(ctx)
if err != nil {
slog.Error("secret list", "error", err)
os.Exit(1)
@@ -544,6 +485,8 @@ func runSecretLegacy(ctx context.Context, cfg config.Config, sub string) {
}
}
// runExport regenerates the seed YAMLs from the DB via SeedService and
// writes them into the seeds dir (DR / version control).
func runExport(ctx context.Context, cfg config.Config) error {
pool, err := db.New(ctx, cfg.DatabaseURL)
if err != nil {
@@ -551,7 +494,7 @@ func runExport(ctx context.Context, cfg config.Config) error {
}
defer pool.Close()
exports, err := db.ExportToYAML(ctx, pool)
exports, err := app.NewSeedService(db.NewSeedRepo(pool)).Export(ctx)
if err != nil {
return err
}