Phase 2 of the proxmox provider plan. Adds the proxmox: config block with its validation, registers the type in the config allow-list and the constructor map, and starts internal/provider/proxmox: the api seam and its data types, providerIDs (vms/<vmid>/<name>) with the UID-derived VMID, the description mark, state mapping, and error classification. Tests read the responses captured from pv1-1 in Phase 0 rather than repeating Proxmox's wording, and every matcher is run against every captured error so none fires on the wrong message. The contract methods are Transient stubs until Phases 5 to 7. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
193 lines
5.8 KiB
Go
193 lines
5.8 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$`)
|
|
vmNotRunning = regexp.MustCompile(`^VM [0-9]+ is not running$`)
|
|
)
|
|
|
|
const (
|
|
msgLocked = "VM is locked"
|
|
msgAlreadyExists = "config file already exists"
|
|
msgAgentNotRunning = "QEMU guest agent is not running"
|
|
msgAgentNotConfigured = "No QEMU guest agent configured"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// isLocked reports a VM held by a running task ("VM is locked (clone)").
|
|
func isLocked(err error) bool {
|
|
msg, ok := server500(err)
|
|
return ok && strings.HasPrefix(msg, msgLocked)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// isVMNotRunning reports an agent call on a VM that is stopped. It is
|
|
// matched on the whole message because " is not running" alone is also a
|
|
// suffix of the agent-not-running answer.
|
|
func isVMNotRunning(err error) bool {
|
|
msg, ok := server500(err)
|
|
return ok && vmNotRunning.MatchString(msg)
|
|
}
|
|
|
|
// 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)
|
|
}
|