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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user