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