Files
egress-proxies-operator/internal/provider/proxmox/delete.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

153 lines
5.2 KiB
Go

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 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 {
vmid, name, err := parseProviderID(id)
if err != nil {
return p.wrapErr("delete", id, err)
}
log := p.logger(ctx).WithValues("vmid", vmid, "name", name)
unlock, err := p.lockName(ctx, name)
if err != nil {
return p.wrapErr("delete", id, err)
}
defer unlock()
node, err := p.destroy(ctx, log, vmid, name)
if err != nil {
return p.wrapErr("delete", id, err)
}
if node == "" {
if err := p.deleteSeedEverywhere(ctx, log, name); err != nil {
return p.wrapErr("delete", id, err)
}
} else {
log = log.WithValues("node", node)
}
log.V(1).Info("proxmox vm deleted", "destroyed", node != "")
return nil
}
// 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) {
r, ok, err := p.resolve(ctx, vmid)
if err != nil {
return "", err
}
if !ok {
log.V(1).Info("proxmox vm already gone")
return "", nil
}
log = log.WithValues("node", r.Node)
if r.Lock != "" && r.Lock != lockSuspended {
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, 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) {
log.V(1).Info("proxmox vm already gone")
return "", nil
}
if err != nil {
return "", fmt.Errorf("reading config of VM %d: %w", vmid, err)
}
if cfg["name"] != name {
log.V(1).Info("proxmox vm at this vmid is not ours, left alone", "vmName", cfg["name"])
return "", nil
}
if !parseDescription(cfg["description"]).Managed {
log.V(1).Info("proxmox vm carries no proxy-operator mark, left alone")
return "", nil
}
// 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" {
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
}
}
// 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 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 nil
}
if err != nil {
return fmt.Errorf("%s VM %d: %w", what, vmid, err)
}
if err := p.waitTask(ctx, log, upid, budget); err != nil {
return fmt.Errorf("%s VM %d: %w", what, vmid, err)
}
return nil
}
// 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)
upid, err := p.api.DeleteVolume(ctx, node, p.cfg.ISOStorage, volid)
if err != nil {
return fmt.Errorf("deleting seed %s on %s: %w", volid, node, err)
}
if err := p.waitTask(ctx, log, upid, p.budgets.seedDelete); err != nil {
return fmt.Errorf("deleting seed %s on %s: %w", volid, node, err)
}
return nil
}
// 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, 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 || 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())
}
return nil
}