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)
}
}