rdev/internal/adapter/deployer/resources_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

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