proxy-operator: Kubernetes operator for crawling-proxy fleets #1
@@ -6,7 +6,7 @@ Pairs with [docs/plans/2026-08-07-1747-proxy-operator.md](../plans/2026-08-07-17
|
||||
|
||||
- [x] Step 0 — Branch and scaffold
|
||||
- [x] Step 1 — API types (`api/v1alpha1/proxy_types.go`)
|
||||
- [ ] Step 2 — Provider contract (`internal/provider/`)
|
||||
- [x] Step 2 — Provider contract (`internal/provider/`)
|
||||
- [ ] Step 3 — Mock provider (`internal/provider/mock/`)
|
||||
- [ ] Step 4 — Reconciler (`internal/controller/`)
|
||||
- [ ] Step 5 — Health engine (`internal/health/`)
|
||||
@@ -148,3 +148,54 @@ which only `make test` does via `setup-envtest`. Plain `go test ./...` fails the
|
||||
`internal/controller` package with a `/usr/local/kubebuilder/bin/etcd: no such file`
|
||||
error that has nothing to do with the code. Use `make test`, not `go test ./...`,
|
||||
whenever the controller package is in scope.
|
||||
|
||||
## Step 2 — Provider contract (`internal/provider/`)
|
||||
|
||||
Wrote the `Provider` interface (`Create`/`Get`/`Delete`/`ListByTag`), `Instance`,
|
||||
`Placement`, `CreateRequest`, and the GC-contract label constants
|
||||
(`provider.go`); the error taxonomy with multi-error `Unwrap() []error` so
|
||||
`errors.Is` and `errors.As` both work off the same wrapped value
|
||||
(`errors.go`); deterministic instance naming via SHA-256 → base32 → 16
|
||||
chars (`name.go`); and `--providers-config` YAML parsing with fail-fast
|
||||
validation (`config.go`).
|
||||
|
||||
One deliberate deviation from the plan's file layout: the plan listed
|
||||
`internal/provider/metrics.go` as part of this step, but the
|
||||
`provider.WithMetrics` decorator it describes is Step 9's concern (it needs
|
||||
the Prometheus vectors that don't exist until the metrics package is
|
||||
built) and nothing in this step depends on it existing yet. Deferred to
|
||||
Step 9 rather than writing a decorator with nowhere to register its
|
||||
metrics.
|
||||
|
||||
The registry package (`internal/provider/registry/registry.go`) came out
|
||||
slightly different from the plan's sketch, and better for it: instead of a
|
||||
package-level `var builtin = map[string]Constructor{"mock": mock.New, "gcp":
|
||||
gcp.New}` living inside the registry package, `Build` takes the
|
||||
`map[string]Constructor` as a parameter. This means `registry` has zero
|
||||
import on `internal/provider/mock` or `internal/provider/gcp` — neither of
|
||||
which exists yet at this point in the plan (mock is Step 3, gcp is Step 8)
|
||||
— so the package compiles today instead of only once both are done, and the
|
||||
explicit wiring lives at the composition root (`cmd/main.go`, Step 10)
|
||||
rather than being smeared into the registry package itself. Still fully
|
||||
avoids the import-cycle trap the plan called out.
|
||||
|
||||
Ran the full suite:
|
||||
|
||||
```bash
|
||||
go mod tidy # sigs.k8s.io/yaml (already an indirect dep of the k8s.io toolchain) promoted to direct
|
||||
go build ./... && go vet ./...
|
||||
go test -race -v ./internal/provider/...
|
||||
make test
|
||||
```
|
||||
|
||||
`internal/provider` landed at 96.2% coverage, `internal/provider/registry` at
|
||||
100%. `make test` also ran `go fmt ./...`, which reformatted `errors.go`'s
|
||||
struct-field comment alignment before its first commit — no logic change,
|
||||
just gofmt on a brand-new file.
|
||||
|
||||
Worth noting: `sigs.k8s.io/yaml` (not `gopkg.in/yaml.v3`) was picked for
|
||||
`--providers-config` parsing specifically because it has `UnmarshalStrict`
|
||||
built in (rejects unknown fields, which is what "fail fast on unknown type"
|
||||
in the plan actually needs) and was already pulled in transitively by the
|
||||
k8s.io toolchain, so no new dependency was added — `go mod tidy` just
|
||||
promoted it from indirect to direct.
|
||||
|
||||
2
go.mod
2
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
k8s.io/apimachinery v0.36.0
|
||||
k8s.io/client-go v0.36.0
|
||||
sigs.k8s.io/controller-runtime v0.24.1
|
||||
sigs.k8s.io/yaml v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -96,5 +97,4 @@ require (
|
||||
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
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