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:
2026-08-07 22:44:22 +02:00
parent f28766fce3
commit a4a483acbc
11 changed files with 859 additions and 2 deletions

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