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:
237
internal/adapters/ssh/provisioner.go
Normal file
237
internal/adapters/ssh/provisioner.go
Normal 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"
|
||||
}
|
||||
100
internal/adapters/ssh/provisioner_test.go
Normal file
100
internal/adapters/ssh/provisioner_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package ssh
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dtoro/oikos/internal/core/ports"
|
||||
)
|
||||
|
||||
// TestResolveTemplate — moved from httpapi/pct_create_test.go with the
|
||||
// pct flow it belongs to.
|
||||
func TestResolveTemplate(t *testing.T) {
|
||||
avail := []string{
|
||||
"debian-12-standard_12.7-1_amd64.tar.zst",
|
||||
"debian-13-standard_13.0-1_amd64.tar.zst",
|
||||
"ubuntu-24.04-standard_24.04-2_amd64.tar.zst",
|
||||
}
|
||||
cases := []struct {
|
||||
requested string
|
||||
want string
|
||||
}{
|
||||
{"debian-13-standard_13.0-1_amd64.tar.zst", "debian-13-standard_13.0-1_amd64.tar.zst"}, // exact
|
||||
{"debian-13", "debian-13-standard_13.0-1_amd64.tar.zst"}, // prefix
|
||||
{"", "debian-13-standard_13.0-1_amd64.tar.zst"}, // auto newest debian
|
||||
{"debian-99", "debian-13-standard_13.0-1_amd64.tar.zst"}, // miss prefix → auto debian
|
||||
{"ubuntu-24", "ubuntu-24.04-standard_24.04-2_amd64.tar.zst"},
|
||||
{"alpine", "debian-13-standard_13.0-1_amd64.tar.zst"}, // miss → auto debian
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ResolveTemplate(c.requested, avail); got != c.want {
|
||||
t.Errorf("ResolveTemplate(%q) = %q, want %q", c.requested, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := ResolveTemplate("debian", nil); got != "" {
|
||||
t.Errorf("ResolveTemplate with no cache = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayPreflightPassed(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"PREFLIGHT_OK", true},
|
||||
{"PREFLIGHT_OK\n", true},
|
||||
{" PREFLIGHT_OK ", true},
|
||||
{"PREFLIGHT_FAIL", false},
|
||||
{"UNREACHABLE", false}, // the original substring bug
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := GatewayPreflightPassed(c.in); got != c.want {
|
||||
t.Errorf("GatewayPreflightPassed(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPctCreateCmd(t *testing.T) {
|
||||
base := ports.LXCInput{
|
||||
VMID: 137, Hostname: "grafana", Cores: 2, MemoryMB: 1024, DiskGB: 12,
|
||||
IP: "192.168.8.55", GW: "192.168.8.2", Bridge: "vmbr1",
|
||||
Storage: "local-lvm", Template: "debian-13-standard_13.0-1_amd64.tar.zst",
|
||||
Nameserver: "192.168.8.2", Searchdomain: "hubris.network",
|
||||
}
|
||||
got := BuildPctCreateCmd(base)
|
||||
for _, want := range []string{
|
||||
"pct create 137 /var/lib/vz/template/cache/debian-13-standard_13.0-1_amd64.tar.zst",
|
||||
"--hostname grafana",
|
||||
"--cores 2",
|
||||
"--memory 1024",
|
||||
"--rootfs local-lvm:12",
|
||||
"--unprivileged 1",
|
||||
"--net0 name=eth0,bridge=vmbr1,ip=192.168.8.55,gw=192.168.8.2",
|
||||
"--start 1",
|
||||
"--nameserver 192.168.8.2",
|
||||
"--searchdomain hubris.network",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("cmd missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "--features") {
|
||||
t.Errorf("unprivileged non-nested should have no features flag:\n%s", got)
|
||||
}
|
||||
|
||||
nested := base
|
||||
nested.Nesting = true
|
||||
nested.Privileged = true
|
||||
nested.IP = "dhcp"
|
||||
got = BuildPctCreateCmd(nested)
|
||||
if !strings.Contains(got, "--features nesting=1,keyctl=1") {
|
||||
t.Errorf("privileged+nesting features missing:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--unprivileged 0") {
|
||||
t.Errorf("privileged should pass --unprivileged 0:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "ip=dhcp") || strings.Contains(got, "gw=") {
|
||||
t.Errorf("dhcp must omit gateway:\n%s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user