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>
641 lines
18 KiB
Go
641 lines
18 KiB
Go
package deployer
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
networkingv1 "k8s.io/api/networking/v1"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
func TestBuildIngress_WithBasePath(t *testing.T) {
|
|
d := &Deployer{config: Config{IngressClass: "traefik", TLSIssuer: "letsencrypt"}}
|
|
pathType := networkingv1.PathTypePrefix
|
|
|
|
tests := []struct {
|
|
name string
|
|
spec domain.DeploySpec
|
|
expectedPath string
|
|
}{
|
|
{
|
|
name: "uses BasePath when set",
|
|
spec: domain.DeploySpec{
|
|
ProjectName: "myapp",
|
|
Domain: "myapp.threesix.ai",
|
|
Port: 8080,
|
|
BasePath: "/api/auth",
|
|
},
|
|
expectedPath: "/api/auth",
|
|
},
|
|
{
|
|
name: "defaults to / when BasePath empty",
|
|
spec: domain.DeploySpec{
|
|
ProjectName: "myapp",
|
|
Domain: "myapp.threesix.ai",
|
|
Port: 8080,
|
|
BasePath: "",
|
|
},
|
|
expectedPath: "/",
|
|
},
|
|
{
|
|
name: "root path explicit",
|
|
spec: domain.DeploySpec{
|
|
ProjectName: "myapp",
|
|
Domain: "myapp.threesix.ai",
|
|
Port: 3000,
|
|
BasePath: "/",
|
|
},
|
|
expectedPath: "/",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ingress := d.buildIngress(tc.spec, "projects", pathType, "traefik", "myapp-tls", nil)
|
|
|
|
if len(ingress.Spec.Rules) != 1 {
|
|
t.Fatalf("expected 1 rule, got %d", len(ingress.Spec.Rules))
|
|
}
|
|
if len(ingress.Spec.Rules[0].HTTP.Paths) != 1 {
|
|
t.Fatalf("expected 1 path, got %d", len(ingress.Spec.Rules[0].HTTP.Paths))
|
|
}
|
|
|
|
actualPath := ingress.Spec.Rules[0].HTTP.Paths[0].Path
|
|
if actualPath != tc.expectedPath {
|
|
t.Errorf("path = %q, want %q", actualPath, tc.expectedPath)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildIngress_Labels(t *testing.T) {
|
|
d := &Deployer{config: Config{IngressClass: "traefik"}}
|
|
pathType := networkingv1.PathTypePrefix
|
|
|
|
t.Run("includes component label for monorepo component", func(t *testing.T) {
|
|
spec := domain.DeploySpec{
|
|
ProjectName: "myapp",
|
|
ComponentPath: "services/api",
|
|
Domain: "myapp.threesix.ai",
|
|
Port: 8080,
|
|
}
|
|
|
|
ingress := d.buildIngress(spec, "projects", pathType, "traefik", "myapp-tls", nil)
|
|
|
|
if ingress.Labels["component"] != "services-api" {
|
|
t.Errorf("component label = %q, want %q", ingress.Labels["component"], "services-api")
|
|
}
|
|
if ingress.Labels["project"] != "myapp" {
|
|
t.Errorf("project label = %q, want %q", ingress.Labels["project"], "myapp")
|
|
}
|
|
})
|
|
|
|
t.Run("no component label for single-app project", func(t *testing.T) {
|
|
spec := domain.DeploySpec{
|
|
ProjectName: "myapp",
|
|
Domain: "myapp.threesix.ai",
|
|
Port: 8080,
|
|
}
|
|
|
|
ingress := d.buildIngress(spec, "projects", pathType, "traefik", "myapp-tls", nil)
|
|
|
|
if _, ok := ingress.Labels["component"]; ok {
|
|
t.Error("component label should not be present for single-app project")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSortIngressPaths(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input []string
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "sorts by length descending",
|
|
input: []string{"/", "/api", "/api/auth"},
|
|
expected: []string{"/api/auth", "/api", "/"},
|
|
},
|
|
{
|
|
name: "same length sorted alphabetically",
|
|
input: []string{"/api", "/web"},
|
|
expected: []string{"/api", "/web"},
|
|
},
|
|
{
|
|
name: "already sorted",
|
|
input: []string{"/api/auth", "/api", "/"},
|
|
expected: []string{"/api/auth", "/api", "/"},
|
|
},
|
|
{
|
|
name: "empty list",
|
|
input: []string{},
|
|
expected: []string{},
|
|
},
|
|
{
|
|
name: "single item",
|
|
input: []string{"/api"},
|
|
expected: []string{"/api"},
|
|
},
|
|
{
|
|
name: "reverse order",
|
|
input: []string{"/", "/a", "/ab", "/abc"},
|
|
expected: []string{"/abc", "/ab", "/a", "/"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pathType := networkingv1.PathTypePrefix
|
|
paths := make([]networkingv1.HTTPIngressPath, len(tc.input))
|
|
for i, p := range tc.input {
|
|
paths[i] = networkingv1.HTTPIngressPath{
|
|
Path: p,
|
|
PathType: &pathType,
|
|
}
|
|
}
|
|
|
|
sortIngressPaths(paths)
|
|
|
|
for i, expected := range tc.expected {
|
|
if paths[i].Path != expected {
|
|
t.Errorf("paths[%d] = %q, want %q", i, paths[i].Path, expected)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSanitizeLabelValue(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected string
|
|
}{
|
|
{"services/api", "services-api"},
|
|
{"apps/web", "apps-web"},
|
|
{"services/auth-api", "services-auth-api"},
|
|
{"simple", "simple"},
|
|
{"", ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
result := sanitizeLabelValue(tc.input)
|
|
if result != tc.expected {
|
|
t.Errorf("sanitizeLabelValue(%q) = %q, want %q", tc.input, result, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// AddIngressPath Tests
|
|
// =============================================================================
|
|
|
|
func TestAddIngressPath_CreatesNewIngress(t *testing.T) {
|
|
mock := newMockIngressClient()
|
|
d := NewDeployerWithIngressClient(nil, mock, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
TLSIssuer: "letsencrypt",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api/auth", "myapp-auth", 8001)
|
|
if err != nil {
|
|
t.Fatalf("AddIngressPath failed: %v", err)
|
|
}
|
|
|
|
ingress, err := mock.GetIngress(ctx, "projects", "myapp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get created ingress: %v", err)
|
|
}
|
|
|
|
// Verify ingress structure
|
|
if ingress.Name != "myapp" {
|
|
t.Errorf("ingress name = %q, want %q", ingress.Name, "myapp")
|
|
}
|
|
if ingress.Labels["project"] != "myapp" {
|
|
t.Errorf("project label = %q, want %q", ingress.Labels["project"], "myapp")
|
|
}
|
|
if len(ingress.Spec.Rules) != 1 {
|
|
t.Fatalf("expected 1 rule, got %d", len(ingress.Spec.Rules))
|
|
}
|
|
if ingress.Spec.Rules[0].Host != "myapp.threesix.ai" {
|
|
t.Errorf("host = %q, want %q", ingress.Spec.Rules[0].Host, "myapp.threesix.ai")
|
|
}
|
|
if len(ingress.Spec.Rules[0].HTTP.Paths) != 1 {
|
|
t.Fatalf("expected 1 path, got %d", len(ingress.Spec.Rules[0].HTTP.Paths))
|
|
}
|
|
path := ingress.Spec.Rules[0].HTTP.Paths[0]
|
|
if path.Path != "/api/auth" {
|
|
t.Errorf("path = %q, want %q", path.Path, "/api/auth")
|
|
}
|
|
if path.Backend.Service.Name != "myapp-auth" {
|
|
t.Errorf("service name = %q, want %q", path.Backend.Service.Name, "myapp-auth")
|
|
}
|
|
if path.Backend.Service.Port.Number != 8001 {
|
|
t.Errorf("port = %d, want %d", path.Backend.Service.Port.Number, 8001)
|
|
}
|
|
|
|
// Verify TLS
|
|
if len(ingress.Spec.TLS) != 1 {
|
|
t.Fatalf("expected 1 TLS entry, got %d", len(ingress.Spec.TLS))
|
|
}
|
|
if ingress.Spec.TLS[0].Hosts[0] != "myapp.threesix.ai" {
|
|
t.Errorf("TLS host = %q, want %q", ingress.Spec.TLS[0].Hosts[0], "myapp.threesix.ai")
|
|
}
|
|
|
|
// Verify annotations
|
|
if ingress.Annotations["cert-manager.io/cluster-issuer"] != "letsencrypt" {
|
|
t.Errorf("cluster-issuer annotation = %q, want %q", ingress.Annotations["cert-manager.io/cluster-issuer"], "letsencrypt")
|
|
}
|
|
}
|
|
|
|
func TestAddIngressPath_AddsPathToExistingIngress(t *testing.T) {
|
|
mock := newMockIngressClient()
|
|
d := NewDeployerWithIngressClient(nil, mock, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Create initial path
|
|
err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/", "myapp-web", 3000)
|
|
if err != nil {
|
|
t.Fatalf("initial AddIngressPath failed: %v", err)
|
|
}
|
|
|
|
// Add second path
|
|
err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
|
|
if err != nil {
|
|
t.Fatalf("second AddIngressPath failed: %v", err)
|
|
}
|
|
|
|
ingress, err := mock.GetIngress(ctx, "projects", "myapp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get ingress: %v", err)
|
|
}
|
|
|
|
// Should have 2 paths, sorted by specificity (longer first)
|
|
paths := ingress.Spec.Rules[0].HTTP.Paths
|
|
if len(paths) != 2 {
|
|
t.Fatalf("expected 2 paths, got %d", len(paths))
|
|
}
|
|
if paths[0].Path != "/api" {
|
|
t.Errorf("first path = %q, want %q (longer path first)", paths[0].Path, "/api")
|
|
}
|
|
if paths[1].Path != "/" {
|
|
t.Errorf("second path = %q, want %q", paths[1].Path, "/")
|
|
}
|
|
}
|
|
|
|
func TestAddIngressPath_UpdatesExistingPath(t *testing.T) {
|
|
mock := newMockIngressClient()
|
|
d := NewDeployerWithIngressClient(nil, mock, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Create initial path
|
|
err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api-v1", 8080)
|
|
if err != nil {
|
|
t.Fatalf("initial AddIngressPath failed: %v", err)
|
|
}
|
|
|
|
// Update same path with different service
|
|
err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api-v2", 8081)
|
|
if err != nil {
|
|
t.Fatalf("update AddIngressPath failed: %v", err)
|
|
}
|
|
|
|
ingress, err := mock.GetIngress(ctx, "projects", "myapp")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get ingress: %v", err)
|
|
}
|
|
|
|
// Should still have only 1 path (updated, not duplicated)
|
|
paths := ingress.Spec.Rules[0].HTTP.Paths
|
|
if len(paths) != 1 {
|
|
t.Fatalf("expected 1 path, got %d", len(paths))
|
|
}
|
|
if paths[0].Backend.Service.Name != "myapp-api-v2" {
|
|
t.Errorf("service = %q, want %q", paths[0].Backend.Service.Name, "myapp-api-v2")
|
|
}
|
|
if paths[0].Backend.Service.Port.Number != 8081 {
|
|
t.Errorf("port = %d, want %d", paths[0].Backend.Service.Port.Number, 8081)
|
|
}
|
|
}
|
|
|
|
func TestAddIngressPath_RetriesOnConflict(t *testing.T) {
|
|
// Use conflictNTimesClient which returns conflict errors N times then succeeds
|
|
conflictClient := newConflictNTimesClient(2) // Fail twice, succeed on third
|
|
d := NewDeployerWithIngressClient(nil, conflictClient, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Pre-create ingress so update is called (not create)
|
|
_, err := conflictClient.CreateIngress(ctx, "projects", &networkingv1.Ingress{
|
|
ObjectMeta: networkingv1.Ingress{}.ObjectMeta,
|
|
})
|
|
// Reset - manually create the ingress via the underlying mock
|
|
conflictClient.mockIngressClient.mu.Lock()
|
|
pathType := networkingv1.PathTypePrefix
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{
|
|
ObjectMeta: networkingv1.Ingress{}.ObjectMeta,
|
|
Spec: networkingv1.IngressSpec{
|
|
Rules: []networkingv1.IngressRule{
|
|
{
|
|
Host: "myapp.threesix.ai",
|
|
IngressRuleValue: networkingv1.IngressRuleValue{
|
|
HTTP: &networkingv1.HTTPIngressRuleValue{
|
|
Paths: []networkingv1.HTTPIngressPath{
|
|
{Path: "/", PathType: &pathType},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp"
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1"
|
|
conflictClient.mockIngressClient.mu.Unlock()
|
|
|
|
err = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
|
|
if err != nil {
|
|
t.Fatalf("should succeed after retries: %v", err)
|
|
}
|
|
|
|
// Should have called update 3 times (2 failures + 1 success)
|
|
if conflictClient.UpdateCallCount() != 3 {
|
|
t.Errorf("expected 3 update calls (2 failures + 1 success), got %d", conflictClient.UpdateCallCount())
|
|
}
|
|
}
|
|
|
|
func TestAddIngressPath_FailsAfterMaxRetries(t *testing.T) {
|
|
// Use conflictNTimesClient which always returns conflict
|
|
conflictClient := newConflictNTimesClient(maxIngressRetries + 1) // Always fail
|
|
d := NewDeployerWithIngressClient(nil, conflictClient, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Pre-create ingress
|
|
conflictClient.mockIngressClient.mu.Lock()
|
|
pathType := networkingv1.PathTypePrefix
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{
|
|
Spec: networkingv1.IngressSpec{
|
|
Rules: []networkingv1.IngressRule{
|
|
{
|
|
Host: "myapp.threesix.ai",
|
|
IngressRuleValue: networkingv1.IngressRuleValue{
|
|
HTTP: &networkingv1.HTTPIngressRuleValue{
|
|
Paths: []networkingv1.HTTPIngressPath{
|
|
{Path: "/", PathType: &pathType},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp"
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1"
|
|
conflictClient.mockIngressClient.mu.Unlock()
|
|
|
|
err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
|
|
if err == nil {
|
|
t.Fatal("expected error after max retries")
|
|
}
|
|
if conflictClient.UpdateCallCount() != maxIngressRetries {
|
|
t.Errorf("expected %d update calls, got %d", maxIngressRetries, conflictClient.UpdateCallCount())
|
|
}
|
|
}
|
|
|
|
func TestAddIngressPath_DoesNotRetryNonConflictErrors(t *testing.T) {
|
|
mock := newMockIngressClient()
|
|
// Set a non-conflict error
|
|
mock.getErr = fmt.Errorf("network error")
|
|
d := NewDeployerWithIngressClient(nil, mock, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
err := d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
|
|
// Should only call Get once (no retry for non-conflict errors)
|
|
if mock.getCalls.Load() != 1 {
|
|
t.Errorf("expected 1 get call, got %d", mock.getCalls.Load())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// RemoveIngressPath Tests
|
|
// =============================================================================
|
|
|
|
func TestRemoveIngressPath_RemovesPath(t *testing.T) {
|
|
mock := newMockIngressClient()
|
|
d := NewDeployerWithIngressClient(nil, mock, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Create ingress with two paths
|
|
_ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/", "myapp-web", 3000)
|
|
_ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
|
|
|
|
// Remove one path
|
|
err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api")
|
|
if err != nil {
|
|
t.Fatalf("RemoveIngressPath failed: %v", err)
|
|
}
|
|
|
|
ingress, _ := mock.GetIngress(ctx, "projects", "myapp")
|
|
paths := ingress.Spec.Rules[0].HTTP.Paths
|
|
if len(paths) != 1 {
|
|
t.Fatalf("expected 1 path remaining, got %d", len(paths))
|
|
}
|
|
if paths[0].Path != "/" {
|
|
t.Errorf("remaining path = %q, want %q", paths[0].Path, "/")
|
|
}
|
|
}
|
|
|
|
func TestRemoveIngressPath_DeletesIngressWhenEmpty(t *testing.T) {
|
|
mock := newMockIngressClient()
|
|
d := NewDeployerWithIngressClient(nil, mock, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Create ingress with single path
|
|
_ = d.AddIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api", "myapp-api", 8080)
|
|
|
|
// Remove the only path
|
|
err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api")
|
|
if err != nil {
|
|
t.Fatalf("RemoveIngressPath failed: %v", err)
|
|
}
|
|
|
|
// Ingress should be deleted
|
|
_, err = mock.GetIngress(ctx, "projects", "myapp")
|
|
if !errors.IsNotFound(err) {
|
|
t.Errorf("expected NotFound error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveIngressPath_NotFoundIsNoOp(t *testing.T) {
|
|
mock := newMockIngressClient()
|
|
d := NewDeployerWithIngressClient(nil, mock, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Remove from non-existent ingress - should not error
|
|
err := d.RemoveIngressPath(ctx, "nonexistent", "nonexistent.threesix.ai", "/api")
|
|
if err != nil {
|
|
t.Errorf("expected no error for non-existent ingress, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoveIngressPath_RetriesOnConflict(t *testing.T) {
|
|
conflictClient := newConflictNTimesClient(1) // Fail once, succeed on second
|
|
d := NewDeployerWithIngressClient(nil, conflictClient, Config{
|
|
Namespace: "projects",
|
|
IngressClass: "traefik",
|
|
})
|
|
ctx := context.Background()
|
|
|
|
// Pre-create ingress with two paths
|
|
conflictClient.mockIngressClient.mu.Lock()
|
|
pathType := networkingv1.PathTypePrefix
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"] = &networkingv1.Ingress{
|
|
Spec: networkingv1.IngressSpec{
|
|
Rules: []networkingv1.IngressRule{
|
|
{
|
|
Host: "myapp.threesix.ai",
|
|
IngressRuleValue: networkingv1.IngressRuleValue{
|
|
HTTP: &networkingv1.HTTPIngressRuleValue{
|
|
Paths: []networkingv1.HTTPIngressPath{
|
|
{Path: "/", PathType: &pathType},
|
|
{Path: "/api", PathType: &pathType},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"].Name = "myapp"
|
|
conflictClient.mockIngressClient.ingresses["projects/myapp"].ResourceVersion = "1"
|
|
conflictClient.mockIngressClient.mu.Unlock()
|
|
|
|
err := d.RemoveIngressPath(ctx, "myapp", "myapp.threesix.ai", "/api")
|
|
if err != nil {
|
|
t.Fatalf("should succeed after retry: %v", err)
|
|
}
|
|
|
|
// Should have called update 2 times (1 failure + 1 success)
|
|
if conflictClient.UpdateCallCount() != 2 {
|
|
t.Errorf("expected 2 update calls, got %d", conflictClient.UpdateCallCount())
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// retryOnConflict Tests
|
|
// =============================================================================
|
|
|
|
func TestRetryOnConflict_SucceedsImmediately(t *testing.T) {
|
|
calls := 0
|
|
err := retryOnConflict(context.Background(), func() error {
|
|
calls++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("expected 1 call, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryOnConflict_RetriesConflictErrors(t *testing.T) {
|
|
calls := 0
|
|
err := retryOnConflict(context.Background(), func() error {
|
|
calls++
|
|
if calls < 3 {
|
|
return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict"))
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if calls != 3 {
|
|
t.Errorf("expected 3 calls, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryOnConflict_FailsAfterMaxRetries(t *testing.T) {
|
|
calls := 0
|
|
err := retryOnConflict(context.Background(), func() error {
|
|
calls++
|
|
return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict"))
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error")
|
|
}
|
|
if calls != maxIngressRetries {
|
|
t.Errorf("expected %d calls, got %d", maxIngressRetries, calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryOnConflict_DoesNotRetryNonConflictErrors(t *testing.T) {
|
|
calls := 0
|
|
err := retryOnConflict(context.Background(), func() error {
|
|
calls++
|
|
return fmt.Errorf("some other error")
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error")
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("expected 1 call (no retry for non-conflict), got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryOnConflict_RespectsContextCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
calls := 0
|
|
|
|
// Cancel after first call
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
err := retryOnConflict(ctx, func() error {
|
|
calls++
|
|
return errors.NewConflict(schema.GroupResource{}, "test", fmt.Errorf("conflict"))
|
|
})
|
|
|
|
if err != context.Canceled {
|
|
t.Errorf("expected context.Canceled, got %v", err)
|
|
}
|
|
}
|