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:
133
internal/provider/config.go
Normal file
133
internal/provider/config.go
Normal 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
|
||||
}
|
||||
173
internal/provider/config_test.go
Normal file
173
internal/provider/config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
70
internal/provider/errors_test.go
Normal file
70
internal/provider/errors_test.go
Normal 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
33
internal/provider/name.go
Normal 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]))
|
||||
}
|
||||
61
internal/provider/name_test.go
Normal file
61
internal/provider/name_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
109
internal/provider/provider.go
Normal file
109
internal/provider/provider.go
Normal 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)
|
||||
}
|
||||
44
internal/provider/registry/registry.go
Normal file
44
internal/provider/registry/registry.go
Normal 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
|
||||
}
|
||||
92
internal/provider/registry/registry_test.go
Normal file
92
internal/provider/registry/registry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user