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:
@@ -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