Add the provider contract, error taxonomy, naming, and config (Step 2)
The Provider interface (Create/Get/Delete/ListByTag), Instance, and CreateRequest that every cloud backend implements — kept independent of api/v1alpha1 so this package has no CRD-type coupling. Error taxonomy (ErrNotFound/ErrQuotaExceeded/ErrTransient/ErrPermanent) wrapped via a multi-error Unwrap() []error, so errors.Is and errors.As both work off the same value: the reconciler branches on classification, logs keep the underlying SDK error. Unclassified errors default to ErrTransient — retrying is always safer than latching Failed. Deterministic instance naming (SHA-256 -> base32 -> 16 chars, 22 total with the "proxy-" prefix) satisfying GCP's RFC1035 name rules with headroom, and idempotency-tested across 10k UIDs with zero collisions. --providers-config YAML parsing (config.go) with fail-fast validation: unknown type, duplicate name, missing gcp.project, mismatched type/config-block, and strict-mode rejection of unknown keys. internal/provider/registry/registry.go takes its type->constructor map as a parameter rather than hardcoding it, so the package has zero import on internal/provider/mock or internal/provider/gcp (neither exists yet — mock is Step 3, gcp is Step 8) and compiles today. Explicit wiring moves to the composition root in cmd/main.go (Step 10). Deferred internal/provider/metrics.go (the WithMetrics decorator) to Step 9, where the Prometheus vectors it needs actually get built — nothing in this step depends on it. internal/provider at 96.2% coverage, internal/provider/registry at 100%. make test green. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
91
internal/provider/errors.go
Normal file
91
internal/provider/errors.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The provider error taxonomy. Every error a Provider method returns should
|
||||
// be classifiable as exactly one of these four, via Wrap or Class. The
|
||||
// reconciler branches on this classification to decide how to react —
|
||||
// never on a provider-specific error type.
|
||||
var (
|
||||
// ErrNotFound means the instance doesn't exist. Get returning this is
|
||||
// normal, not exceptional.
|
||||
ErrNotFound = errors.New("provider: instance not found")
|
||||
// ErrQuotaExceeded means the request failed because of a cloud quota or
|
||||
// rate limit. The reconciler backs off slowly (minutes, not seconds)
|
||||
// rather than hammering an exhausted quota.
|
||||
ErrQuotaExceeded = errors.New("provider: quota exceeded")
|
||||
// ErrTransient means the request failed for a reason likely to clear on
|
||||
// retry (network blip, 5xx, timeout). The reconciler retries with the
|
||||
// workqueue's normal exponential backoff.
|
||||
ErrTransient = errors.New("provider: transient error")
|
||||
// ErrPermanent means the request failed for a reason that will not
|
||||
// clear on retry (bad config, permission denied, invalid argument). The
|
||||
// reconciler stops retrying and surfaces the failure in status.
|
||||
ErrPermanent = errors.New("provider: permanent error")
|
||||
)
|
||||
|
||||
// Error wraps a provider-specific error with enough context for logs, while
|
||||
// remaining classifiable via errors.Is against one of the four sentinels
|
||||
// above and unwrappable via errors.As to the underlying SDK error.
|
||||
type Error struct {
|
||||
Class error // one of ErrNotFound/ErrQuotaExceeded/ErrTransient/ErrPermanent
|
||||
Op string // "create", "get", "delete", "list"
|
||||
Provider string // the configured provider name, e.g. "gcp-eu"
|
||||
ID string // providerID, if known
|
||||
Err error // underlying error; may be nil
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
msg := fmt.Sprintf("provider %s: %s", e.Provider, e.Op)
|
||||
if e.ID != "" {
|
||||
msg += fmt.Sprintf(" %s", e.ID)
|
||||
}
|
||||
msg += ": " + e.Class.Error()
|
||||
if e.Err != nil {
|
||||
msg += fmt.Sprintf(" (%v)", e.Err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// Unwrap exposes both the taxonomy sentinel and the underlying error, via
|
||||
// Go's multi-error unwrap (errors.Unwrap() []error). This is what lets
|
||||
// errors.Is(err, ErrQuotaExceeded) and errors.As(err, &googleErr) both
|
||||
// succeed against the same *Error value: errors.Is/As walk every branch.
|
||||
func (e *Error) Unwrap() []error {
|
||||
if e.Err != nil {
|
||||
return []error{e.Class, e.Err}
|
||||
}
|
||||
return []error{e.Class}
|
||||
}
|
||||
|
||||
// Wrap builds an *Error classified under class. err is the underlying
|
||||
// SDK/network error and may be nil when there's nothing further to wrap
|
||||
// (e.g. classifying a bare HTTP status code).
|
||||
func Wrap(class error, op, providerName, id string, err error) error {
|
||||
return &Error{Class: class, Op: op, Provider: providerName, ID: id, Err: err}
|
||||
}
|
||||
|
||||
// Class returns the taxonomy sentinel matching err — ErrNotFound,
|
||||
// ErrQuotaExceeded, ErrPermanent, or ErrTransient, checked via errors.Is so
|
||||
// it works against any error built with Wrap regardless of how deeply it's
|
||||
// wrapped elsewhere. An unclassified error (nil, or not built with Wrap)
|
||||
// defaults to ErrTransient: retrying is always safer than latching Failed
|
||||
// on an error nobody has taught this package to recognize.
|
||||
func Class(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return ErrNotFound
|
||||
case errors.Is(err, ErrQuotaExceeded):
|
||||
return ErrQuotaExceeded
|
||||
case errors.Is(err, ErrPermanent):
|
||||
return ErrPermanent
|
||||
default:
|
||||
return ErrTransient
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user