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>
176 lines
4.6 KiB
Go
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
|
|
}
|