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