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,44 @@
// Package registry assembles a set of named providers from a
// provider.Config by dispatching each entry's Type through a
// caller-supplied map of constructors.
//
// This package deliberately never imports internal/provider/mock or
// internal/provider/gcp. If it did, and internal/provider ever needed to
// import this package (e.g. to expose a default registry), that would be an
// import cycle: internal/provider/mock already imports internal/provider
// for the Provider interface. Keeping the type→constructor map external —
// supplied by the composition root in cmd/main.go — avoids the cycle
// entirely and keeps this package trivially testable with fake
// constructors.
package registry
import (
"context"
"fmt"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// Constructor builds a provider.Provider from its config block.
type Constructor func(ctx context.Context, cfg provider.ProviderConfig) (provider.Provider, error)
// Build constructs every provider listed in cfg, dispatching on
// ProviderConfig.Type through builtins. Fails fast: an unknown type or a
// constructor error aborts the whole build rather than returning a
// partially-usable provider set that would misbehave once a caller reaches
// for the missing entry.
func Build(ctx context.Context, cfg *provider.Config, builtins map[string]Constructor) (map[string]provider.Provider, error) {
providers := make(map[string]provider.Provider, len(cfg.Providers))
for _, pc := range cfg.Providers {
ctor, ok := builtins[pc.Type]
if !ok {
return nil, fmt.Errorf("provider %q: unknown type %q", pc.Name, pc.Type)
}
p, err := ctor(ctx, pc)
if err != nil {
return nil, fmt.Errorf("provider %q: %w", pc.Name, err)
}
providers[pc.Name] = p
}
return providers, nil
}

View File

@@ -0,0 +1,92 @@
package registry
import (
"context"
"errors"
"testing"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
// stubProvider is a minimal provider.Provider satisfying the interface for
// registry tests, which care about wiring, not provider behavior.
type stubProvider struct{ name string }
func (s *stubProvider) Create(ctx context.Context, req provider.CreateRequest) (string, error) {
return "", nil
}
func (s *stubProvider) Get(ctx context.Context, providerID string) (*provider.Instance, error) {
return nil, provider.ErrNotFound
}
func (s *stubProvider) Delete(ctx context.Context, providerID string) error { return nil }
func (s *stubProvider) ListByTag(ctx context.Context) ([]provider.Instance, error) {
return nil, nil
}
func stubConstructor(name string) Constructor {
return func(ctx context.Context, cfg provider.ProviderConfig) (provider.Provider, error) {
return &stubProvider{name: name}, nil
}
}
func TestBuild_dispatchesByType(t *testing.T) {
t.Parallel()
cfg := &provider.Config{Providers: []provider.ProviderConfig{
{Name: "mock", Type: "mock"},
{Name: "gcp-eu", Type: "gcp"},
}}
builtins := map[string]Constructor{
"mock": stubConstructor("mock"),
"gcp": stubConstructor("gcp"),
}
providers, err := Build(context.Background(), cfg, builtins)
if err != nil {
t.Fatalf("Build() error = %v, want nil", err)
}
if len(providers) != 2 {
t.Fatalf("len(providers) = %d, want 2", len(providers))
}
if _, ok := providers["mock"]; !ok {
t.Error(`providers["mock"] missing`)
}
if _, ok := providers["gcp-eu"]; !ok {
t.Error(`providers["gcp-eu"] missing, keyed by config name not type`)
}
}
func TestBuild_unknownType(t *testing.T) {
t.Parallel()
cfg := &provider.Config{Providers: []provider.ProviderConfig{
{Name: "p1", Type: "azure"},
}}
_, err := Build(context.Background(), cfg, map[string]Constructor{"mock": stubConstructor("mock")})
if err == nil {
t.Fatal("Build() error = nil, want error for unknown type")
}
}
func TestBuild_constructorErrorAbortsWholeBuild(t *testing.T) {
t.Parallel()
failing := errors.New("boom")
cfg := &provider.Config{Providers: []provider.ProviderConfig{
{Name: "mock", Type: "mock"},
{Name: "bad", Type: "bad"},
}}
builtins := map[string]Constructor{
"mock": stubConstructor("mock"),
"bad": func(ctx context.Context, cfg provider.ProviderConfig) (provider.Provider, error) {
return nil, failing
},
}
providers, err := Build(context.Background(), cfg, builtins)
if err == nil {
t.Fatal("Build() error = nil, want error from failing constructor")
}
if !errors.Is(err, failing) {
t.Errorf("Build() error = %v, want it to wrap %v", err, failing)
}
if providers != nil {
t.Errorf("Build() providers = %v, want nil on failure (no partial result)", providers)
}
}