Add the mock provider with a real CONNECT proxy per port (Step 3)

An in-memory provider.Provider whose state (Provisioning -> Running ->
Terminated -> purged) is a pure function of an injectable clock, not
background timers, so it's deterministic under tests and correct under
real time with no goroutine lifecycle to leak.

Once an instance is observed Running, it lazily acquires a real HTTP
CONNECT proxy listener so the health engine's through-the-proxy probe
(later steps) genuinely tunnels a request end to end, instead of the
healthcheck being simulated or bypassed for local development.

Redesigned the listener sharing model from what the plan assumed: the
plan's "one loopback IP per instance" doesn't work on macOS (only
127.0.0.1 binds without a privileged ifconfig alias, unlike Linux where
the whole 127.0.0.0/8 routes to loopback by default), and there's no
channel for a provider to report a port back to the reconciler anyway
(EffectivePort() is spec-only). Instances now share one real listener
per port, reference-counted at the package level rather than per
Provider instance, since a bound TCP port is a genuinely process-global
OS resource -- two separately configured mock-typed provider entries
must not both try to bind the same default port.

Fault injection wired both ways: MockConfig.FailNextCreates/FailWith for
demos, InjectCreateFailures(n, class) for tests. Create is idempotent by
name.

Caught and fixed a real test flake (not a logic bug): the freePort test
helper asked the OS for a free port via bind-then-close, a TOCTOU race
under t.Parallel() that let two tests collide on the same "free" port.
Replaced it with a monotonic counter, since these tests only need
uniqueness within the test run.

internal/provider/mock at 91.1% coverage, including an end-to-end test
that opens real sockets: Create -> Get past provisionDelay -> a real
http.Client tunnelling a CONNECT through the mock to a real TLS origin.
make test green across the whole repo.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-07 23:26:34 +02:00
parent a4a483acbc
commit ef1387dc01
6 changed files with 1016 additions and 2 deletions

View File

@@ -0,0 +1,257 @@
// Package mock is an in-memory provider.Provider for local development and
// tests. State transitions are a pure function of an injectable clock, not
// background timers, so behavior is deterministic under a fake clock and
// there is no goroutine lifecycle for provisioning/deletion to leak.
//
// Once an instance's state resolves to Running, the provider starts (or
// joins) a real, minimal HTTP CONNECT proxy listener — see proxy.go — so
// that a through-the-proxy healthcheck (internal/health) genuinely
// succeeds against it. That's the difference between this being a true
// end-to-end local demo and one that quietly bypasses the operator's core
// mechanism.
package mock
import (
"context"
"fmt"
"sync"
"time"
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
)
const (
defaultProvisionDelay = 5 * time.Second
defaultDeleteDelay = 1 * time.Second
)
// Provider is a thread-safe, in-memory provider.Provider.
type Provider struct {
mu sync.Mutex
name string
now func() time.Time
provisionDelay time.Duration
deleteDelay time.Duration
instances map[string]*record // keyed by instance name (== providerID)
failNext int
failClass error
}
type record struct {
uid string
port int32
createdAt time.Time
deletedAt time.Time // zero == not deleted
proxyAcquired bool
releaseProxy func()
}
// New builds a mock Provider from its config block. Satisfies
// registry.Constructor.
func New(_ context.Context, cfg provider.ProviderConfig) (provider.Provider, error) {
p := &Provider{
name: cfg.Name,
now: time.Now,
provisionDelay: defaultProvisionDelay,
deleteDelay: defaultDeleteDelay,
instances: make(map[string]*record),
}
if cfg.Mock == nil {
return p, nil
}
if cfg.Mock.ProvisionDelaySeconds > 0 {
p.provisionDelay = time.Duration(cfg.Mock.ProvisionDelaySeconds) * time.Second
}
if cfg.Mock.DeleteDelaySeconds > 0 {
p.deleteDelay = time.Duration(cfg.Mock.DeleteDelaySeconds) * time.Second
}
if cfg.Mock.FailNextCreates > 0 {
class, err := failClassFromString(cfg.Mock.FailWith)
if err != nil {
return nil, fmt.Errorf("mock provider %q: %w", cfg.Name, err)
}
p.failNext = cfg.Mock.FailNextCreates
p.failClass = class
}
return p, nil
}
func failClassFromString(s string) (error, error) {
switch s {
case "", provider.FailWithTransient:
return provider.ErrTransient, nil
case provider.FailWithNotFound:
return provider.ErrNotFound, nil
case provider.FailWithQuota:
return provider.ErrQuotaExceeded, nil
case provider.FailWithPermanent:
return provider.ErrPermanent, nil
default:
return nil, fmt.Errorf("unknown failWith %q", s)
}
}
// InjectCreateFailures makes the next n calls to Create fail, each
// returning an error classified as class. Exported for tests exercising
// the reconciler's error handling; config-driven FailNextCreates/FailWith
// (config.go) drives the same mechanism for demos.
func (p *Provider) InjectCreateFailures(n int, class error) {
p.mu.Lock()
defer p.mu.Unlock()
p.failNext = n
p.failClass = class
}
// Create is idempotent by req.Name: a repeat call for an existing,
// non-deleted instance returns its existing name rather than creating a
// duplicate. This is what lets the reconciler recover cleanly if it
// crashes between calling Create and persisting providerID — the next
// reconcile's Create call finds the same instance by its deterministic
// name.
func (p *Provider) Create(_ context.Context, req provider.CreateRequest) (string, error) {
p.mu.Lock()
defer p.mu.Unlock()
if existing, ok := p.instances[req.Name]; ok && existing.deletedAt.IsZero() {
return req.Name, nil
}
if p.failNext > 0 {
p.failNext--
return "", provider.Wrap(p.failClass, "create", p.name, req.Name, nil)
}
p.instances[req.Name] = &record{
uid: req.UID,
port: req.Port,
createdAt: p.now(),
}
return req.Name, nil
}
// Get returns the current state of a previously created instance,
// deriving it from the record's timestamps against the provider's clock.
// The first Get to observe a Running instance lazily acquires its real
// proxy listener.
func (p *Provider) Get(_ context.Context, providerID string) (*provider.Instance, error) {
p.mu.Lock()
defer p.mu.Unlock()
rec, ok := p.instances[providerID]
if !ok {
return nil, provider.Wrap(provider.ErrNotFound, "get", p.name, providerID, nil)
}
state, purge := p.stateLocked(rec)
if purge {
p.releaseLocked(rec)
delete(p.instances, providerID)
return nil, provider.Wrap(provider.ErrNotFound, "get", p.name, providerID, nil)
}
inst := &provider.Instance{
ID: providerID,
State: state,
UID: rec.uid,
CreatedAt: rec.createdAt,
}
if state == provider.StateRunning {
if !rec.proxyAcquired {
release, err := acquireProxy(rec.port)
if err != nil {
return nil, provider.Wrap(provider.ErrTransient, "get", p.name, providerID, err)
}
rec.releaseProxy = release
rec.proxyAcquired = true
}
inst.IP = mockProxyIP
}
return inst, nil
}
// Delete is idempotent: deleting an unknown or already-deleted instance is
// not an error. The real proxy listener (if any) is released immediately;
// the record itself lingers, reporting Terminated, until deleteDelay has
// passed and a later Get/ListByTag purges it — mirroring a real provider
// where the API stops accepting the ID before the resource fully vanishes.
func (p *Provider) Delete(_ context.Context, providerID string) error {
p.mu.Lock()
defer p.mu.Unlock()
rec, ok := p.instances[providerID]
if !ok {
return nil
}
if rec.deletedAt.IsZero() {
rec.deletedAt = p.now()
}
p.releaseLocked(rec)
return nil
}
// ListByTag returns every non-purged instance, mirroring what a real
// provider's tag/label-filtered list call would return. Also lazily purges
// (and releases) any instance whose deleteDelay has elapsed, since orphan
// GC — the only caller that runs regardless of whether anyone still calls
// Get on a given proxy — is what's responsible for eventually reclaiming
// deleted-and-expired instances in a real fleet.
func (p *Provider) ListByTag(_ context.Context) ([]provider.Instance, error) {
p.mu.Lock()
defer p.mu.Unlock()
var out []provider.Instance
for id, rec := range p.instances {
state, purge := p.stateLocked(rec)
if purge {
p.releaseLocked(rec)
delete(p.instances, id)
continue
}
inst := provider.Instance{
ID: id,
State: state,
UID: rec.uid,
CreatedAt: rec.createdAt,
}
if state == provider.StateRunning && rec.proxyAcquired {
inst.IP = mockProxyIP
}
out = append(out, inst)
}
return out, nil
}
// stateLocked derives state from rec's timestamps against p.now(). Must be
// called with p.mu held. purge is true once the instance has been deleted
// long enough that it should behave as fully gone (ErrNotFound to Get,
// absent from ListByTag).
func (p *Provider) stateLocked(rec *record) (state provider.InstanceState, purge bool) {
now := p.now()
if !rec.deletedAt.IsZero() {
if now.Sub(rec.deletedAt) < p.deleteDelay {
return provider.StateTerminated, false
}
return "", true
}
if now.Sub(rec.createdAt) < p.provisionDelay {
return provider.StateProvisioning, false
}
return provider.StateRunning, false
}
// releaseLocked releases rec's proxy listener reference, if it holds one.
// Must be called with p.mu held; acquireProxy/release use a separate lock
// (sharedProxies.mu), so this never deadlocks against it.
func (p *Provider) releaseLocked(rec *record) {
if rec.proxyAcquired {
rec.releaseProxy()
rec.proxyAcquired = false
rec.releaseProxy = nil
}
}