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 `) matters: a bare `ping ` 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" }