Files
egress-proxies-operator/internal/provider/proxmox/proxmox.go
T
kacerrandClaude Opus 5.5 803716459a proxmox: Get, ListByTag and task polling
Get follows spec entry 9's check order (lock and unknown status before
the name, suspended is Stopped after the name and mark checks), reads
status/current so paused is Stopped, and maps the three agent messages
of entry 7. ListByTag reads one config per candidate and fails whole
only on the resources call or an ended ctx. waitTask polls the UPID's
node every budgets.poll, logs failed tasks at V(1) and counts
"WARNINGS: <n>" as success; waitUnlocked serves Create's ownership check.

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

187 lines
6.1 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
}
client, err := newHTTPAPI(withDefaults(cfg))
if err != nil {
return nil, fmt.Errorf("proxmox provider %s: %w", pc.Name, err)
}
p := newProvider(pc.Name, cfg, observe(client, pc.Name))
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 an 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
}
// Create and Delete are stubs until the phases that implement them (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) Delete(_ context.Context, id string) error {
return p.wrapErr("delete", id, transientf("proxmox Delete is not implemented yet"))
}