rdev/internal/adapter/deployer/k8s_client_mock_test.go
jordan 1790afd0ee feat: add path-based ingress management for component lifecycle
Adds AddIngressPath and RemoveIngressPath to the Deployer interface
for managing per-component ingress rules in monorepo projects.

- Implement conflict retry logic for concurrent ingress updates
- Add K8s client interface for testability
- Add comprehensive unit tests for ingress path operations
- Add component deployment and teardown methods to ComponentService
- Update service templates with OpenAPI spec improvements
- Add evolving-app cookbook tree for reference
- Split resources.go into resources_ingress.go for path-based routing
- Split component.go into component_deploy.go for deployment helpers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 01:31:50 -07:00

176 lines
4.6 KiB
Go

package deployer
import (
"context"
"fmt"
"strconv"
"sync"
"sync/atomic"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// mockIngressClient implements IngressClient for testing.
type mockIngressClient struct {
mu sync.RWMutex
ingresses map[string]*networkingv1.Ingress // key: "namespace/name"
// For testing error scenarios
getErr error
createErr error
updateErr error
deleteErr error
// Counters for verifying retry behavior
getCalls atomic.Int32
createCalls atomic.Int32
updateCalls atomic.Int32
deleteCalls atomic.Int32
}
func newMockIngressClient() *mockIngressClient {
return &mockIngressClient{
ingresses: make(map[string]*networkingv1.Ingress),
}
}
func (m *mockIngressClient) key(namespace, name string) string {
return namespace + "/" + name
}
func (m *mockIngressClient) GetIngress(ctx context.Context, namespace, name string) (*networkingv1.Ingress, error) {
m.getCalls.Add(1)
m.mu.RLock()
defer m.mu.RUnlock()
if m.getErr != nil {
return nil, m.getErr
}
ing, ok := m.ingresses[m.key(namespace, name)]
if !ok {
return nil, errors.NewNotFound(schema.GroupResource{Group: "networking.k8s.io", Resource: "ingresses"}, name)
}
return ing.DeepCopy(), nil
}
func (m *mockIngressClient) CreateIngress(ctx context.Context, namespace string, ingress *networkingv1.Ingress) (*networkingv1.Ingress, error) {
m.createCalls.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if m.createErr != nil {
return nil, m.createErr
}
key := m.key(namespace, ingress.Name)
if _, exists := m.ingresses[key]; exists {
return nil, errors.NewAlreadyExists(schema.GroupResource{Group: "networking.k8s.io", Resource: "ingresses"}, ingress.Name)
}
// Set resource version for conflict detection
ingress.ResourceVersion = "1"
m.ingresses[key] = ingress.DeepCopy()
return ingress.DeepCopy(), nil
}
func (m *mockIngressClient) UpdateIngress(ctx context.Context, namespace string, ingress *networkingv1.Ingress) (*networkingv1.Ingress, error) {
m.updateCalls.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if m.updateErr != nil {
return nil, m.updateErr
}
key := m.key(namespace, ingress.Name)
existing, ok := m.ingresses[key]
if !ok {
return nil, errors.NewNotFound(schema.GroupResource{Group: "networking.k8s.io", Resource: "ingresses"}, ingress.Name)
}
// Check resource version for optimistic locking
if ingress.ResourceVersion != existing.ResourceVersion {
return nil, errors.NewConflict(schema.GroupResource{Group: "networking.k8s.io", Resource: "ingresses"}, ingress.Name, fmt.Errorf("resource version mismatch"))
}
// Increment resource version
rv, _ := strconv.Atoi(existing.ResourceVersion)
ingress.ResourceVersion = strconv.Itoa(rv + 1)
m.ingresses[key] = ingress.DeepCopy()
return ingress.DeepCopy(), nil
}
func (m *mockIngressClient) DeleteIngress(ctx context.Context, namespace, name string) error {
m.deleteCalls.Add(1)
m.mu.Lock()
defer m.mu.Unlock()
if m.deleteErr != nil {
return m.deleteErr
}
delete(m.ingresses, m.key(namespace, name))
return nil
}
// conflictOnceError returns a conflict error only on the first call, then succeeds.
type conflictOnceError struct {
mu sync.Mutex
called bool
callCount int
}
func (e *conflictOnceError) Error() error {
e.mu.Lock()
defer e.mu.Unlock()
e.callCount++
if !e.called {
e.called = true
return errors.NewConflict(schema.GroupResource{Group: "networking.k8s.io", Resource: "ingresses"}, "test", fmt.Errorf("simulated conflict"))
}
return nil
}
func (e *conflictOnceError) CallCount() int {
e.mu.Lock()
defer e.mu.Unlock()
return e.callCount
}
// conflictNTimesClient wraps mockIngressClient to return conflict errors N times on update.
type conflictNTimesClient struct {
*mockIngressClient
conflictCount int
mu sync.Mutex
updateCalls int
}
func newConflictNTimesClient(n int) *conflictNTimesClient {
return &conflictNTimesClient{
mockIngressClient: newMockIngressClient(),
conflictCount: n,
}
}
func (c *conflictNTimesClient) UpdateIngress(ctx context.Context, namespace string, ingress *networkingv1.Ingress) (*networkingv1.Ingress, error) {
c.mu.Lock()
c.updateCalls++
callNum := c.updateCalls
c.mu.Unlock()
if callNum <= c.conflictCount {
return nil, errors.NewConflict(schema.GroupResource{Group: "networking.k8s.io", Resource: "ingresses"}, ingress.Name, fmt.Errorf("simulated conflict %d", callNum))
}
return c.mockIngressClient.UpdateIngress(ctx, namespace, ingress)
}
func (c *conflictNTimesClient) UpdateCallCount() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.updateCalls
}