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>
This commit is contained in:
2026-09-24 10:28:28 +00:00
co-authored by Claude Opus 5.5
parent 7f2073f94b
commit 3d72507894
28 changed files with 882 additions and 201 deletions
+33 -30
View File
@@ -2,14 +2,17 @@ package proxmox
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-logr/logr"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// Delete is spec entry 11: under the per-name lock, stop and destroy the VM
// when it is ours, then delete its seed. Everything already gone is
// Delete is spec entry 11: under the per-name lock, stop the VM when it is
// ours, delete its seed, then destroy it. Everything already gone is
// success, and nothing of ours is left when nil is returned, so Get
// afterwards is NotFound.
func (p *Provider) Delete(ctx context.Context, id string) error {
@@ -29,19 +32,17 @@ func (p *Provider) Delete(ctx context.Context, id string) error {
return p.wrapErr("delete", id, err)
}
if node == "" {
err = p.deleteSeedEverywhere(ctx, log, name)
if err := p.deleteSeedEverywhere(ctx, log, name); err != nil {
return p.wrapErr("delete", id, err)
}
} else {
log = log.WithValues("node", node)
err = p.deleteSeed(ctx, log, node, name)
}
if err != nil {
return p.wrapErr("delete", id, err)
}
log.V(1).Info("proxmox vm deleted", "destroyed", node != "")
return nil
}
// destroy is steps 2 to 6. It returns the node of the VM it destroyed, or
// destroy is steps 2 to 7. It returns the node of the VM it destroyed, or
// "" when there was no VM of ours to destroy: then the seed's node is not
// known, and the seed is looked for on every node.
func (p *Provider) destroy(ctx context.Context, log logr.Logger, vmid int, name string) (string, error) {
@@ -58,7 +59,7 @@ func (p *Provider) destroy(ctx context.Context, log logr.Logger, vmid int, name
return "", transientf("VM %d is locked (%s)", vmid, r.Lock)
}
if r.Status == "unknown" {
return "", transientf("VM %d has status unknown: its node %s is not answering", vmid, r.Node)
return "", 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 isNotExist(err) {
@@ -76,47 +77,47 @@ func (p *Provider) destroy(ctx context.Context, log logr.Logger, vmid int, name
log.V(1).Info("proxmox vm carries no proxy-operator mark, left alone")
return "", nil
}
switch lock := cfg["lock"]; lock {
case "":
case lockSuspended:
// Destroy refuses a locked VM and the lock never clears on its own,
// so only an admin can move this on, as in Create's ownership check.
return "", permanentf("VM %d named %s is hibernated (lock suspended); resume or destroy it in Proxmox", vmid, name)
default:
// A hibernated VM is stopped with lock suspended, and the destroy
// takes it, state volume included (captured on pv1-1).
if lock := cfg["lock"]; lock != "" && lock != lockSuspended {
return "", transientf("VM %d is locked (%s)", vmid, lock)
}
// The listing shows a paused VM as running, and Stop ends it too.
if r.Status != "stopped" {
gone, err := p.runTask(ctx, log, "stopping", vmid, p.budgets.stop, func() (string, error) { return p.api.Stop(ctx, r.Node, vmid) })
if err != nil || gone {
return r.Node, err
if err := p.runTask(ctx, log, "stopping", vmid, p.budgets.stop, func() (string, error) { return p.api.Stop(ctx, r.Node, vmid) }); err != nil {
return "", err
}
}
if _, err := p.runTask(ctx, log, "destroying", vmid, p.budgets.destroy, func() (string, error) { return p.api.Destroy(ctx, r.Node, vmid) }); err != nil {
// The seed goes while the VM is still there: once it is gone, Get is
// NotFound and nothing calls Delete again to finish the job.
if err := p.deleteSeed(ctx, log, r.Node, name); err != nil {
return "", err
}
if err := p.runTask(ctx, log, "destroying", vmid, p.budgets.destroy, func() (string, error) { return p.api.Destroy(ctx, r.Node, vmid) }); err != nil {
return "", err
}
return r.Node, nil
}
// runTask submits a task on a VM and waits for it. A VM that is gone by the
// time the task is submitted reports gone: it is what the task was for.
func (p *Provider) runTask(ctx context.Context, log logr.Logger, what string, vmid int, budget time.Duration, submit func() (string, error)) (bool, error) {
// time the task is submitted is success: it is what the task was for.
func (p *Provider) runTask(ctx context.Context, log logr.Logger, what string, vmid int, budget time.Duration, submit func() (string, error)) error {
upid, err := submit()
if isNotExist(err) {
log.V(1).Info("proxmox vm already gone")
return true, nil
return nil
}
if err != nil {
return false, fmt.Errorf("%s VM %d: %w", what, vmid, err)
return fmt.Errorf("%s VM %d: %w", what, vmid, err)
}
if err := p.waitTask(ctx, log, upid, budget); err != nil {
return false, fmt.Errorf("%s VM %d: %w", what, vmid, err)
return fmt.Errorf("%s VM %d: %w", what, vmid, err)
}
return false, nil
return nil
}
// deleteSeed is step 7 on one node. A missing volume still ends in a
// deleteSeed is step 6 on one node. A missing volume still ends in a
// successful task (F13), so nothing is matched.
func (p *Provider) deleteSeed(ctx context.Context, log logr.Logger, node, name string) error {
volid := seedVolID(p.cfg.ISOStorage, name)
@@ -130,17 +131,19 @@ func (p *Provider) deleteSeed(ctx context.Context, log logr.Logger, node, name s
return nil
}
// deleteSeedEverywhere is step 7 when the VM's node is not known: every
// deleteSeedEverywhere is step 6 when the VM's node is not known: every
// configured node is tried, and one that fails is logged and skipped, so a
// node that is down or lacks the storage cannot hold the Delete up. An
// ended ctx fails the call instead.
// ended ctx fails the call instead, and so does a Permanent refusal such as
// a 403 for Datastore.Allocate, which every node would give and which
// would otherwise leave the seed behind unseen.
func (p *Provider) deleteSeedEverywhere(ctx context.Context, log logr.Logger, name string) error {
for _, node := range p.cfg.Nodes {
err := p.deleteSeed(ctx, log, node, name)
if err == nil {
continue
}
if ctx.Err() != nil {
if ctx.Err() != nil || errors.Is(classify("delete", err), provider.ErrPermanent) {
return err
}
log.V(1).Info("proxmox seed delete failed on a node, skipped", "node", node, "reason", err.Error())