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
|
||||
}
|
||||
Reference in New Issue
Block a user