Phase 10. Two review passes (go-operator-reviewer, /code-review high) over the branch, recorded in docs/reviews/2026-09-23-proxmox-provider-review/: 9 findings fixed, 3 accepted as documented limits, 7 rejected with a reason. - Delete removes the seed between the stop and the destroy, so a failed seed delete leaves the VM for the next Delete instead of leaking the ISO; a Permanent refusal on the every-node path fails the call. - Create skips a probe VMID whose listed name is someone else's without waiting on its lock. - The three answers Phase 0 never captured are fixtures now: a missing storage is a 400 (the Checker reports StorageNotFound), a hibernated VM is destroyable (Delete destroys ours), a paused VM stops. - TestProxmox_lifecycle and TestProxmoxCheck_realHost passed on pv1-1 (PVE 9.2.20); the docs record the run and drop "not yet run". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
133 lines
4.8 KiB
Go
133 lines
4.8 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/go-logr/logr"
|
|
|
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
|
)
|
|
|
|
// lockSuspended is the lock of a VM hibernated to disk: it is not a task in
|
|
// progress, and the VM stays that way until someone resumes it.
|
|
const lockSuspended = "suspended"
|
|
|
|
// Get is spec entries 7 to 9: the VMID's resource entry, then its config
|
|
// for the name and the mark, then status/current and, for a running VM, the
|
|
// guest agent. A running Get makes four calls.
|
|
func (p *Provider) Get(ctx context.Context, id string) (*provider.Instance, error) {
|
|
vmid, name, err := parseProviderID(id)
|
|
if err != nil {
|
|
return nil, p.wrapErr("get", id, err)
|
|
}
|
|
log := p.logger(ctx).WithValues("vmid", vmid, "name", name)
|
|
inst, err := p.get(ctx, log, vmid, name)
|
|
if err != nil {
|
|
err = p.wrapErr("get", id, err)
|
|
if errors.Is(err, provider.ErrNotFound) {
|
|
log.V(1).Info("proxmox vm not found", "reason", err.Error())
|
|
}
|
|
return nil, err
|
|
}
|
|
return inst, nil
|
|
}
|
|
|
|
func (p *Provider) get(ctx context.Context, log logr.Logger, vmid int, name string) (*provider.Instance, error) {
|
|
r, ok, err := p.resolve(ctx, vmid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: VM %d is not in the cluster's resources", provider.ErrNotFound, vmid)
|
|
}
|
|
log = log.WithValues("node", r.Node)
|
|
// A mid-clone entry has no name and status unknown, so the lock is
|
|
// checked before anything that would read it as absent or foreign.
|
|
if r.Lock != "" && r.Lock != lockSuspended {
|
|
return nil, transientf("VM %d is locked (%s)", vmid, r.Lock)
|
|
}
|
|
if r.Status == "unknown" {
|
|
return nil, transientf("VM %d has status unknown: its node %s is not answering, or the VM is too new to be reported yet", vmid, r.Node)
|
|
}
|
|
cfg, err := p.api.VMConfig(ctx, r.Node, vmid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading config of VM %d: %w", vmid, err)
|
|
}
|
|
if lock := cfg["lock"]; lock != "" && lock != lockSuspended {
|
|
return nil, transientf("VM %d is locked (%s)", vmid, lock)
|
|
}
|
|
if cfg["name"] != name {
|
|
return nil, fmt.Errorf("%w: VM %d is now named %q: the VMID was reused", provider.ErrNotFound, vmid, cfg["name"])
|
|
}
|
|
mark := parseDescription(cfg["description"])
|
|
if !mark.Managed {
|
|
return nil, fmt.Errorf("%w: VM %d carries no proxy-operator mark", provider.ErrNotFound, vmid)
|
|
}
|
|
inst := &provider.Instance{ID: formatProviderID(vmid, name), UID: mark.UID, CreatedAt: mark.CreatedAt}
|
|
|
|
// A hibernated VM is Stopped whatever status/current says; it needs no
|
|
// further call.
|
|
if r.Lock == lockSuspended || cfg["lock"] == lockSuspended {
|
|
inst.State = provider.StateStopped
|
|
log.V(1).Info("proxmox vm fetched", "state", inst.State, "lock", lockSuspended, "uid", inst.UID)
|
|
return inst, nil
|
|
}
|
|
st, err := p.api.VMStatus(ctx, r.Node, vmid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading status of VM %d: %w", vmid, err)
|
|
}
|
|
if guestRunning(st.Status, st.QMPStatus) {
|
|
ip, state, err := p.agentAddress(ctx, log, r.Node, vmid)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
inst.IP = ip
|
|
inst.State = state
|
|
} else {
|
|
inst.State = mapState(st.Status, st.QMPStatus, "")
|
|
}
|
|
log.V(1).Info("proxmox vm fetched", "state", inst.State, "ip", inst.IP, "uid", inst.UID, "status", st.Status, "qmpstatus", st.QMPStatus)
|
|
return inst, nil
|
|
}
|
|
|
|
// agentAddress asks the guest agent of a running VM for its address and
|
|
// turns its answers into a state (spec entry 7). Any answer it cannot place
|
|
// is Transient, whatever its status: an address read never latches or
|
|
// loses a Proxy.
|
|
func (p *Provider) agentAddress(ctx context.Context, log logr.Logger, node string, vmid int) (string, provider.InstanceState, error) {
|
|
ifaces, err := p.api.AgentNetworkInterfaces(ctx, node, vmid)
|
|
switch {
|
|
case err == nil:
|
|
ip := firstIPv4(ifaces)
|
|
log.V(2).Info("proxmox guest agent answered", "interfaces", len(ifaces), "ip", ip)
|
|
return ip, mapState("running", "running", ip), nil
|
|
case isAgentNotRunning(err):
|
|
return "", provider.StateProvisioning, nil
|
|
case isAgentNotConfigured(err):
|
|
// agent=1 was removed: this VM can never report an address, so it
|
|
// is replaced by one that can.
|
|
return "", provider.StateStopped, nil
|
|
default:
|
|
// Includes "VM <vmid> is not running": it stopped since the status
|
|
// read, and the next Get sees that directly.
|
|
return "", "", fmt.Errorf("%w: reading the guest agent of VM %d: %w", provider.ErrTransient, vmid, err)
|
|
}
|
|
}
|
|
|
|
// resolve finds a VMID in the cluster's resources. Only QEMU VMs count: a
|
|
// container at the VMID means ours is gone.
|
|
func (p *Provider) resolve(ctx context.Context, vmid int) (vmResource, bool, error) {
|
|
rs, err := p.api.ClusterResources(ctx)
|
|
if err != nil {
|
|
return vmResource{}, false, fmt.Errorf("listing cluster resources: %w", err)
|
|
}
|
|
for _, r := range rs {
|
|
if r.VMID == vmid && r.Type == "qemu" {
|
|
return r, true, nil
|
|
}
|
|
}
|
|
return vmResource{}, false, nil
|
|
}
|