Files
egress-proxies-operator/internal/provider/proxmox/get.go
T
kacerrandClaude Opus 5.5 3d72507894 proxmox: review fixes, real-host validation record, changelog
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>
2026-09-24 10:28:28 +00:00

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
}