Create validates placement, then under the per-name lock adopts the VM a previous attempt left (by name, or at a taken VMID after the ownership check of spec entry 2) or full-clones the template onto the UID's deterministic VMID with the mark in the clone call. It then uploads the seed over any existing file, writes cores, memory, agent, tag, ide2 and net0 when configured, grows the boot disk when diskGiB is larger, starts, and waits for running/running. waitUnlocked no longer waits on a suspended lock: a hibernated VM is judged at once, so ours is Permanent and a foreign one is probed past. The template is checked only on the allocation path, and its shape (net0, boot disk size) is refused before any clone exists. Spec entries 2, 4 and 10 updated to match. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
181 lines
5.8 KiB
Go
181 lines
5.8 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
|
|
}
|
|
|
|
// Delete is a stub until Phase 7. It answers Transient, so a Proxy on this
|
|
// provider retries rather than latching.
|
|
func (p *Provider) Delete(_ context.Context, id string) error {
|
|
return p.wrapErr("delete", id, transientf("proxmox Delete is not implemented yet"))
|
|
}
|