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>
186 lines
5.7 KiB
Go
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)
|
|
}
|