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.
238 lines
8.7 KiB
Go
238 lines
8.7 KiB
Go
package ssh
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/dtoro/oikos/internal/core/ports"
|
|
)
|
|
|
|
// Provisioner implements ports.Provisioner over CommandExecutor: the
|
|
// pct create flow absorbed from httpapi's approved-execution path
|
|
// (Phase 7). It owns the SSH-side pre-flights — template-cache
|
|
// resolution, cluster-wide VMID collision guard, gateway reachability
|
|
// on the target bridge — and the pct create itself. Guest registration
|
|
// in the entity graph is ProvisioningService's job, not this adapter's.
|
|
type Provisioner struct {
|
|
exec ports.CommandExecutor
|
|
}
|
|
|
|
var _ ports.Provisioner = (*Provisioner)(nil)
|
|
|
|
// NewProvisioner builds the provisioner over a command executor (the
|
|
// dial-pool executor; the target is the Proxmox host, reached directly).
|
|
func NewProvisioner(exec ports.CommandExecutor) *Provisioner {
|
|
return &Provisioner{exec: exec}
|
|
}
|
|
|
|
func (p *Provisioner) target(in ports.LXCInput) ports.Target {
|
|
return ports.Target{Host: in.HostAddr, User: in.HostUser}
|
|
}
|
|
|
|
// CreateLXC runs the full pct create: template pre-flight, VMID guard,
|
|
// gateway pre-flight, then create + start. Output streams to in.Sink.
|
|
func (p *Provisioner) CreateLXC(ctx context.Context, in ports.LXCInput) (ports.ProvisionResult, error) {
|
|
// Template pre-flight: resolve against what the host actually has
|
|
// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
|
|
// `pct` error when that exact file isn't present. List the cache, then
|
|
// either validate the requested template or auto-pick the newest
|
|
// debian one; on miss, fail early with the available list so the
|
|
// operator/agent can retry with a real name.
|
|
res := p.exec.Run(ctx, p.target(in),
|
|
"ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true",
|
|
ports.ExecOpts{})
|
|
if res.Err != nil {
|
|
return ports.ProvisionResult{}, fmt.Errorf("list templates on %s: %w", in.HostSlug, res.Err)
|
|
}
|
|
available := []string{}
|
|
for _, l := range strings.Split(strings.TrimSpace(res.Output), "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
available = append(available, l)
|
|
}
|
|
}
|
|
in.Template = ResolveTemplate(in.Template, available)
|
|
if in.Template == "" {
|
|
return ports.ProvisionResult{}, fmt.Errorf(
|
|
"no usable LXC template on %s; available: %v", in.HostSlug, available)
|
|
}
|
|
|
|
// VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's
|
|
// guess (e.g. 132) can collide with a container on another node — pct
|
|
// create then fails with "CT N already exists on node X". Fetch the set
|
|
// of in-use VMIDs across the cluster; if the requested id is taken (or
|
|
// absent), fall back to the cluster's next free id so provisioning
|
|
// still succeeds instead of dead-ending on the operator's approval.
|
|
var vmidErr error
|
|
in.VMID, vmidErr = p.resolveVMID(ctx, in)
|
|
if vmidErr != nil {
|
|
return ports.ProvisionResult{}, vmidErr
|
|
}
|
|
|
|
// Gateway pre-flight: for a static config, ping the gateway from the
|
|
// target HOST, on the SPECIFIC BRIDGE being requested, before spending
|
|
// 5+ minutes creating the container. Binding to the bridge
|
|
// (`ping -I <bridge>`) matters: a bare `ping <gw>` from the host can
|
|
// succeed via the host's own routing table even when the container —
|
|
// which only gets a naive on-link default route via its bridge's veth —
|
|
// can never ARP that gateway at all. Binding reproduces what the
|
|
// container will actually experience.
|
|
isStatic := in.IP != "" && !strings.EqualFold(in.IP, "dhcp")
|
|
if isStatic && in.GW != "" {
|
|
ping := p.exec.Run(ctx, p.target(in),
|
|
fmt.Sprintf("ping -I %s -c1 -W2 %s >/dev/null 2>&1 && echo PREFLIGHT_OK || echo PREFLIGHT_FAIL", in.Bridge, in.GW),
|
|
ports.ExecOpts{})
|
|
if ping.Err != nil || !GatewayPreflightPassed(ping.Output) {
|
|
return ports.ProvisionResult{}, fmt.Errorf(
|
|
"gateway %s is not reachable from %s on bridge %s — this almost always means the bridge doesn't carry that subnet on this host (each bridge only reaches the network it's physically wired to). "+
|
|
"Do not retry with a different gateway guess in the same subnet: find an existing LXC on this host with an IP in the same /28 and copy its exact bridge+gateway, or use DHCP instead",
|
|
in.GW, in.HostSlug, in.Bridge)
|
|
}
|
|
}
|
|
|
|
createCmd := BuildPctCreateCmd(in)
|
|
|
|
slog.Info("provisioner: pct create running",
|
|
"vmid", in.VMID, "hostname", in.Hostname, "cmd", createCmd)
|
|
run := p.exec.Run(ctx, p.target(in), createCmd, ports.ExecOpts{Sink: in.Sink})
|
|
if run.Err != nil {
|
|
return ports.ProvisionResult{VMID: in.VMID, Template: in.Template, Output: run.Output}, run.Err
|
|
}
|
|
return ports.ProvisionResult{VMID: in.VMID, Template: in.Template, Output: run.Output}, nil
|
|
}
|
|
|
|
// resolveVMID returns the VMID to use, reassigning via the cluster's
|
|
// next free id when the request is absent or already taken.
|
|
func (p *Provisioner) resolveVMID(ctx context.Context, in ports.LXCInput) (int, error) {
|
|
usedRaw := p.exec.Run(ctx, p.target(in),
|
|
`pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`,
|
|
ports.ExecOpts{})
|
|
used := map[int]bool{}
|
|
for _, l := range strings.Fields(usedRaw.Output) {
|
|
if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
|
|
used[n] = true
|
|
}
|
|
}
|
|
if in.VMID != 0 && !used[in.VMID] {
|
|
return in.VMID, nil
|
|
}
|
|
if in.VMID != 0 {
|
|
slog.Info("provisioner: pct_create VMID reassigned", "requested", in.VMID)
|
|
}
|
|
nextRaw := p.exec.Run(ctx, p.target(in), `pvesh get /cluster/nextid 2>/dev/null`, ports.ExecOpts{})
|
|
nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw.Output))
|
|
if nextRaw.Err != nil || cerr != nil || nextID == 0 {
|
|
return 0, fmt.Errorf("VMID %d is already in use on the cluster and could not resolve a free id", in.VMID)
|
|
}
|
|
return nextID, nil
|
|
}
|
|
|
|
// CreateVM is reserved for the qm flow; no consumer exists yet.
|
|
func (p *Provisioner) CreateVM(_ context.Context, _ ports.VMInput) (ports.ProvisionResult, error) {
|
|
return ports.ProvisionResult{}, errors.New("provisioner: VM creation via qm not implemented yet")
|
|
}
|
|
|
|
// BuildPctCreateCmd renders the pct create command from a
|
|
// fully-defaulted input. Pure; split out for tests.
|
|
func BuildPctCreateCmd(in ports.LXCInput) string {
|
|
privFlag := "--unprivileged 1"
|
|
if in.Privileged {
|
|
privFlag = "--unprivileged 0"
|
|
}
|
|
|
|
features := []string{}
|
|
if in.Nesting {
|
|
features = append(features, "nesting=1")
|
|
}
|
|
if in.Privileged {
|
|
features = append(features, "keyctl=1")
|
|
}
|
|
nestingFlag := ""
|
|
if len(features) > 0 {
|
|
nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ","))
|
|
}
|
|
|
|
// net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox
|
|
// rejects a gateway alongside ip=dhcp, so only add gw for a static IP.
|
|
net0 := "name=eth0,bridge=" + in.Bridge + ","
|
|
if in.IP == "" || strings.EqualFold(in.IP, "dhcp") {
|
|
net0 += "ip=dhcp"
|
|
} else {
|
|
net0 += "ip=" + in.IP
|
|
if in.GW != "" {
|
|
net0 += ",gw=" + in.GW
|
|
}
|
|
}
|
|
|
|
templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", in.Template)
|
|
cmd := fmt.Sprintf(
|
|
"pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1",
|
|
in.VMID, templatePath, in.Hostname, in.Cores, in.MemoryMB,
|
|
in.Storage, in.DiskGB, privFlag, net0, nestingFlag)
|
|
|
|
if in.Nameserver != "" {
|
|
cmd += fmt.Sprintf(" --nameserver %s", in.Nameserver)
|
|
}
|
|
if in.Searchdomain != "" {
|
|
cmd += fmt.Sprintf(" --searchdomain %s", in.Searchdomain)
|
|
}
|
|
|
|
for i, mp := range in.Mounts {
|
|
if i < 10 { // pct supports up to mp9
|
|
cmd += fmt.Sprintf(" --mp%d %s", i, mp)
|
|
}
|
|
}
|
|
return cmd
|
|
}
|
|
|
|
// ResolveTemplate maps a requested template name to one actually present
|
|
// in the host's template cache. Exact match wins; a bare distro hint
|
|
// (e.g. "debian-13" or "debian") matches by prefix; empty picks the
|
|
// newest debian (falling back to any) template available. Returns ""
|
|
// when nothing fits.
|
|
func ResolveTemplate(requested string, available []string) string {
|
|
if len(available) == 0 {
|
|
return ""
|
|
}
|
|
if requested != "" {
|
|
for _, a := range available {
|
|
if a == requested {
|
|
return a
|
|
}
|
|
}
|
|
for _, a := range available {
|
|
if strings.HasPrefix(a, requested) {
|
|
return a
|
|
}
|
|
}
|
|
}
|
|
// Auto-pick: prefer debian, then the lexically-greatest (newest version).
|
|
best := ""
|
|
for _, a := range available {
|
|
if strings.Contains(a, "debian") && a > best {
|
|
best = a
|
|
}
|
|
}
|
|
if best != "" {
|
|
return best
|
|
}
|
|
for _, a := range available {
|
|
if a > best {
|
|
best = a
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// GatewayPreflightPassed interprets the PREFLIGHT_OK/PREFLIGHT_FAIL
|
|
// markers from the gateway pre-flight check. Exact-match markers — a
|
|
// prior version checked for "REACHABLE", a substring of "UNREACHABLE",
|
|
// so the check could never actually fail. Exact match plus a test make
|
|
// that bug class structurally unable to recur silently.
|
|
func GatewayPreflightPassed(out string) bool {
|
|
return strings.TrimSpace(out) == "PREFLIGHT_OK"
|
|
}
|