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.
240 lines
7.0 KiB
Go
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)
|
|
}
|