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.
381 lines
11 KiB
Go
381 lines
11 KiB
Go
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
|
|
}
|