Files
oikos/internal/core/app/provisioning.go
dtoro 60c0432d8b 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.
2026-08-16 09:11:26 +02:00

240 lines
7.0 KiB
Go

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