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

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