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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user