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

@@ -0,0 +1,239 @@
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/google/uuid"
)
// ProvisioningService owns the pct_create use-case: validate + default
// the spec, run the provisioner (SSH-side template resolution, VMID
// guard, preflight, pct create), then register the guest in the entity
// graph. Absorbed the pct_create arm of httpapi's approved-execution
// path (Phase 7); the execution bookkeeping (status rows, events, log
// streaming to the SPA) stays with the calling adapter.
//
// Registration semantics are deliberately best-effort, as before: a
// failed entity/edge write is logged, never fatal — the container exists
// on the host, and the graph can be repaired by re-seeding or manual
// upsert. Failing the execution after a successful create would report
// a provisioning failure for a container that actually exists.
type ProvisioningService struct {
provisioner ports.Provisioner
resolver ports.TargetResolver
entities ports.EntityRepository
rels ports.RelationshipRepository
}
// NewProvisioningService wires the service.
func NewProvisioningService(
provisioner ports.Provisioner,
resolver ports.TargetResolver,
entities ports.EntityRepository,
rels ports.RelationshipRepository,
) *ProvisioningService {
return &ProvisioningService{provisioner: provisioner, resolver: resolver, entities: entities, rels: rels}
}
// CreateLXCCmd is one LXC create request. Only Hostname is required;
// zero fields get lab defaults via DefaultLXCSpec.
type CreateLXCCmd struct {
HostSlug string // the Proxmox host (host:<slug>) that runs pct
Hostname string
VMID int
Cores int
MemoryMB int
DiskGB int
IP string
GW string
Bridge string
Storage string
Template string
Privileged bool
Nesting bool
Mounts []string
Nameserver string
Searchdomain string
// Sink, when non-nil, receives the create command's output chunks as
// they arrive (execution-log streaming).
Sink func(stream string, chunk []byte)
}
// DefaultLXCSpec fills the lab defaults. Kept as data (not baked into
// the adapter) so tests and future callers can see and share them.
func DefaultLXCSpec(cmd CreateLXCCmd) ports.LXCInput {
in := ports.LXCInput{
HostSlug: cmd.HostSlug,
Hostname: cmd.Hostname,
VMID: cmd.VMID,
Cores: cmd.Cores,
MemoryMB: cmd.MemoryMB,
DiskGB: cmd.DiskGB,
IP: cmd.IP,
GW: cmd.GW,
Bridge: cmd.Bridge,
Storage: cmd.Storage,
Template: cmd.Template,
Privileged: cmd.Privileged,
Nesting: cmd.Nesting,
Mounts: cmd.Mounts,
Nameserver: cmd.Nameserver,
Searchdomain: cmd.Searchdomain,
Sink: cmd.Sink,
}
if in.Cores == 0 {
in.Cores = 1
}
if in.MemoryMB == 0 {
in.MemoryMB = 512
}
if in.DiskGB == 0 {
in.DiskGB = 8
}
if in.Storage == "" {
in.Storage = "local-lvm"
}
if in.GW == "" {
in.GW = "192.168.8.2"
}
if in.Bridge == "" {
in.Bridge = "vmbr0"
}
if in.Nameserver == "" {
in.Nameserver = "192.168.8.2"
}
if in.Searchdomain == "" {
in.Searchdomain = "hubris.network"
}
return in
}
// LXCOutcome reports a completed create: the graph slug, the VMID the
// cluster actually assigned (may differ from the request), and the
// command output.
type LXCOutcome struct {
Slug string
VMID int
Template string
Output string
}
// CreateLXC validates the request, provisions the container on the
// resolved Proxmox host, and registers it in the graph. pct_create is
// atomic by design: create + start + register, nothing else — package
// installs and post-install scripts are the agent's own follow-up `run`
// calls so each step is individually observable and recoverable.
func (s *ProvisioningService) CreateLXC(ctx context.Context, cmd CreateLXCCmd) (LXCOutcome, error) {
if cmd.Hostname == "" {
return LXCOutcome{}, errors.New("pct_create: hostname is required")
}
in := DefaultLXCSpec(cmd)
addr, user, err := s.resolver.ResolveHost(ctx, in.HostSlug, "")
if err != nil {
return LXCOutcome{}, err
}
in.HostAddr, in.HostUser = addr, user
res, err := s.provisioner.CreateLXC(ctx, in)
if err != nil {
return LXCOutcome{}, err
}
out := LXCOutcome{
Slug: "lxc:" + in.Hostname,
VMID: res.VMID,
Template: res.Template,
Output: res.Output,
}
s.registerLXC(ctx, in, out)
return out, nil
}
// registerLXC best-effort writes the guest into the graph: entity
// (provisioning state, enrolled now), hosts edge from the Proxmox host,
// and the entity_status row (via the create's derived-checks path).
// Failures are logged, not returned — see the service comment.
func (s *ProvisioningService) registerLXC(ctx context.Context, in ports.LXCInput, out LXCOutcome) {
id, err := uuid.NewV7()
if err != nil {
slog.Error("provisioning: generate entity id", "error", err, "slug", out.Slug)
return
}
now := time.Now()
attrs := map[string]any{
"pve_id": fmt.Sprintf("%d", out.VMID),
"host": strings.TrimPrefix(in.HostSlug, "host:"),
}
if in.IP != "" && !strings.EqualFold(in.IP, "dhcp") {
attrs["ip"] = in.IP
}
guestID := domain.UUID(id.String())
created, err := s.entities.Create(ctx, ports.EntityCreateInput{
Entity: domain.Entity{
ID: guestID,
Slug: out.Slug,
Type: "lxc",
Name: in.Hostname,
State: "provisioning",
Attributes: attrs,
},
EnrolledAt: &now,
})
switch {
case errors.Is(err, domain.ErrAlreadyExists):
// Re-provision of a known slug: link the edge to the existing row.
existing, lerr := s.entities.BySlug(ctx, out.Slug)
if lerr != nil {
slog.Error("provisioning: resolve existing lxc entity", "error", lerr, "slug", out.Slug)
return
}
guestID = existing.ID
case err != nil:
slog.Error("provisioning: lxc entity register", "error", err, "slug", out.Slug)
return
default:
guestID = created.ID
}
host, herr := s.entities.BySlug(ctx, in.HostSlug)
if herr != nil {
slog.Error("provisioning: resolve host entity for edge", "error", herr, "host", in.HostSlug)
return
}
if _, rerr := s.rels.Create(ctx, ports.RelationshipCreateInput{
Relationship: domain.Relationship{
SourceID: host.ID,
TargetID: guestID,
Type: "hosts",
Attributes: map[string]any{"provisioned_by": "nomos"},
},
}); rerr != nil && !errors.Is(rerr, domain.ErrAlreadyExists) {
slog.Error("provisioning: hosts edge register", "error", rerr, "host", in.HostSlug, "lxc", out.Slug)
}
slog.Info("provisioning: lxc entity registered", "slug", out.Slug, "vmid", out.VMID, "host", in.HostSlug)
}
// CreateVM provisions a VM via qm on a Proxmox host. Reserved for the
// qm flow; the ssh adapter reports not-implemented until it lands.
func (s *ProvisioningService) CreateVM(ctx context.Context, cmd ports.VMInput) (ports.ProvisionResult, error) {
addr, user, err := s.resolver.ResolveHost(ctx, cmd.HostSlug, "")
if err != nil {
return ports.ProvisionResult{}, err
}
cmd.HostAddr, cmd.HostUser = addr, user
return s.provisioner.CreateVM(ctx, cmd)
}

View File

@@ -0,0 +1,125 @@
package app
import (
"context"
"errors"
"testing"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/dtoro/oikos/internal/core/ports/portstest"
)
func newProvisioningTestDeps() (*portstest.FakeProvisioner, *portstest.FakeResolver, *portstest.EntityRepo, *portstest.RelRepo, *ProvisioningService) {
prov := &portstest.FakeProvisioner{LXCRes: ports.ProvisionResult{VMID: 142, Template: "debian-13-standard_13.0-1_amd64.tar.zst", Output: "create ok"}}
resolver := &portstest.FakeResolver{Addr: "10.0.0.5", User: "root"}
entities := portstest.NewEntityRepo()
rels := portstest.NewRelRepo()
entities.Create(context.Background(), ports.EntityCreateInput{
Entity: domain.Entity{ID: "host-1", Slug: "host:hubris", Type: "proxmox-host", Name: "hubris", State: "active"},
})
svc := NewProvisioningService(prov, resolver, entities, rels)
return prov, resolver, entities, rels, svc
}
func TestProvisioningCreateLXCRequiresHostname(t *testing.T) {
_, _, _, _, svc := newProvisioningTestDeps()
_, err := svc.CreateLXC(context.Background(), CreateLXCCmd{HostSlug: "host:hubris"})
if err == nil || err.Error() != "pct_create: hostname is required" {
t.Fatalf("got %v, want hostname-required error", err)
}
}
func TestProvisioningCreateLXCHappyPath(t *testing.T) {
prov, resolver, entities, rels, svc := newProvisioningTestDeps()
out, err := svc.CreateLXC(context.Background(), CreateLXCCmd{
HostSlug: "host:hubris",
Hostname: "grafana",
IP: "192.168.8.55",
})
if err != nil {
t.Fatalf("CreateLXC: %v", err)
}
if out.Slug != "lxc:grafana" || out.VMID != 142 {
t.Errorf("outcome = %+v, want slug lxc:grafana vmid 142", out)
}
// Provisioner saw the resolved endpoint and the defaults.
if len(prov.LXCs) != 1 {
t.Fatalf("provisioner calls = %d, want 1", len(prov.LXCs))
}
in := prov.LXCs[0]
if in.HostAddr != "10.0.0.5" || in.HostUser != "root" {
t.Errorf("resolved endpoint = %s@%s, want root@10.0.0.5", in.HostUser, in.HostAddr)
}
if in.Cores != 1 || in.MemoryMB != 512 || in.DiskGB != 8 || in.Storage != "local-lvm" {
t.Errorf("defaults not applied: %+v", in)
}
if in.Bridge != "vmbr0" || in.GW != "192.168.8.2" || in.Searchdomain != "hubris.network" {
t.Errorf("network defaults not applied: %+v", in)
}
// Entity registered: provisioning state, enrolled, vmid/host attrs.
got, ok := entities.FindBySlug("lxc:grafana")
if !ok {
t.Fatal("lxc:grafana not registered")
}
if got.Type != "lxc" || got.State != "provisioning" {
t.Errorf("entity = %s/%s, want lxc/provisioning", got.Type, got.State)
}
if got.Attributes["pve_id"] != "142" || got.Attributes["host"] != "hubris" || got.Attributes["ip"] != "192.168.8.55" {
t.Errorf("attrs = %v", got.Attributes)
}
if _, enrolled := entities.Enrolled[got.ID]; !enrolled {
t.Error("entity not enrolled")
}
// Hosts edge from the Proxmox host to the guest.
edges, err := rels.ListFor(context.Background(), domain.UUID("host-1"), "outbound")
if err != nil || len(edges) != 1 {
t.Fatalf("host edges = %v err=%v, want 1", edges, err)
}
if edges[0].Type != "hosts" || edges[0].TargetID != got.ID {
t.Errorf("edge = %+v, want hosts → %s", edges[0], got.ID)
}
if edges[0].Attributes["provisioned_by"] != "nomos" {
t.Errorf("edge attrs = %v, want provisioned_by=nomos", edges[0].Attributes)
}
_ = resolver // endpoint asserted via provisioner input
}
func TestProvisioningCreateLXCProvisionerFailureRegistersNothing(t *testing.T) {
prov, _, entities, rels, svc := newProvisioningTestDeps()
prov.LXCErr = errors.New("no usable LXC template")
if _, err := svc.CreateLXC(context.Background(), CreateLXCCmd{HostSlug: "host:hubris", Hostname: "x"}); err == nil {
t.Fatal("expected provisioner error to propagate")
}
if _, ok := entities.FindBySlug("lxc:x"); ok {
t.Error("failed create must not register an entity")
}
if edges, _ := rels.ListFor(context.Background(), domain.UUID("host-1"), "outbound"); len(edges) != 0 {
t.Errorf("failed create must not create edges, got %v", edges)
}
}
func TestProvisioningCreateLXCRegistrationBestEffort(t *testing.T) {
_, _, entities, rels, svc := newProvisioningTestDeps()
entities.ErrStub = errors.New("db down")
// The container exists on the host; a graph write failure must not
// fail the provisioning outcome.
out, err := svc.CreateLXC(context.Background(), CreateLXCCmd{HostSlug: "host:hubris", Hostname: "y"})
if err != nil {
t.Fatalf("CreateLXC should survive registration failure: %v", err)
}
if out.VMID != 142 {
t.Errorf("outcome = %+v, want provisioner result", out)
}
if edges, _ := rels.ListFor(context.Background(), domain.UUID("host-1"), "outbound"); len(edges) != 0 {
t.Errorf("no edges expected when entity write failed, got %v", edges)
}
}

View File

@@ -0,0 +1,42 @@
package app
import (
"context"
"github.com/dtoro/oikos/internal/core/ports"
)
// SecretsService is the secrets use-case surface. get/list are direct
// reads; Set is a direct write for the CLI/operator path — routing agent
// writes through the approval flow (plan §3.4) lands with the governance
// slice (PolicyService/ExecutionService), at which point the MCP
// set_secret tool converges here too.
type SecretsService struct {
backend ports.Secrets
}
// NewSecretsService wires the service over a secrets backend
// (Infisical primary, SOPS DR fallback).
func NewSecretsService(backend ports.Secrets) *SecretsService {
return &SecretsService{backend: backend}
}
// Get retrieves one secret value by key.
func (s *SecretsService) Get(ctx context.Context, key string) (string, error) {
return s.backend.Get(ctx, key)
}
// List returns all secret keys (no values).
func (s *SecretsService) List(ctx context.Context) ([]string, error) {
return s.backend.List(ctx)
}
// Set stores or updates a secret.
func (s *SecretsService) Set(ctx context.Context, key, value string) error {
return s.backend.Set(ctx, key, value)
}
// Name reports the active backend (for diagnostics).
func (s *SecretsService) Name() string {
return s.backend.Name()
}

95
internal/core/app/seed.go Normal file
View File

@@ -0,0 +1,95 @@
package app
import (
"context"
"fmt"
"log/slog"
"os"
"github.com/dtoro/oikos/internal/core/ports"
)
// SeedService is the bootstrap/DR use-case: ingest the seeds/*.yaml files
// into the DB (idempotent, ontology-validated) and regenerate them from
// the DB for version control. Absorbed the ingest orchestration that
// lived in cmd/oikos runSeed and the export entry point in runExport
// (Phase 7); the SQL stays behind ports.SeedRepository in the postgres
// adapter.
type SeedService struct {
repo ports.SeedRepository
}
// NewSeedService wires the service.
func NewSeedService(repo ports.SeedRepository) *SeedService {
return &SeedService{repo: repo}
}
// Ingest reads the seed YAMLs from dir and ingests them in dependency
// order: ontology (types) before inventory (entities/edges validated
// against them), then policy, then knowledge — which is optional and
// skipped silently when absent. Files whose content hash is unchanged
// are no-ops at the repository layer.
func (s *SeedService) Ingest(ctx context.Context, dir string) error {
type step struct {
file string
ingest func(ctx context.Context, filename string, content []byte) (ports.SeedCounts, bool, error)
optional bool
}
steps := []step{
{"ontology.yaml", s.repo.IngestOntology, false},
{"inventory.yaml", s.repo.IngestInventory, false},
{"policy.yaml", s.repo.IngestPolicy, false},
{"knowledge.yaml", s.repo.IngestKnowledge, true},
}
for _, st := range steps {
content, err := os.ReadFile(dir + "/" + st.file)
if err != nil {
if os.IsNotExist(err) && st.optional {
slog.Info("knowledge seed not found, skipping")
continue
}
return fmt.Errorf("read %s seed: %w", st.file, err)
}
counts, applied, err := st.ingest(ctx, st.file, content)
if err != nil {
return err
}
if !applied {
continue
}
switch st.file {
case "ontology.yaml":
slog.Info("ontology ingested",
"lifecycles", counts.Lifecycles,
"entity_types", counts.EntityTypes,
"relationship_types", counts.RelationshipTypes)
case "inventory.yaml":
slog.Info("inventory ingested",
"entities", counts.Entities,
"relationships", counts.Relationships)
case "policy.yaml":
slog.Info("policy ingested",
"risk_classes", counts.RiskClasses,
"approval_rules", counts.ApprovalRules,
"autonomy_settings", counts.AutonomySettings)
case "knowledge.yaml":
slog.Info("knowledge ingested",
"documents", counts.Documents,
"investigations", counts.Investigations,
"runbooks", counts.Runbooks)
}
}
slog.Info("seed ingest complete")
return nil
}
// Export regenerates the seed YAMLs from the DB (DR / version control).
// Deterministic: the repository orders lists and sorts map keys so
// export → ingest → export is byte-stable.
func (s *SeedService) Export(ctx context.Context) (map[string][]byte, error) {
return s.repo.Export(ctx)
}

View File

@@ -0,0 +1,98 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/dtoro/oikos/internal/core/ports/portstest"
)
func writeSeedFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestSeedServiceIngestOrder(t *testing.T) {
dir := t.TempDir()
writeSeedFile(t, dir, "ontology.yaml", "a: 1")
writeSeedFile(t, dir, "inventory.yaml", "b: 2")
writeSeedFile(t, dir, "policy.yaml", "c: 3")
writeSeedFile(t, dir, "knowledge.yaml", "d: 4")
repo := portstest.NewSeedRepo()
if err := NewSeedService(repo).Ingest(context.Background(), dir); err != nil {
t.Fatalf("Ingest: %v", err)
}
want := []string{"ontology.yaml", "inventory.yaml", "policy.yaml", "knowledge.yaml"}
if len(repo.Order) != len(want) {
t.Fatalf("ingested %v, want %v", repo.Order, want)
}
for i, f := range want {
if repo.Order[i] != f {
t.Errorf("order[%d] = %s, want %s", i, repo.Order[i], f)
}
}
}
func TestSeedServiceKnowledgeOptional(t *testing.T) {
dir := t.TempDir()
writeSeedFile(t, dir, "ontology.yaml", "a: 1")
writeSeedFile(t, dir, "inventory.yaml", "b: 2")
writeSeedFile(t, dir, "policy.yaml", "c: 3")
repo := portstest.NewSeedRepo()
if err := NewSeedService(repo).Ingest(context.Background(), dir); err != nil {
t.Fatalf("Ingest without knowledge seed: %v", err)
}
for _, f := range repo.Order {
if f == "knowledge.yaml" {
t.Error("knowledge.yaml must be skipped when absent")
}
}
}
func TestSeedServiceMissingStructuralSeedFails(t *testing.T) {
dir := t.TempDir()
writeSeedFile(t, dir, "ontology.yaml", "a: 1")
// inventory.yaml and policy.yaml absent — both are required.
repo := portstest.NewSeedRepo()
err := NewSeedService(repo).Ingest(context.Background(), dir)
if err == nil {
t.Fatal("missing inventory seed must fail the ingest")
}
if repo.Order[len(repo.Order)-1] != "ontology.yaml" {
t.Errorf("ingest stopped at %v, want ontology.yaml only", repo.Order)
}
}
func TestSeedServiceIngestErrorPropagates(t *testing.T) {
dir := t.TempDir()
writeSeedFile(t, dir, "ontology.yaml", "a: 1")
writeSeedFile(t, dir, "inventory.yaml", "b: 2")
repo := portstest.NewSeedRepo()
repo.IngestErr["inventory.yaml"] = errors.New("entity bad: unknown type")
err := NewSeedService(repo).Ingest(context.Background(), dir)
if err == nil || !errors.Is(err, repo.IngestErr["inventory.yaml"]) {
t.Fatalf("got %v, want ingest error", err)
}
}
func TestSeedServiceExport(t *testing.T) {
repo := portstest.NewSeedRepo()
repo.ExportFiles = map[string][]byte{"ontology.yaml": []byte("x")}
files, err := NewSeedService(repo).Export(context.Background())
if err != nil {
t.Fatalf("Export: %v", err)
}
if string(files["ontology.yaml"]) != "x" {
t.Errorf("export = %v", files)
}
}

View File

@@ -60,6 +60,9 @@ type EntityCreateInput struct {
Audit []AuditEntry
Event *Event
Idempotency *Idempotency
// EnrolledAt, when set, stamps the entities.enrolled_at column (the
// client-enrollment / provisioning marker). Zero for ordinary creates.
EnrolledAt *time.Time
}
// EntityUpdateInput mutates an entity atomically. ExpectedVersion is the

View File

@@ -47,28 +47,66 @@ type TargetResolver interface {
IsGuest(entityType string) bool
}
// Provisioner creates guests via pct/qm on a Proxmox host (Phase 7 fills
// the input payloads in; signatures firm up with ProvisioningService).
// Provisioner creates guests via pct/qm on a Proxmox host. The ssh
// adapter implements it over CommandExecutor: template-cache resolution,
// cluster-wide VMID collision guard, gateway preflight, and the pct/qm
// create itself. DB registration of the created guest is NOT part of the
// port — ProvisioningService owns that over the entity/relationship
// repositories.
type Provisioner interface {
CreateLXC(ctx context.Context, host domain.UUID, input LXCInput) (domain.UUID, error)
CreateVM(ctx context.Context, host domain.UUID, input VMInput) (domain.UUID, error)
CreateLXC(ctx context.Context, input LXCInput) (ProvisionResult, error)
CreateVM(ctx context.Context, input VMInput) (ProvisionResult, error)
}
// LXCInput is a placeholder until ProvisioningService (Phase 7) fixes the
// create payloads; declared now so the port surface is complete.
// LXCInput is a fully-defaulted LXC create spec (ProvisioningService
// applies defaults before calling). HostAddr/HostUser are the resolved
// SSH endpoint of the Proxmox host; Sink, when non-nil, receives the
// create command's output chunks as they arrive.
type LXCInput struct {
Name string
Template string
Cores int
MemoryMB int
DiskGB int
HostAddr string
HostUser string
HostSlug string
Hostname string
VMID int
Cores int
MemoryMB int
DiskGB int
IP string
GW string
Bridge string
Storage string
Template string
Privileged bool
Nesting bool
Mounts []string
Nameserver string
Searchdomain string
Sink func(stream string, chunk []byte)
}
// VMInput mirrors LXCInput for VM creation via qm.
type VMInput struct {
HostAddr string
HostUser string
HostSlug string
Name string
VMID int
TemplateID int
Cores int
MemoryMB int
DiskGB int
Sink func(stream string, chunk []byte)
}
// ProvisionResult reports what the provisioner actually did — the VMID
// may differ from the request (cluster collision guard reassigns), and
// the template is the concrete cache entry picked.
type ProvisionResult struct {
VMID int
Template string
Output string
}

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
@@ -26,6 +27,7 @@ type EntityRepo struct {
Checks map[domain.UUID][]ports.CheckDef
Idempotent map[string]ports.IdempotentResponse
Rederived []domain.UUID
Enrolled map[domain.UUID]time.Time
ErrStub error // returned by every command when set
}
@@ -115,11 +117,17 @@ func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (doma
in.Entity.ID = domain.UUID(fmt.Sprintf("fake-entity-%03d", r.nextID))
}
if _, dup := r.bySlug[in.Entity.Slug]; dup {
return domain.Entity{}, domain.ErrConflict
return domain.Entity{}, domain.ErrAlreadyExists
}
if in.Entity.Version == 0 {
in.Entity.Version = 1
}
if in.EnrolledAt != nil {
if r.Enrolled == nil {
r.Enrolled = make(map[domain.UUID]time.Time)
}
r.Enrolled[in.Entity.ID] = *in.EnrolledAt
}
r.store(in.Entity)
r.Audits = append(r.Audits, in.Audit...)
if in.Event != nil {
@@ -288,3 +296,192 @@ func (p *SpyPublisher) Publish(_ context.Context, event ports.Event) error {
p.Events = append(p.Events, event)
return p.Err
}
// RelRepo is an in-memory ports.RelationshipRepository.
type RelRepo struct {
mu sync.Mutex
edges []domain.Relationship
// ErrStubFor maps a target entity ID to an error to return when an
// edge onto that target is created (for best-effort-path tests).
ErrStubFor map[domain.UUID]error
}
// NewRelRepo builds an empty in-memory relationship repository.
func NewRelRepo() *RelRepo { return &RelRepo{} }
// Create stores the edge as-is.
func (r *RelRepo) Create(_ context.Context, in ports.RelationshipCreateInput) (domain.Relationship, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.ErrStubFor != nil {
if err, ok := r.ErrStubFor[in.Relationship.TargetID]; ok {
return domain.Relationship{}, err
}
}
r.edges = append(r.edges, in.Relationship)
return in.Relationship, nil
}
// End soft-deletes matching current edges.
func (r *RelRepo) End(_ context.Context, source, target domain.UUID, relType string) error {
r.mu.Lock()
defer r.mu.Unlock()
kept := r.edges[:0]
for _, e := range r.edges {
if e.SourceID == source && e.TargetID == target && e.Type == relType {
continue
}
kept = append(kept, e)
}
r.edges = kept
return nil
}
// ListFor returns edges touching the entity in the given direction
// ("outbound", "inbound", or both).
func (r *RelRepo) ListFor(_ context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error) {
r.mu.Lock()
defer r.mu.Unlock()
var out []domain.Relationship
for _, e := range r.edges {
switch direction {
case "outbound":
if e.SourceID == entityID {
out = append(out, e)
}
case "inbound":
if e.TargetID == entityID {
out = append(out, e)
}
default:
if e.SourceID == entityID || e.TargetID == entityID {
out = append(out, e)
}
}
}
return out, nil
}
// FakeResolver resolves every slug onto a fixed endpoint; check-shaped
// lookups can be overridden per type.
type FakeResolver struct {
Addr string
User string
Err error
// ErrForType maps entity types to a resolve error (check path).
ErrForType map[string]error
}
// ResolveExecTarget resolves any slug to the fixed endpoint.
func (r *FakeResolver) ResolveExecTarget(_ context.Context, _ string) (ports.Target, error) {
if r.Err != nil {
return ports.Target{}, r.Err
}
return ports.Target{Host: r.Addr, User: r.User}, nil
}
// ResolveForCheck resolves a check target by entity type.
func (r *FakeResolver) ResolveForCheck(_ context.Context, _ domain.UUID, entityType string) (ports.Target, error) {
if err, ok := r.ErrForType[entityType]; ok {
return ports.Target{}, err
}
return r.ResolveExecTarget(context.Background(), entityType)
}
// ResolveHost resolves any host slug to the fixed endpoint.
func (r *FakeResolver) ResolveHost(_ context.Context, _, _ string) (string, string, error) {
if r.Err != nil {
return "", "", r.Err
}
return r.Addr, r.User, nil
}
// IsGuest marks guest types reached via pct/qm.
func (r *FakeResolver) IsGuest(entityType string) bool {
return entityType == "lxc" || entityType == "vm"
}
// FakeProvisioner records provision requests and replies with canned
// results (default: a successful create echoing the request's VMID).
type FakeProvisioner struct {
mu sync.Mutex
LXCs []ports.LXCInput
VMs []ports.VMInput
LXCRes ports.ProvisionResult
LXCErr error
VMErr error
}
// CreateLXC records the request and replies with the canned result.
func (p *FakeProvisioner) CreateLXC(_ context.Context, in ports.LXCInput) (ports.ProvisionResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.LXCs = append(p.LXCs, in)
return p.LXCRes, p.LXCErr
}
// CreateVM records the request and replies with the canned error.
func (p *FakeProvisioner) CreateVM(_ context.Context, in ports.VMInput) (ports.ProvisionResult, error) {
p.mu.Lock()
defer p.mu.Unlock()
p.VMs = append(p.VMs, in)
return ports.ProvisionResult{}, p.VMErr
}
// SeedRepo is an in-memory ports.SeedRepository. Files map to canned
// (counts, applied) pairs; every ingest records the file content.
type SeedRepo struct {
mu sync.Mutex
Files map[string][]byte
Applied map[string]bool
IngestErr map[string]error
// Order records the files ingested, in order.
Order []string
ExportFiles map[string][]byte
ExportErr error
}
// NewSeedRepo builds an empty in-memory seed repository.
func NewSeedRepo() *SeedRepo {
return &SeedRepo{
Files: make(map[string][]byte),
Applied: make(map[string]bool),
IngestErr: make(map[string]error),
}
}
func (r *SeedRepo) ingest(file string, content []byte) (ports.SeedCounts, bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
if err := r.IngestErr[file]; err != nil {
return ports.SeedCounts{}, false, err
}
r.Files[file] = content
r.Order = append(r.Order, file)
return ports.SeedCounts{}, r.Applied[file], nil
}
// IngestOntology records the ontology seed file.
func (r *SeedRepo) IngestOntology(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
return r.ingest(file, content)
}
// IngestInventory records the inventory seed file.
func (r *SeedRepo) IngestInventory(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
return r.ingest(file, content)
}
// IngestPolicy records the policy seed file.
func (r *SeedRepo) IngestPolicy(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
return r.ingest(file, content)
}
// IngestKnowledge records the knowledge seed file.
func (r *SeedRepo) IngestKnowledge(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
return r.ingest(file, content)
}
// Export returns the canned export payload.
func (r *SeedRepo) Export(_ context.Context) (map[string][]byte, error) {
return r.ExportFiles, r.ExportErr
}

View File

@@ -0,0 +1,43 @@
package ports
import "context"
// SeedCounts summarizes what one seed ingest wrote, by section. Sections
// the file does not exercise stay zero.
type SeedCounts struct {
Lifecycles int
EntityTypes int
RelationshipTypes int
Entities int
Relationships int
Checks int
RiskClasses int
ApprovalRules int
AutonomySettings int
Documents int
Investigations int
Runbooks int
}
// SeedRepository is the seed aggregate: bootstrap ingest (seeds/*.yaml →
// DB) and the inverse export (DB → seeds/*.yaml for DR / version
// control).
//
// Each Ingest method is one transaction — parse, validate against the
// ontology, write, and record the file's content hash in seed_versions —
// and a no-op (applied=false) when the hash is unchanged, so re-running
// `oikos seed` is idempotent. Validation failures roll back the whole
// file. Ingest order across files (ontology before inventory) is a
// SeedService concern; within inventory, default checks derive only after
// relationships exist (a service inherits its container's address).
//
// Export regenerates the three structural seed YAMLs deterministically
// (sorted maps, ordered lists) so export → ingest → export is
// byte-stable. Runtime state (cognition layer) is excluded.
type SeedRepository interface {
IngestOntology(ctx context.Context, filename string, content []byte) (counts SeedCounts, applied bool, err error)
IngestInventory(ctx context.Context, filename string, content []byte) (counts SeedCounts, applied bool, err error)
IngestPolicy(ctx context.Context, filename string, content []byte) (counts SeedCounts, applied bool, err error)
IngestKnowledge(ctx context.Context, filename string, content []byte) (counts SeedCounts, applied bool, err error)
Export(ctx context.Context) (map[string][]byte, error)
}