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:
239
internal/core/app/provisioning.go
Normal file
239
internal/core/app/provisioning.go
Normal 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)
|
||||
}
|
||||
125
internal/core/app/provisioning_test.go
Normal file
125
internal/core/app/provisioning_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
internal/core/app/secrets.go
Normal file
42
internal/core/app/secrets.go
Normal 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
95
internal/core/app/seed.go
Normal 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)
|
||||
}
|
||||
98
internal/core/app/seed_test.go
Normal file
98
internal/core/app/seed_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
43
internal/core/ports/seeds.go
Normal file
43
internal/core/ports/seeds.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user