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