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