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.
This commit is contained in:
2026-08-16 09:11:26 +02:00
parent cd44501fa9
commit 60c0432d8b
26 changed files with 1565 additions and 689 deletions

View File

@@ -0,0 +1,237 @@
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"
}