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

186 lines
5.7 KiB
Go

package proxmox
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
"regexp"
"slices"
"strings"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// permanentf and transientf classify an error at the source, so classify
// keeps that class no matter what wraps it later.
func permanentf(format string, args ...any) error {
return fmt.Errorf("%w: %s", provider.ErrPermanent, fmt.Sprintf(format, args...))
}
func transientf(format string, args ...any) error {
return fmt.Errorf("%w: %s", provider.ErrTransient, fmt.Sprintf(format, args...))
}
// apiError is a non-2xx answer from the API. It carries the status and the
// message only, never headers or the request URL, so no credential can leak
// through it (spec entry 14).
type apiError struct {
Status int
Message string
// Errors is a 400's per-parameter map.
Errors map[string]string
}
func (e *apiError) Error() string {
msg := fmt.Sprintf("proxmox API %d: %s", e.Status, e.Message)
if len(e.Errors) == 0 {
return msg
}
fields := make([]string, 0, len(e.Errors))
for _, k := range slices.Sorted(maps.Keys(e.Errors)) {
fields = append(fields, k+": "+e.Errors[k])
}
return msg + " (" + strings.Join(fields, "; ") + ")"
}
// newAPIError builds an apiError from a response: the body's message with
// its trailing newline trimmed, or the status reason phrase when the body
// has none, as for a 401 (spec entry 13, fact F1).
func newAPIError(status int, reason string, body []byte) *apiError {
var b struct {
Message string `json:"message"`
Errors map[string]string `json:"errors"`
}
_ = json.Unmarshal(body, &b)
e := &apiError{Status: status, Message: strings.TrimSpace(b.Message)}
if e.Message == "" {
e.Message = strings.TrimSpace(reason)
}
if e.Message == "" {
e.Message = http.StatusText(status)
}
if len(b.Errors) > 0 {
e.Errors = make(map[string]string, len(b.Errors))
for k, v := range b.Errors {
e.Errors[k] = strings.TrimSpace(v)
}
}
return e
}
// The 500 messages the provider acts on, each backed by a captured response
// under testdata/.
var (
vmConfigMissing = regexp.MustCompile(`^Configuration file '[^']*' does not exist$`)
)
const (
msgAlreadyExists = "config file already exists"
msgAgentNotRunning = "QEMU guest agent is not running"
msgAgentNotConfigured = "No QEMU guest agent configured"
msgNoSuchStorage = "No such storage."
)
// server500 returns the message of err when it is, or wraps, an apiError
// with status 500.
func server500(err error) (string, bool) {
var ae *apiError
if !errors.As(err, &ae) || ae.Status != http.StatusInternalServerError {
return "", false
}
return ae.Message, true
}
// isNotExist reports the missing-VM answer: the VM's config file is gone.
func isNotExist(err error) bool {
msg, ok := server500(err)
return ok && vmConfigMissing.MatchString(msg)
}
// isNoSuchStorage reports a storage the cluster does not know: a 400 whose
// storage field error is exactly msgNoSuchStorage. Other storage field
// errors, such as a clone onto a storage without image support, are not.
func isNoSuchStorage(err error) bool {
var ae *apiError
return errors.As(err, &ae) && ae.Status == http.StatusBadRequest && ae.Errors["storage"] == msgNoSuchStorage
}
// isAlreadyExists reports a clone onto a VMID that is taken.
func isAlreadyExists(err error) bool {
msg, ok := server500(err)
return ok && strings.HasSuffix(msg, msgAlreadyExists)
}
// isAgentNotRunning reports a VM with the agent enabled whose agent does not
// answer yet.
func isAgentNotRunning(err error) bool {
msg, ok := server500(err)
return ok && msg == msgAgentNotRunning
}
// isAgentNotConfigured reports a VM whose config no longer enables the agent.
func isAgentNotConfigured(err error) bool {
msg, ok := server500(err)
return ok && msg == msgAgentNotConfigured
}
// classify maps any error reaching the provider's boundary to one of the
// four contract sentinels, per spec entry 13. op is the contract operation:
// a vanished VM inside Create is Transient, because the contract forbids
// NotFound from Create.
func classify(op string, err error) error {
if err == nil {
return provider.ErrTransient
}
for _, class := range []error{provider.ErrNotFound, provider.ErrQuotaExceeded, provider.ErrPermanent, provider.ErrTransient} {
if errors.Is(err, class) {
return class
}
}
var ae *apiError
if errors.As(err, &ae) {
return classifyAPI(op, err, ae)
}
if isTLSVerification(err) {
return provider.ErrPermanent
}
// Everything else retries: context cancellation and deadlines,
// net.OpError, io.EOF, and anything unrecognised.
return provider.ErrTransient
}
func classifyAPI(op string, err error, ae *apiError) error {
switch {
case ae.Status >= 400 && ae.Status < 500:
// 401 and 403 are credentials or privileges, 400 a request Proxmox
// will never accept: all fixed by an admin, not by retrying.
return provider.ErrPermanent
case isNotExist(err):
if op == "create" {
return provider.ErrTransient
}
return provider.ErrNotFound
default:
// Other 500s (a lock, a taken VMID escaping the allocator, agent
// states escaping Get), pveproxy's 595 and 596, and any other status.
return provider.ErrTransient
}
}
// isTLSVerification reports a certificate the client refused: the URL does
// not match the certificate, or the CA is wrong. An admin fixes the URL or
// caFile; retrying cannot.
func isTLSVerification(err error) bool {
var (
verr *tls.CertificateVerificationError
unknown x509.UnknownAuthorityError
hostname x509.HostnameError
invalid x509.CertificateInvalidError
)
return errors.As(err, &verr) || errors.As(err, &unknown) || errors.As(err, &hostname) || errors.As(err, &invalid)
}