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) }