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

@@ -259,10 +259,10 @@ func (r *EntityRepo) Create(ctx context.Context, in ports.EntityCreateInput) (do
}
created, err := scanDomainEntity(tx.QueryRow(ctx, `
INSERT INTO entities (id, slug, type, name, state, attributes)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING `+entityFullCols,
mustUUID(e.ID), e.Slug, e.Type, e.Name, state, attrsJSON))
mustUUID(e.ID), e.Slug, e.Type, e.Name, state, attrsJSON, in.EnrolledAt))
if err != nil {
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
return domain.Entity{}, errors.Join(domain.ErrAlreadyExists, err)

View File

@@ -0,0 +1,380 @@
package db
import (
"context"
"encoding/json"
"fmt"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// SeedRepo implements ports.SeedRepository over the postgres pool. Each
// Ingest method wraps Pool.SeedIngest (hash no-op + one transaction +
// seed_versions recording) around the file's ingest functions; the
// knowledge ingest moved here from internal/knowledge when SeedService
// absorbed seeding (Phase 7) — raw SQL over the open transaction belongs
// in the adapter.
type SeedRepo struct {
pool *Pool
}
var _ ports.SeedRepository = (*SeedRepo)(nil)
// NewSeedRepo builds the seed repository.
func NewSeedRepo(pool *Pool) *SeedRepo { return &SeedRepo{pool: pool} }
// IngestOntology ingests seeds/ontology.yaml (lifecycles, entity types,
// relationship types) — one transaction, no-op on unchanged hash.
func (r *SeedRepo) IngestOntology(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestOntologySeed(ctx, tx, data)
if err != nil {
return err
}
counts.Lifecycles = res.Lifecycles
counts.EntityTypes = res.EntityTypes
counts.RelationshipTypes = res.RelationshipTypes
applied = true
return nil
})
return counts, applied, err
}
// IngestInventory ingests seeds/inventory.yaml (entities, relationships,
// derived default checks) with ontology validation — a violating seed
// rolls back atomically.
func (r *SeedRepo) IngestInventory(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestInventorySeed(ctx, tx, data)
if err != nil {
return err
}
counts.Entities = res.Entities
counts.Relationships = res.Relationships
counts.Checks = res.Checks
applied = true
return nil
})
return counts, applied, err
}
// IngestPolicy ingests seeds/policy.yaml (risk classes, approval rules,
// autonomy settings).
func (r *SeedRepo) IngestPolicy(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestPolicySeed(ctx, tx, data)
if err != nil {
return err
}
counts.RiskClasses = res.RiskClasses
counts.ApprovalRules = res.ApprovalRules
counts.AutonomySettings = res.AutonomySettings
applied = true
return nil
})
return counts, applied, err
}
// IngestKnowledge ingests seeds/knowledge.yaml (documents,
// investigations, runbooks) as knowledge entities linked into the graph.
func (r *SeedRepo) IngestKnowledge(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error) {
var counts ports.SeedCounts
applied := false
err := r.pool.SeedIngest(ctx, filename, content, func(ctx context.Context, tx pgx.Tx, data map[string]any) error {
res, err := IngestKnowledgeSeed(ctx, tx, data)
if err != nil {
return err
}
counts.Documents = res.Documents
counts.Investigations = res.Investigations
counts.Runbooks = res.Runbooks
applied = true
return nil
})
return counts, applied, err
}
// Export regenerates the three structural seed YAMLs from the DB.
func (r *SeedRepo) Export(ctx context.Context) (map[string][]byte, error) {
return ExportToYAML(ctx, r.pool)
}
// KnowledgeSeedResult holds counts from the knowledge seed ingest.
type KnowledgeSeedResult struct {
Documents int
Investigations int
Runbooks int
}
// IngestKnowledgeSeed ingests the knowledge seed (documents,
// investigations, runbooks) into knowledge entities plus their graph
// edges, inside the caller's ingest transaction. Absorbed from
// internal/knowledge (Phase 7): the logic is seed ingest, its home is the
// seed repository.
func IngestKnowledgeSeed(ctx context.Context, tx pgx.Tx, data map[string]any) (*KnowledgeSeedResult, error) {
r := &KnowledgeSeedResult{}
docs, _ := data["documents"].([]any)
for _, raw := range docs {
d, _ := raw.(map[string]any)
if err := ingestDocument(ctx, tx, d); err != nil {
return nil, fmt.Errorf("document %v: %w", seedStr(d, "slug"), err)
}
r.Documents++
}
invs, _ := data["investigations"].([]any)
for _, raw := range invs {
m, _ := raw.(map[string]any)
if err := ingestInvestigation(ctx, tx, m); err != nil {
return nil, fmt.Errorf("investigation %v: %w", seedStr(m, "slug"), err)
}
r.Investigations++
}
rbs, _ := data["runbooks"].([]any)
for _, raw := range rbs {
m, _ := raw.(map[string]any)
if err := ingestRunbook(ctx, tx, m); err != nil {
return nil, fmt.Errorf("runbook %v: %w", seedStr(m, "slug"), err)
}
r.Runbooks++
}
return r, nil
}
func seedStr(m map[string]any, key string) string {
s, _ := m[key].(string)
return s
}
func seedStrSlice(m map[string]any, key string) []string {
raw, _ := m[key].([]any)
var out []string
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func ingestDocument(ctx context.Context, tx pgx.Tx, m map[string]any) error {
slug := seedStr(m, "slug")
title := seedStr(m, "title")
content := seedStr(m, "content")
entitySlug := seedStr(m, "entity_slug")
tags := seedStrSlice(m, "tags")
atGlance, _ := m["at_glance"].(map[string]any)
clRaw, _ := m["changelog"].([]any)
entityDocSlug := "document:" + slug
if err := upsertKnowledgeEntity(ctx, tx, entityDocSlug, "document", title, content, slug, tags); err != nil {
return err
}
attrs := map[string]any{}
if len(atGlance) > 0 {
attrs["at_glance"] = atGlance
}
if len(clRaw) > 0 {
attrs["changelog"] = clRaw
}
if len(attrs) > 0 {
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = attributes || $1, updated_at = now()
WHERE slug = $2`, string(attrsBytes), entityDocSlug)
if err != nil {
return fmt.Errorf("update document attrs: %w", err)
}
}
if entitySlug != "" {
if err := createSeedEdge(ctx, tx, entityDocSlug, entitySlug, "documents", nil); err != nil {
return fmt.Errorf("link document: %w", err)
}
}
if len(atGlance) > 0 && entitySlug != "" {
backfillAttrs, _ := json.Marshal(atGlance)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = $1 || attributes, updated_at = now()
WHERE slug = $2`, string(backfillAttrs), entitySlug)
if err != nil {
return fmt.Errorf("backfill entity attrs: %w", err)
}
}
return nil
}
func ingestInvestigation(ctx context.Context, tx pgx.Tx, m map[string]any) error {
slug := seedStr(m, "slug")
title := seedStr(m, "title")
content := seedStr(m, "content")
date := seedStr(m, "date")
status := seedStr(m, "status")
duration := seedStr(m, "duration")
aboutSlugs := seedStrSlice(m, "about_slugs")
tags := seedStrSlice(m, "tags")
entitySlug := "investigation:" + slug
if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "investigation", title, content, slug, tags); err != nil {
return err
}
attrs := map[string]any{}
if date != "" {
attrs["date"] = date
}
if status != "" {
attrs["status"] = status
}
if duration != "" {
attrs["duration"] = duration
}
if len(attrs) > 0 {
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = attributes || $1, updated_at = now()
WHERE slug = $2`, string(attrsBytes), entitySlug)
if err != nil {
return fmt.Errorf("update investigation attrs: %w", err)
}
}
for _, aboutSlug := range aboutSlugs {
if err := createSeedEdge(ctx, tx, entitySlug, aboutSlug, "about", nil); err != nil {
return fmt.Errorf("link investigation about %s: %w", aboutSlug, err)
}
}
return nil
}
func ingestRunbook(ctx context.Context, tx pgx.Tx, m map[string]any) error {
slug := seedStr(m, "slug")
name := seedStr(m, "name")
riskClass := seedStr(m, "risk_class")
entityType := seedStr(m, "entity_type")
content := seedStr(m, "content")
tags := seedStrSlice(m, "tags")
procedure, _ := m["procedure"].(map[string]any)
entitySlug := "runbook:" + slug
if err := upsertKnowledgeEntity(ctx, tx, entitySlug, "runbook", name, content, slug, tags); err != nil {
return err
}
attrs := map[string]any{}
if riskClass != "" {
attrs["risk_class"] = riskClass
}
if entityType != "" {
attrs["applies_to_type"] = entityType
}
if len(procedure) > 0 {
attrs["procedure"] = procedure
}
if len(attrs) > 0 {
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`UPDATE entities SET attributes = attributes || $1, updated_at = now()
WHERE slug = $2`, string(attrsBytes), entitySlug)
if err != nil {
return fmt.Errorf("update runbook attrs: %w", err)
}
}
return nil
}
func upsertKnowledgeEntity(ctx context.Context, tx pgx.Tx, slug, entityType, title, content, source string, tags []string) error {
id, err := getOrCreateKnowledgeEntity(ctx, tx, slug, entityType, title)
if err != nil {
return err
}
hash := contentHash([]byte(content))
tagArray := toPGArray(tags)
_, err = tx.Exec(ctx,
`INSERT INTO knowledge_entities (entity_id, title, content, source, tags, content_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now(), now())
ON CONFLICT (entity_id) DO UPDATE SET
title = $2, content = $3, source = $4, tags = $5,
content_hash = $6, updated_at = now()`,
id, title, content, source, tagArray, hash)
return err
}
func getOrCreateKnowledgeEntity(ctx context.Context, tx pgx.Tx, slug, entityType, name string) (uuid.UUID, error) {
var id uuid.UUID
err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", slug).Scan(&id)
if err == nil {
return id, nil
}
if err != pgx.ErrNoRows {
return uuid.Nil, fmt.Errorf("lookup entity %s: %w", slug, err)
}
id, err = uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("generate uuid: %w", err)
}
_, err = tx.Exec(ctx,
`INSERT INTO entities (id, slug, type, name, state, attributes, version, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULL, '{}', 1, now(), now())`,
id, slug, entityType, name)
if err != nil {
return uuid.Nil, fmt.Errorf("create entity %s: %w", slug, err)
}
return id, nil
}
func createSeedEdge(ctx context.Context, tx pgx.Tx, sourceSlug, targetSlug, relType string, attrs map[string]any) error {
var sourceID, targetID uuid.UUID
if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", sourceSlug).Scan(&sourceID); err != nil {
return fmt.Errorf("source %s: %w", sourceSlug, err)
}
if err := tx.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&targetID); err != nil {
return fmt.Errorf("target %s: %w", targetSlug, err)
}
attrsBytes, _ := json.Marshal(attrs)
_, err := tx.Exec(ctx,
`INSERT INTO relationships (source_id, target_id, type, attributes, valid_from, valid_to)
VALUES ($1, $2, $3, $4, now(), NULL)
ON CONFLICT (source_id, target_id, type) WHERE valid_to IS NULL
DO UPDATE SET attributes = EXCLUDED.attributes`,
sourceID, targetID, relType, string(attrsBytes))
return err
}
func toPGArray(tags []string) string {
if len(tags) == 0 {
return "{}"
}
out := "{"
for i, t := range tags {
if i > 0 {
out += ","
}
out += `"` + t + `"`
}
out += "}"
return out
}

View File

@@ -0,0 +1,65 @@
package db
import (
"reflect"
"testing"
)
// Tests for the seed-helper functions absorbed from internal/knowledge
// (Phase 7).
func TestSeedStr(t *testing.T) {
cases := []struct {
name string
m map[string]any
key string
want string
}{
{"missing key", map[string]any{}, "nope", ""},
{"string value", map[string]any{"k": "v"}, "k", "v"},
{"int value", map[string]any{"k": 42}, "k", ""},
{"nil value", map[string]any{"k": nil}, "k", ""},
{"empty string", map[string]any{"k": ""}, "k", ""},
}
for _, c := range cases {
if got := seedStr(c.m, c.key); got != c.want {
t.Errorf("%s: seedStr(%v, %q) = %q, want %q", c.name, c.m, c.key, got, c.want)
}
}
}
func TestSeedStrSlice(t *testing.T) {
cases := []struct {
name string
m map[string]any
key string
want []string
}{
{"missing key", map[string]any{}, "tags", nil},
{"string list", map[string]any{"tags": []any{"a", "b"}}, "tags", []string{"a", "b"}},
{"mixed list drops non-strings", map[string]any{"tags": []any{"a", 1, "b"}}, "tags", []string{"a", "b"}},
{"empty list", map[string]any{"tags": []any{}}, "tags", nil},
}
for _, c := range cases {
if got := seedStrSlice(c.m, c.key); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: seedStrSlice(%v, %q) = %v, want %v", c.name, c.m, c.key, got, c.want)
}
}
}
func TestToPGArray(t *testing.T) {
cases := []struct {
in []string
want string
}{
{nil, "{}"},
{[]string{}, "{}"},
{[]string{"ops"}, `{"ops"}`},
{[]string{"ops", "network"}, `{"ops","network"}`},
}
for _, c := range cases {
if got := toPGArray(c.in); got != c.want {
t.Errorf("toPGArray(%v) = %q, want %q", c.in, got, c.want)
}
}
}