proxmox: config block, IDs, marks, state mapping, classify, registration
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>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user