Files
egress-proxies-operator/internal/provider/proxmox/proxmox.go
T
kacerrandClaude Opus 5.5 0a6c8624f3 provider: extract nocloud seed builder and keyedlock from kvm2
kvm2's NoCloud seed builder moves to internal/provider/nocloud (exported,
with an ErrEmptyName sentinel instead of permanentf) and its keyed locks
to internal/provider/keyedlock, tests moved under their existing names.
kvm2 behaviour is unchanged. proxmox gains the per-name lock and the pure
seed helpers (seedISOName, seedVolID, buildSeed); unlike kvm2 (review
finding S5.1), a seed staging failure there is Transient, not Permanent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 14:01:27 +00:00

193 lines
6.4 KiB
Go

// Package proxmox implements the provider contract on a Proxmox VE cluster
// through its REST API: VMs are full clones of prepared templates, seeded
// with a NoCloud ISO uploaded to a storage and addressed through the guest
// agent. The design is in docs/proxmox-provider-spec.md.
package proxmox
import (
"context"
"fmt"
"os"
"time"
"github.com/go-logr/logr"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider/keyedlock"
)
const (
defaultVMIDMin = 100000
defaultVMIDMax = 199999
defaultRequestTimeoutSeconds = 30
)
// budgets bounds every wait of the provider (spec entry 18); tests shrink
// them.
type budgets struct {
clone time.Duration // the clone task, and waiting for a VM's lock
upload time.Duration
resize time.Duration
start time.Duration
stop time.Duration
destroy time.Duration
seedDelete time.Duration
running time.Duration // from the start task's end to status running
poll time.Duration // task and status poll interval
}
func defaultBudgets() budgets {
return budgets{
clone: 10 * time.Minute,
upload: 2 * time.Minute,
resize: 2 * time.Minute,
start: 2 * time.Minute,
stop: 2 * time.Minute,
destroy: 5 * time.Minute,
seedDelete: time.Minute,
running: 60 * time.Second,
poll: time.Second,
}
}
// Provider is one configured proxmox entry: one cluster reached through one
// API URL with one token.
type Provider struct {
name string
cfg provider.ProxmoxConfig
api api
now func() time.Time
budgets budgets
// nameLocks serialises Create and Delete per instance name (spec entry
// 19), so the reconciler's Delete and GC's Delete never race on stop and
// destroy, and a Delete cannot interleave with a Create's steps.
nameLocks *keyedlock.Locks
}
var _ provider.Provider = (*Provider)(nil)
// New builds a Provider from pc.Proxmox. It never makes a request: the token
// file is only checked for existence, and a missing one is a warning, not an
// error, because the Secret mount is optional and may land later.
func New(ctx context.Context, pc provider.ProviderConfig) (provider.Provider, error) {
cfg := provider.ProxmoxConfig{}
if pc.Proxmox != nil {
cfg = *pc.Proxmox
}
// The HTTP client behind the api seam arrives in Phase 4 of the plan;
// until then every method is a stub that makes no request.
p := newProvider(pc.Name, cfg, nil)
log := p.logger(ctx)
if _, err := os.Stat(p.cfg.TokenFile); err != nil {
log.Info("WARNING: token file is not readable; every call fails until it is", "path", p.cfg.TokenFile, "reason", err.Error())
}
if p.cfg.InsecureSkipTLSVerify {
log.Info("WARNING: insecureSkipTLSVerify is set; the API's certificate is not verified")
}
return p, nil
}
// newProvider is the constructor tests use, with a in-memory api.
func newProvider(name string, cfg provider.ProxmoxConfig, a api) *Provider {
return &Provider{
name: name,
cfg: withDefaults(cfg),
api: a,
now: time.Now,
budgets: defaultBudgets(),
nameLocks: keyedlock.New(),
}
}
// withDefaults returns cfg with every optional field filled in, so the rest
// of the package never re-checks for an empty value. Slices, maps and
// pointers are copied, not shared with the caller.
func withDefaults(cfg provider.ProxmoxConfig) provider.ProxmoxConfig {
cfg.Nodes = append([]string(nil), cfg.Nodes...)
if cfg.VMIDRange == nil {
cfg.VMIDRange = &provider.ProxmoxVMIDRange{Min: defaultVMIDMin, Max: defaultVMIDMax}
} else {
r := *cfg.VMIDRange
cfg.VMIDRange = &r
}
if cfg.RequestTimeoutSeconds == 0 {
cfg.RequestTimeoutSeconds = defaultRequestTimeoutSeconds
}
install := true
if cfg.InstallGuestAgent != nil {
install = *cfg.InstallGuestAgent
}
cfg.InstallGuestAgent = &install
templates := make(map[string]provider.ProxmoxTemplate, len(cfg.Templates))
for k, v := range cfg.Templates {
templates[k] = v
}
cfg.Templates = templates
machineTypes := make(map[string]provider.ProxmoxMachineType, len(cfg.MachineTypes))
for k, v := range cfg.MachineTypes {
machineTypes[k] = v
}
cfg.MachineTypes = machineTypes
return cfg
}
func (p *Provider) logger(ctx context.Context) logr.Logger {
return logf.FromContext(ctx).WithName("proxmox").WithValues("provider", p.name)
}
// node resolves placement.zone to a configured node. An empty zone is
// allowed only when exactly one node is configured.
func (p *Provider) node(zone string) (string, error) {
if zone == "" {
if len(p.cfg.Nodes) == 1 {
return p.cfg.Nodes[0], nil
}
return "", permanentf("placement.zone is required when proxmox.nodes lists more than one node")
}
for _, n := range p.cfg.Nodes {
if n == zone {
return n, nil
}
}
return "", permanentf("placement.zone %q is not in proxmox.nodes", zone)
}
// wrapErr is the single exit for errors: it classifies err and wraps it with
// the configured provider name, op and id.
func (p *Provider) wrapErr(op, id string, err error) error {
return provider.Wrap(classify(op, err), op, p.name, id, err)
}
// lockName takes the per-name lock of spec entry 19, or gives up with ctx,
// so a caller whose ctx ends while another caller's Create waits on a clone
// returns promptly.
func (p *Provider) lockName(ctx context.Context, name string) (func(), error) {
release, err := p.nameLocks.Acquire(ctx, name)
if err != nil {
return nil, fmt.Errorf("%w: waiting for the lock on %s: %w", provider.ErrTransient, name, err)
}
return release, nil
}
// The four contract methods are stubs until the phases that implement them
// (Get and ListByTag in Phase 5, Create in 6, Delete in 7). Each answers
// Transient, so a Proxy on this provider retries rather than latching.
func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (string, error) {
return "", p.wrapErr("create", "", transientf("proxmox Create is not implemented yet (request %s)", req.Name))
}
func (p *Provider) Get(_ context.Context, id string) (*provider.Instance, error) {
return nil, p.wrapErr("get", id, transientf("proxmox Get is not implemented yet"))
}
func (p *Provider) Delete(_ context.Context, id string) error {
return p.wrapErr("delete", id, transientf("proxmox Delete is not implemented yet"))
}
func (p *Provider) ListByTag(_ context.Context) ([]provider.Instance, error) {
return nil, p.wrapErr("list", "", transientf("proxmox ListByTag is not implemented yet"))
}