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

133
internal/provider/config.go Normal file
View File

@@ -0,0 +1,133 @@
package provider
import (
"fmt"
"os"
"sigs.k8s.io/yaml"
)
// Fail-with classes accepted by MockConfig.FailWith, shared with the mock
// provider's fault injection so the two sides never drift on the string
// values.
const (
FailWithNotFound = "notfound"
FailWithQuota = "quota"
FailWithTransient = "transient"
FailWithPermanent = "permanent"
)
var validFailClasses = map[string]bool{
FailWithNotFound: true,
FailWithQuota: true,
FailWithTransient: true,
FailWithPermanent: true,
}
// Config is the top-level shape of the --providers-config file.
type Config struct {
Providers []ProviderConfig `json:"providers"`
}
// ProviderConfig is one named, typed provider instance — e.g. "gcp-eu" and
// "gcp-us" can be two ProviderConfigs of Type "gcp" with different GCP
// blocks. Exactly one of the type-specific blocks below should be set,
// matching Type.
type ProviderConfig struct {
Name string `json:"name"`
Type string `json:"type"`
Mock *MockConfig `json:"mock,omitempty"`
GCP *GCPConfig `json:"gcp,omitempty"`
}
// MockConfig configures the in-memory mock provider.
type MockConfig struct {
// ProvisionDelaySeconds is how long a created instance reports
// Provisioning before Running. Default 5.
ProvisionDelaySeconds int32 `json:"provisionDelaySeconds,omitempty"`
// DeleteDelaySeconds is how long a deleted instance reports Terminated
// before Get starts returning ErrNotFound. Default 1.
DeleteDelaySeconds int32 `json:"deleteDelaySeconds,omitempty"`
// FailNextCreates makes the next N Create calls fail with FailWith,
// for exercising the reconciler's error handling in demos.
FailNextCreates int `json:"failNextCreates,omitempty"`
// FailWith selects the error class injected failures return: one of
// FailWithNotFound/FailWithQuota/FailWithTransient/FailWithPermanent.
// Default FailWithTransient.
FailWith string `json:"failWith,omitempty"`
}
// GCPConfig configures a named GCP provider instance.
type GCPConfig struct {
// Project is the GCP project ID. Required.
Project string `json:"project"`
// Network is the VPC network name. Default "default".
Network string `json:"network,omitempty"`
// NetworkTag is the firewall/network tag applied to created instances.
// Default "proxy-operator".
NetworkTag string `json:"networkTag,omitempty"`
// DiskSizeGB is the boot disk size in GB. Default 10.
DiskSizeGB int64 `json:"diskSizeGb,omitempty"`
}
// LoadConfigFile reads and parses a providers-config file from disk.
func LoadConfigFile(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading providers config %s: %w", path, err)
}
return LoadConfig(data)
}
// LoadConfig parses and validates providers-config YAML. It fails fast:
// unknown provider types, duplicate names, and missing type-specific
// required fields are all load-time errors here, not runtime surprises
// discovered only when a provider is actually used.
func LoadConfig(data []byte) (*Config, error) {
var cfg Config
if err := yaml.UnmarshalStrict(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing providers config: %w", err)
}
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("validating providers config: %w", err)
}
return &cfg, nil
}
func (c *Config) validate() error {
if len(c.Providers) == 0 {
return fmt.Errorf("providers: at least one provider must be configured")
}
seen := make(map[string]bool, len(c.Providers))
for i, p := range c.Providers {
if p.Name == "" {
return fmt.Errorf("providers[%d]: name is required", i)
}
if seen[p.Name] {
return fmt.Errorf("providers[%d]: duplicate provider name %q", i, p.Name)
}
seen[p.Name] = true
switch p.Type {
case "mock":
if p.GCP != nil {
return fmt.Errorf("providers[%d] %q: type is mock but a gcp block is set", i, p.Name)
}
if p.Mock != nil && p.Mock.FailWith != "" && !validFailClasses[p.Mock.FailWith] {
return fmt.Errorf("providers[%d] %q: unknown mock.failWith %q", i, p.Name, p.Mock.FailWith)
}
case "gcp":
if p.Mock != nil {
return fmt.Errorf("providers[%d] %q: type is gcp but a mock block is set", i, p.Name)
}
if p.GCP == nil || p.GCP.Project == "" {
return fmt.Errorf("providers[%d] %q: gcp.project is required", i, p.Name)
}
case "":
return fmt.Errorf("providers[%d] %q: type is required", i, p.Name)
default:
return fmt.Errorf("providers[%d] %q: unknown provider type %q", i, p.Name, p.Type)
}
}
return nil
}

View File

@@ -0,0 +1,173 @@
package provider
import (
"strings"
"testing"
)
func TestLoadConfig_valid(t *testing.T) {
t.Parallel()
data := []byte(`
providers:
- name: mock
type: mock
mock:
provisionDelaySeconds: 2
- name: gcp-eu
type: gcp
gcp:
project: my-project
network: custom-net
`)
cfg, err := LoadConfig(data)
if err != nil {
t.Fatalf("LoadConfig() error = %v, want nil", err)
}
if len(cfg.Providers) != 2 {
t.Fatalf("len(cfg.Providers) = %d, want 2", len(cfg.Providers))
}
if cfg.Providers[0].Mock == nil || cfg.Providers[0].Mock.ProvisionDelaySeconds != 2 {
t.Errorf("providers[0].mock = %+v, want ProvisionDelaySeconds=2", cfg.Providers[0].Mock)
}
if cfg.Providers[1].GCP == nil || cfg.Providers[1].GCP.Project != "my-project" {
t.Errorf("providers[1].gcp = %+v, want Project=my-project", cfg.Providers[1].GCP)
}
}
func TestLoadConfig_invalid(t *testing.T) {
t.Parallel()
tests := []struct {
name string
yaml string
wantErrSub string
}{
{
name: "empty providers list",
yaml: `providers: []`,
wantErrSub: "at least one provider",
},
{
name: "missing name",
yaml: `
providers:
- type: mock`,
wantErrSub: "name is required",
},
{
name: "duplicate name",
yaml: `
providers:
- name: mock
type: mock
- name: mock
type: mock`,
wantErrSub: "duplicate provider name",
},
{
name: "unknown type",
yaml: `
providers:
- name: p1
type: azure`,
wantErrSub: `unknown provider type "azure"`,
},
{
name: "missing type",
yaml: `
providers:
- name: p1`,
wantErrSub: "type is required",
},
{
name: "gcp missing project",
yaml: `
providers:
- name: gcp-eu
type: gcp`,
wantErrSub: "gcp.project is required",
},
{
name: "gcp with empty project",
yaml: `
providers:
- name: gcp-eu
type: gcp
gcp:
project: ""`,
wantErrSub: "gcp.project is required",
},
{
name: "mock type with gcp block",
yaml: `
providers:
- name: p1
type: mock
gcp:
project: my-project`,
wantErrSub: "type is mock but a gcp block is set",
},
{
name: "gcp type with mock block",
yaml: `
providers:
- name: p1
type: gcp
gcp:
project: my-project
mock:
failNextCreates: 1`,
wantErrSub: "type is gcp but a mock block is set",
},
{
name: "unknown mock.failWith",
yaml: `
providers:
- name: p1
type: mock
mock:
failWith: oops`,
wantErrSub: `unknown mock.failWith "oops"`,
},
{
name: "strict mode rejects unknown top-level key",
yaml: `
providers:
- name: p1
type: mock
extraneous: true`,
wantErrSub: "parsing providers config",
},
{
name: "strict mode rejects unknown provider key",
yaml: `
providers:
- name: p1
type: mock
bogus: true`,
wantErrSub: "parsing providers config",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := LoadConfig([]byte(tc.yaml))
if err == nil {
t.Fatalf("LoadConfig() error = nil, want error containing %q", tc.wantErrSub)
}
if !strings.Contains(err.Error(), tc.wantErrSub) {
t.Errorf("LoadConfig() error = %q, want substring %q", err.Error(), tc.wantErrSub)
}
})
}
}
func TestLoadConfigFile_missingFile(t *testing.T) {
t.Parallel()
_, err := LoadConfigFile("/nonexistent/providers.yaml")
if err == nil {
t.Fatal("LoadConfigFile() error = nil, want error")
}
if !strings.Contains(err.Error(), "reading providers config") {
t.Errorf("LoadConfigFile() error = %q, want substring %q", err.Error(), "reading providers config")
}
}

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

View File

@@ -0,0 +1,70 @@
package provider
import (
"errors"
"fmt"
"testing"
)
// sdkError simulates an underlying provider SDK error type, so tests can
// assert errors.As reaches through the wrapper to it.
type sdkError struct{ code int }
func (e *sdkError) Error() string { return fmt.Sprintf("sdk error %d", e.code) }
func TestError_IsAndAs(t *testing.T) {
t.Parallel()
underlying := &sdkError{code: 429}
err := Wrap(ErrQuotaExceeded, "create", "gcp-eu", "zones/z/instances/x", underlying)
if !errors.Is(err, ErrQuotaExceeded) {
t.Error("errors.Is(err, ErrQuotaExceeded) = false, want true")
}
if errors.Is(err, ErrNotFound) {
t.Error("errors.Is(err, ErrNotFound) = true, want false")
}
var sdk *sdkError
if !errors.As(err, &sdk) {
t.Fatal("errors.As(err, &sdk) = false, want true")
}
if sdk.code != 429 {
t.Errorf("recovered sdkError.code = %d, want 429", sdk.code)
}
}
func TestError_WrapWithNilUnderlying(t *testing.T) {
t.Parallel()
err := Wrap(ErrPermanent, "get", "mock", "id", nil)
if !errors.Is(err, ErrPermanent) {
t.Error("errors.Is(err, ErrPermanent) = false, want true")
}
if err.Error() == "" {
t.Error("Error() returned an empty string")
}
}
func TestClass(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
want error
}{
{"nil returns nil", nil, nil},
{"wrapped not found", Wrap(ErrNotFound, "get", "mock", "id", nil), ErrNotFound},
{"wrapped quota", Wrap(ErrQuotaExceeded, "create", "gcp", "id", nil), ErrQuotaExceeded},
{"wrapped permanent", Wrap(ErrPermanent, "create", "gcp", "id", nil), ErrPermanent},
{"wrapped transient", Wrap(ErrTransient, "create", "gcp", "id", nil), ErrTransient},
{"unclassified defaults to transient", errors.New("boom"), ErrTransient},
{"further-wrapped preserves classification", fmt.Errorf("outer: %w", Wrap(ErrQuotaExceeded, "create", "gcp", "id", nil)), ErrQuotaExceeded},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := Class(tc.err); got != tc.want {
t.Errorf("Class(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}

33
internal/provider/name.go Normal file
View File

@@ -0,0 +1,33 @@
package provider
import (
"crypto/sha256"
"encoding/base32"
"strings"
"k8s.io/apimachinery/pkg/types"
)
const namePrefix = "proxy-"
// NameFromUID derives a deterministic instance name from a Proxy CR's UID.
// Provider.Create implementations key idempotency on this name: a repeat
// call after a crash must find the existing instance by this name rather
// than create a duplicate, and the reconciler relies on that to recover
// providerID after a crash even if status was wiped.
//
// SHA-256 of the UID, truncated to the first 10 bytes, RFC 4648 base32
// (no padding), lowercased: exactly 16 characters, for 22 total with the
// "proxy-" prefix. That gives 80 bits of collision resistance — a birthday
// collision only becomes likely around 2^40 objects, against a fleet of
// tens — while satisfying GCP's instance name rules
// (^[a-z]([-a-z0-9]{0,61}[a-z0-9])?$, <=63 chars) with headroom, since GCP
// auto-names some resources (e.g. the boot disk) after the instance name.
// base32's alphabet (A-Z2-7) is entirely legal once lowercased; base64 is
// not (has '+', '/', and uppercase), and hex would need 20 characters for
// the same 80 bits.
func NameFromUID(uid types.UID) string {
sum := sha256.Sum256([]byte(uid))
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
return namePrefix + strings.ToLower(enc.EncodeToString(sum[:10]))
}

View File

@@ -0,0 +1,61 @@
package provider
import (
"fmt"
"regexp"
"testing"
"k8s.io/apimachinery/pkg/types"
)
var nameRegexp = regexp.MustCompile(`^proxy-[a-z2-7]{16}$`)
func TestNameFromUID_idempotent(t *testing.T) {
t.Parallel()
uid := types.UID("f47ac10b-58cc-4372-a567-0e02b2c3d479")
got1 := NameFromUID(uid)
got2 := NameFromUID(uid)
if got1 != got2 {
t.Errorf("NameFromUID(%q) is not idempotent: %q != %q", uid, got1, got2)
}
}
func TestNameFromUID_charsetAndLength(t *testing.T) {
t.Parallel()
tests := []types.UID{
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
"",
"a",
"00000000-0000-0000-0000-000000000000",
"ffffffff-ffff-ffff-ffff-ffffffffffff",
}
for _, uid := range tests {
t.Run(string(uid), func(t *testing.T) {
t.Parallel()
name := NameFromUID(uid)
if !nameRegexp.MatchString(name) {
t.Errorf("NameFromUID(%q) = %q, does not match %s", uid, name, nameRegexp)
}
if len(name) > 63 {
t.Errorf("NameFromUID(%q) = %q, length %d exceeds GCP's 63-char limit", uid, name, len(name))
}
if len(name) != len(namePrefix)+16 {
t.Errorf("NameFromUID(%q) = %q, length %d, want %d", uid, name, len(name), len(namePrefix)+16)
}
})
}
}
func TestNameFromUID_distinctAcrossFleet(t *testing.T) {
t.Parallel()
const n = 10000
seen := make(map[string]types.UID, n)
for i := range n {
uid := types.UID(fmt.Sprintf("00000000-0000-0000-0000-%012d", i))
name := NameFromUID(uid)
if prior, ok := seen[name]; ok {
t.Fatalf("collision: NameFromUID(%q) == NameFromUID(%q) == %q", uid, prior, name)
}
seen[name] = uid
}
}

View File

@@ -0,0 +1,109 @@
// Package provider defines the contract every cloud provider backend
// implements: Create/Get/Delete/ListByTag for a single VM, keyed by an
// opaque providerID. Concrete implementations live in subpackages (mock,
// gcp); this package has no dependency on any of them, so a type→
// constructor registry can be assembled at the composition root (cmd/main.go)
// without an import cycle.
package provider
import (
"context"
"time"
)
// InstanceState is a provider's lifecycle state for a single VM.
type InstanceState string
const (
StateProvisioning InstanceState = "Provisioning"
StateRunning InstanceState = "Running"
StateStopped InstanceState = "Stopped"
StateTerminated InstanceState = "Terminated"
)
// GC contract: every cloud resource a provider creates must carry these two
// labels/tags. Orphan GC (internal/gc) relies on both — Managed to find
// resources it owns at all, UID to decide whether a resource is still
// claimed by a live Proxy CR.
const (
LabelManaged = "proxy-operator-managed"
LabelManagedYes = "true"
LabelUID = "proxy-operator-uid"
)
// Instance is a provider's view of a single VM.
type Instance struct {
// ID is the opaque providerID, stable for the life of the VM.
ID string
// IP is the VM's current address, empty until it's assigned one.
IP string
// State is the VM's current lifecycle state.
State InstanceState
// UID is the LabelUID value read back off the resource — the owning
// Proxy CR's UID, or "" if the resource predates this label (shouldn't
// happen for anything this operator created, but Get/ListByTag callers
// must tolerate it rather than panic).
UID string
// CreatedAt is when the provider created the resource. Orphan GC uses
// this to skip young instances that may still be mid-create, avoiding a
// race with an in-flight Create whose status write hasn't landed yet.
CreatedAt time.Time
}
// Placement is the provider-opaque placement/size configuration a Create
// call needs. It deliberately does not import api/v1alpha1 — this package
// stays independent of the CRD types; the reconciler maps
// v1alpha1.PlacementSpec to this struct when calling Create. Providers may
// ignore fields that don't apply to them.
type Placement struct {
Region string
Zone string
MachineType string
Image string
}
// CreateRequest carries everything a provider needs to create a VM.
type CreateRequest struct {
// Name is the deterministic instance name, already derived from the
// owning CR's UID via NameFromUID. Create must be idempotent keyed on
// this name: a repeat call after a crash must find the existing
// instance rather than create a duplicate.
Name string
// UID is the owning Proxy CR's UID. Create must tag/label the created
// resource with LabelUID=UID and LabelManaged=LabelManagedYes.
UID string
Namespace string
ProxyName string
Placement Placement
// CloudInit is the already-resolved user-data (a Secret reference, if
// used, has already been read by the caller).
CloudInit string
Port int32
}
// Provider is the contract every cloud backend implements. Kept
// deliberately minimal: this is the same interface five future providers
// must satisfy.
type Provider interface {
// Create starts VM creation and returns as soon as the request is
// submitted — it does not block until the VM is running. Must be
// idempotent by req.Name, so a repeat call after a crash finds the
// existing VM instead of duplicating it.
Create(ctx context.Context, req CreateRequest) (providerID string, err error)
// Get returns the current state of a previously created instance.
// Returning (nil, ErrNotFound) is a normal, expected outcome — it
// drives the reconciler's replacement and adoption logic, not an
// exceptional condition.
Get(ctx context.Context, providerID string) (*Instance, error)
// Delete is idempotent: deleting an instance that no longer exists is
// not an error.
Delete(ctx context.Context, providerID string) error
// ListByTag returns every instance this operator has ever tagged with
// LabelManaged=LabelManagedYes, for orphan GC.
ListByTag(ctx context.Context) ([]Instance, error)
}

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