From 1790afd0ee9707ae1cb9675dd5bde1d8634dfa7b Mon Sep 17 00:00:00 2001 From: jordan Date: Wed, 4 Feb 2026 01:31:50 -0700 Subject: [PATCH] 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 --- CLAUDE.md | 5 +- cookbooks/trees/composable-app.yaml | 2 +- cookbooks/trees/evolving-app.yaml | 96 +++ internal/adapter/deployer/deployer.go | 38 +- internal/adapter/deployer/k8s_client.go | 42 ++ .../adapter/deployer/k8s_client_mock_test.go | 175 +++++ internal/adapter/deployer/resources.go | 40 +- .../adapter/deployer/resources_ingress.go | 261 +++++++ internal/adapter/deployer/resources_test.go | 640 ++++++++++++++++++ .../app-astro/tailwind.config.mjs.tmpl | 7 +- .../components/app-react/tailwind.config.js | 8 +- .../service/internal/api/routes.go.tmpl | 9 +- .../service/internal/api/spec.go.tmpl | 12 +- internal/domain/component.go | 5 + internal/domain/component_test.go | 24 + internal/domain/deployment.go | 1 + .../handlers/infrastructure_mocks_test.go | 8 + internal/port/deployer.go | 10 + internal/service/component.go | 65 -- internal/service/component_deploy.go | 109 +++ internal/service/component_test.go | 82 +++ internal/service/project_infra_crud.go | 13 + 22 files changed, 1566 insertions(+), 86 deletions(-) create mode 100644 cookbooks/trees/evolving-app.yaml create mode 100644 internal/adapter/deployer/k8s_client.go create mode 100644 internal/adapter/deployer/k8s_client_mock_test.go create mode 100644 internal/adapter/deployer/resources_ingress.go create mode 100644 internal/adapter/deployer/resources_test.go create mode 100644 internal/service/component_deploy.go create mode 100644 internal/service/component_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 4813c1c..9da90e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,8 +23,9 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena | **Worker pool management** | [services/worker-pool.md](.claude/guides/services/worker-pool.md) | | **Project templates** | [services/templates.md](.claude/guides/services/templates.md) | | **Composable monorepo templates** | [services/composable-monorepo.md](.claude/guides/services/composable-monorepo.md) | -| **Write E2E cookbook test scripts** | [cookbook-scripts/SKILL.md](.claude/skills/cookbook-scripts/SKILL.md) | -| **Cookbook tree system (checkpoints)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) | +| **E2E testing strategy** | [services/e2e-testing-strategy.md](.claude/guides/services/e2e-testing-strategy.md) | +| **Cookbook tree system (commands)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) | +| **Write E2E cookbook scripts** | [cookbook-scripts/SKILL.md](.claude/skills/cookbook-scripts/SKILL.md) | | **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) | | **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) | | **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) | diff --git a/cookbooks/trees/composable-app.yaml b/cookbooks/trees/composable-app.yaml index 2c45a98..612a2c3 100644 --- a/cookbooks/trees/composable-app.yaml +++ b/cookbooks/trees/composable-app.yaml @@ -79,7 +79,7 @@ steps: description: Test API health endpoint depends_on: [verify-site] action: shell - command: "curl -s 'https://{{ .outputs.create-project.domain }}/api/health' 2>/dev/null || echo '{\"error\":\"connection failed\"}'" + command: "curl -s 'https://{{ .outputs.create-project.domain }}/api/{{ .vars.service_name }}/health' 2>/dev/null || echo '{\"error\":\"connection failed\"}'" on_error: continue teardown: diff --git a/cookbooks/trees/evolving-app.yaml b/cookbooks/trees/evolving-app.yaml new file mode 100644 index 0000000..1cd7005 --- /dev/null +++ b/cookbooks/trees/evolving-app.yaml @@ -0,0 +1,96 @@ +name: evolving-app +description: Deploy an app and then evolve it with a new feature using SDLC +version: 1 + +vars: + project_name: "" # Required + service_name: "api" + feature_slug: "health-check-v2" + +steps: + # --- Phase 1: Bootstrap (copied from composable-app) --- + create-project: + description: Create project with monorepo skeleton + action: api + method: POST + endpoint: /project + body: + name: "{{ .vars.project_name }}" + description: "Evolving App Test" + outputs: + - project_id: .data.name + - domain: .data.domain + + add-service: + description: Add backend service component + depends_on: [create-project] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" + body: + type: service + name: "{{ .vars.service_name }}" + template: service + outputs: + - service_path: .data.path + + wait-init-pipeline: + description: Wait for initial build + depends_on: [add-service] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 60 + + # --- Phase 2: Evolve (Add Feature) --- + create-feature: + description: Register new feature in SDLC + depends_on: [wait-init-pipeline] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features" + body: + slug: "{{ .vars.feature_slug }}" + title: "Add health check V2" + + generate-spec: + description: Ask Claude to spec the feature + depends_on: [create-feature] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/spec-feature {{ .vars.feature_slug }}" + auto_commit: true + auto_push: true + git_clone_url: "https://git.threesix.ai/jordan/{{ .outputs.create-project.project_id }}.git" + outputs: + - build_id: .data.task_id + + wait-feature-build: + description: Wait for the spec generation to finish + depends_on: [generate-spec] + action: shell + command: | + echo "Waiting for build {{ .outputs.generate-spec.build_id }}..." + for i in {1..60}; do + STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.generate-spec.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status') + echo "Attempt $i: Build status is $STATUS" + if [ "$STATUS" == "completed" ]; then exit 0; fi + if [ "$STATUS" == "failed" ]; then echo "Build failed"; exit 1; fi + sleep 5 + done + echo "Timeout waiting for build" + exit 1 + + check-artifact: + description: Verify spec artifact was created + depends_on: [wait-feature-build] + action: api + method: GET + endpoint: "/projects/{{ .outputs.create-project.project_id }}/sdlc/features/{{ .vars.feature_slug }}/artifacts" + +teardown: + - description: Delete project + action: api + method: DELETE + endpoint: "/project/{{ .outputs.create-project.project_id }}" diff --git a/internal/adapter/deployer/deployer.go b/internal/adapter/deployer/deployer.go index 5ed8dc0..4034fb6 100644 --- a/internal/adapter/deployer/deployer.go +++ b/internal/adapter/deployer/deployer.go @@ -35,8 +35,9 @@ type Config struct { // Deployer manages Kubernetes deployments for projects. type Deployer struct { - client *kubernetes.Clientset - config Config + client *kubernetes.Clientset + ingressClient IngressClient + config Config } // NewDeployer creates a new Deployer. @@ -51,8 +52,27 @@ func NewDeployer(client *kubernetes.Clientset, cfg Config) *Deployer { cfg.Namespace = "projects" } return &Deployer{ - client: client, - config: cfg, + client: client, + ingressClient: &k8sIngressClient{clientset: client}, + config: cfg, + } +} + +// NewDeployerWithIngressClient creates a Deployer with a custom IngressClient for testing. +func NewDeployerWithIngressClient(client *kubernetes.Clientset, ingressClient IngressClient, cfg Config) *Deployer { + if cfg.DefaultReplicas == 0 { + cfg.DefaultReplicas = 1 + } + if cfg.IngressClass == "" { + cfg.IngressClass = "traefik" + } + if cfg.Namespace == "" { + cfg.Namespace = "projects" + } + return &Deployer{ + client: client, + ingressClient: ingressClient, + config: cfg, } } @@ -99,9 +119,13 @@ func (d *Deployer) Deploy(ctx context.Context, spec domain.DeploySpec) error { return fmt.Errorf("failed to create service: %w", err) } - // Create or update Ingress - if err := d.createOrUpdateIngress(ctx, spec); err != nil { - return fmt.Errorf("failed to create ingress: %w", err) + // Create or update Ingress for single-app projects only. + // Monorepo components (with ComponentPath set) use unified project-level Ingress + // managed via AddIngressPath instead. + if spec.ComponentPath == "" { + if err := d.createOrUpdateIngress(ctx, spec); err != nil { + return fmt.Errorf("failed to create ingress: %w", err) + } } return nil diff --git a/internal/adapter/deployer/k8s_client.go b/internal/adapter/deployer/k8s_client.go new file mode 100644 index 0000000..789b416 --- /dev/null +++ b/internal/adapter/deployer/k8s_client.go @@ -0,0 +1,42 @@ +package deployer + +import ( + "context" + + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// IngressClient abstracts Kubernetes Ingress operations for testability. +type IngressClient interface { + GetIngress(ctx context.Context, namespace, name string) (*networkingv1.Ingress, error) + CreateIngress(ctx context.Context, namespace string, ingress *networkingv1.Ingress) (*networkingv1.Ingress, error) + UpdateIngress(ctx context.Context, namespace string, ingress *networkingv1.Ingress) (*networkingv1.Ingress, error) + DeleteIngress(ctx context.Context, namespace, name string) error +} + +// k8sIngressClient wraps kubernetes.Clientset to implement IngressClient. +type k8sIngressClient struct { + clientset *kubernetes.Clientset +} + +// GetIngress retrieves an Ingress by namespace and name. +func (c *k8sIngressClient) GetIngress(ctx context.Context, namespace, name string) (*networkingv1.Ingress, error) { + return c.clientset.NetworkingV1().Ingresses(namespace).Get(ctx, name, metav1.GetOptions{}) +} + +// CreateIngress creates a new Ingress in the specified namespace. +func (c *k8sIngressClient) CreateIngress(ctx context.Context, namespace string, ingress *networkingv1.Ingress) (*networkingv1.Ingress, error) { + return c.clientset.NetworkingV1().Ingresses(namespace).Create(ctx, ingress, metav1.CreateOptions{}) +} + +// UpdateIngress updates an existing Ingress in the specified namespace. +func (c *k8sIngressClient) UpdateIngress(ctx context.Context, namespace string, ingress *networkingv1.Ingress) (*networkingv1.Ingress, error) { + return c.clientset.NetworkingV1().Ingresses(namespace).Update(ctx, ingress, metav1.UpdateOptions{}) +} + +// DeleteIngress deletes an Ingress by namespace and name. +func (c *k8sIngressClient) DeleteIngress(ctx context.Context, namespace, name string) error { + return c.clientset.NetworkingV1().Ingresses(namespace).Delete(ctx, name, metav1.DeleteOptions{}) +} diff --git a/internal/adapter/deployer/k8s_client_mock_test.go b/internal/adapter/deployer/k8s_client_mock_test.go new file mode 100644 index 0000000..e29545a --- /dev/null +++ b/internal/adapter/deployer/k8s_client_mock_test.go @@ -0,0 +1,175 @@ +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 +} diff --git a/internal/adapter/deployer/resources.go b/internal/adapter/deployer/resources.go index 463f6df..b053848 100644 --- a/internal/adapter/deployer/resources.go +++ b/internal/adapter/deployer/resources.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -16,6 +17,37 @@ import ( "github.com/orchard9/rdev/internal/domain" ) +const ( + maxIngressRetries = 3 + retryBaseDelay = 100 * time.Millisecond +) + +// retryOnConflict executes fn with retry logic for K8s conflict errors. +// Uses exponential backoff: 100ms, 200ms, 400ms. +func retryOnConflict(ctx context.Context, fn func() error) error { + var lastErr error + for attempt := 0; attempt < maxIngressRetries; attempt++ { + err := fn() + if err == nil { + return nil + } + lastErr = err + + // Only retry on conflict errors (optimistic locking failure) + if !errors.IsConflict(err) { + return err + } + + // Check context before sleeping + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryBaseDelay * time.Duration(1< "services-api" @@ -249,6 +281,12 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw labels["component"] = sanitizeLabelValue(spec.ComponentPath) } + // Use BasePath if specified, otherwise default to "/" + path := "/" + if spec.BasePath != "" { + path = spec.BasePath + } + return &networkingv1.Ingress{ ObjectMeta: metav1.ObjectMeta{ Name: deploymentName, @@ -271,7 +309,7 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw HTTP: &networkingv1.HTTPIngressRuleValue{ Paths: []networkingv1.HTTPIngressPath{ { - Path: "/", + Path: path, PathType: &pathType, Backend: networkingv1.IngressBackend{ Service: &networkingv1.IngressServiceBackend{ diff --git a/internal/adapter/deployer/resources_ingress.go b/internal/adapter/deployer/resources_ingress.go new file mode 100644 index 0000000..ba2f068 --- /dev/null +++ b/internal/adapter/deployer/resources_ingress.go @@ -0,0 +1,261 @@ +package deployer + +import ( + "context" + "fmt" + "slices" + "strings" + + networkingv1 "k8s.io/api/networking/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AddIngressPath adds or updates a path rule in the project's unified Ingress. +// For monorepo projects, all components share a single Ingress with path-based routing. +// Paths are ordered by specificity (most specific first) to ensure correct matching. +// Uses retry with exponential backoff on K8s conflict errors. +func (d *Deployer) AddIngressPath(ctx context.Context, projectName, host, path, serviceName string, servicePort int) error { + ns := d.config.Namespace + + return retryOnConflict(ctx, func() error { + // Get existing ingress or create a new one + ingress, err := d.ingressClient.GetIngress(ctx, ns, projectName) + if errors.IsNotFound(err) { + // Create new ingress with this path + return d.createUnifiedIngress(ctx, projectName, host, path, serviceName, servicePort) + } + if err != nil { + return fmt.Errorf("failed to get ingress: %w", err) + } + + // Find the rule for this host + var ruleIndex = -1 + for i, rule := range ingress.Spec.Rules { + if rule.Host == host { + ruleIndex = i + break + } + } + + pathType := networkingv1.PathTypePrefix + newPath := networkingv1.HTTPIngressPath{ + Path: path, + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: serviceName, + Port: networkingv1.ServiceBackendPort{ + Number: int32(servicePort), + }, + }, + }, + } + + if ruleIndex >= 0 { + // Update existing rule - add or replace path + paths := ingress.Spec.Rules[ruleIndex].HTTP.Paths + updated := false + for i, p := range paths { + if p.Path == path { + paths[i] = newPath + updated = true + break + } + } + if !updated { + paths = append(paths, newPath) + } + // Sort paths by specificity (longer paths first) + sortIngressPaths(paths) + ingress.Spec.Rules[ruleIndex].HTTP.Paths = paths + } else { + // Add new rule for this host + ingress.Spec.Rules = append(ingress.Spec.Rules, networkingv1.IngressRule{ + Host: host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{newPath}, + }, + }, + }) + + // Add TLS entry for the new host if not already present + hostHasTLS := false + for _, tls := range ingress.Spec.TLS { + if slices.Contains(tls.Hosts, host) { + hostHasTLS = true + break + } + } + if !hostHasTLS { + tlsSecretName := strings.ReplaceAll(host, ".", "-") + "-tls" + ingress.Spec.TLS = append(ingress.Spec.TLS, networkingv1.IngressTLS{ + Hosts: []string{host}, + SecretName: tlsSecretName, + }) + } + } + + // Update the ingress + _, err = d.ingressClient.UpdateIngress(ctx, ns, ingress) + if err != nil { + return fmt.Errorf("failed to update ingress: %w", err) + } + + return nil + }) +} + +// createUnifiedIngress creates a new project-level Ingress with a single path. +func (d *Deployer) createUnifiedIngress(ctx context.Context, projectName, host, path, serviceName string, servicePort int) error { + ns := d.config.Namespace + pathType := networkingv1.PathTypePrefix + ingressClass := d.config.IngressClass + tlsSecretName := strings.ReplaceAll(host, ".", "-") + "-tls" + + annotations := map[string]string{} + if d.config.TLSIssuer != "" { + annotations["cert-manager.io/cluster-issuer"] = d.config.TLSIssuer + } + + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: projectName, + Namespace: ns, + Labels: map[string]string{"project": projectName}, + Annotations: annotations, + }, + Spec: networkingv1.IngressSpec{ + IngressClassName: &ingressClass, + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{host}, + SecretName: tlsSecretName, + }, + }, + Rules: []networkingv1.IngressRule{ + { + Host: host, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: path, + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: serviceName, + Port: networkingv1.ServiceBackendPort{ + Number: int32(servicePort), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + _, err := d.ingressClient.CreateIngress(ctx, ns, ingress) + if err != nil { + return fmt.Errorf("failed to create ingress: %w", err) + } + + return nil +} + +// RemoveIngressPath removes a path rule from the project's unified Ingress. +// If no paths remain for a host, the host rule is removed. +// If no rules remain, the Ingress is deleted. +// Uses retry with exponential backoff on K8s conflict errors. +func (d *Deployer) RemoveIngressPath(ctx context.Context, projectName, host, path string) error { + ns := d.config.Namespace + + return retryOnConflict(ctx, func() error { + ingress, err := d.ingressClient.GetIngress(ctx, ns, projectName) + if errors.IsNotFound(err) { + return nil // Already gone + } + if err != nil { + return fmt.Errorf("failed to get ingress: %w", err) + } + + // Find and update the rule for this host + var newRules []networkingv1.IngressRule + for _, rule := range ingress.Spec.Rules { + if rule.Host == host { + // Filter out the path to remove + var newPaths []networkingv1.HTTPIngressPath + for _, p := range rule.HTTP.Paths { + if p.Path != path { + newPaths = append(newPaths, p) + } + } + // Keep the rule only if it still has paths + if len(newPaths) > 0 { + rule.HTTP.Paths = newPaths + newRules = append(newRules, rule) + } + // If no paths left, skip adding this rule (effectively removing it) + } else { + newRules = append(newRules, rule) + } + } + + // If no rules left, delete the ingress + if len(newRules) == 0 { + err = d.ingressClient.DeleteIngress(ctx, ns, projectName) + if err != nil && !errors.IsNotFound(err) { + return fmt.Errorf("failed to delete ingress: %w", err) + } + return nil + } + + // Update TLS to remove hosts that no longer have rules + activeHosts := make(map[string]bool) + for _, rule := range newRules { + activeHosts[rule.Host] = true + } + + var newTLS []networkingv1.IngressTLS + for _, tls := range ingress.Spec.TLS { + var activeHostsInTLS []string + for _, h := range tls.Hosts { + if activeHosts[h] { + activeHostsInTLS = append(activeHostsInTLS, h) + } + } + if len(activeHostsInTLS) > 0 { + tls.Hosts = activeHostsInTLS + newTLS = append(newTLS, tls) + } + } + + ingress.Spec.Rules = newRules + ingress.Spec.TLS = newTLS + + _, err = d.ingressClient.UpdateIngress(ctx, ns, ingress) + if err != nil { + return fmt.Errorf("failed to update ingress: %w", err) + } + + return nil + }) +} + +// sortIngressPaths sorts paths by specificity (longer paths first). +// This ensures more specific paths like /api/auth match before /api. +func sortIngressPaths(paths []networkingv1.HTTPIngressPath) { + slices.SortFunc(paths, func(a, b networkingv1.HTTPIngressPath) int { + // Longer paths first (descending length) + if len(a.Path) != len(b.Path) { + return len(b.Path) - len(a.Path) + } + // Same length: alphabetical for consistency + return strings.Compare(a.Path, b.Path) + }) +} diff --git a/internal/adapter/deployer/resources_test.go b/internal/adapter/deployer/resources_test.go new file mode 100644 index 0000000..c7dab9e --- /dev/null +++ b/internal/adapter/deployer/resources_test.go @@ -0,0 +1,640 @@ +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) + } +} diff --git a/internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl b/internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl index 83cac5e..9c278a9 100644 --- a/internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl +++ b/internal/adapter/templates/templates/components/app-astro/tailwind.config.mjs.tmpl @@ -1,6 +1,11 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], + content: [ + './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', + // Include shared packages for Tailwind classes + '../../packages/ui/src/**/*.{js,ts,jsx,tsx}', + '../../packages/layout/src/**/*.{js,ts,jsx,tsx}', + ], theme: { extend: {}, }, diff --git a/internal/adapter/templates/templates/components/app-react/tailwind.config.js b/internal/adapter/templates/templates/components/app-react/tailwind.config.js index d21f1cd..6e1cb34 100644 --- a/internal/adapter/templates/templates/components/app-react/tailwind.config.js +++ b/internal/adapter/templates/templates/components/app-react/tailwind.config.js @@ -1,6 +1,12 @@ /** @type {import('tailwindcss').Config} */ export default { - content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + // Include shared packages for Tailwind classes + '../../packages/ui/src/**/*.{js,ts,jsx,tsx}', + '../../packages/layout/src/**/*.{js,ts,jsx,tsx}', + ], theme: { extend: {}, }, diff --git a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl index ee3dae7..64678d4 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/routes.go.tmpl @@ -9,6 +9,10 @@ import ( ) // RegisterRoutes registers all HTTP routes for the service. +// Routes are mounted under /api/{{COMPONENT_NAME}} to match the ingress path routing. +// This allows the monorepo to expose multiple services under a single domain: +// - https://domain/api/{{COMPONENT_NAME}}/health +// - https://domain/api/{{COMPONENT_NAME}}/examples func RegisterRoutes(application *app.App) { logger := application.Logger() cfg := config.Load() @@ -21,8 +25,9 @@ func RegisterRoutes(application *app.App) { spec := NewServiceSpec() application.EnableDocs(spec) - // Register API routes - application.Route("/api/v1", func(r app.Router) { + // Register API routes under /api/{service-name} to match ingress path routing. + // The ingress routes /api/{{COMPONENT_NAME}}/* to this service. + application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) { r.Get("/health", healthHandler.Check) // Public routes (no auth required) diff --git a/internal/adapter/templates/templates/components/service/internal/api/spec.go.tmpl b/internal/adapter/templates/templates/components/service/internal/api/spec.go.tmpl index 4ef3351..3a70f5b 100644 --- a/internal/adapter/templates/templates/components/service/internal/api/spec.go.tmpl +++ b/internal/adapter/templates/templates/components/service/internal/api/spec.go.tmpl @@ -30,7 +30,7 @@ func NewServiceSpec() *openapi.OpenAPISpec { })) // Health - spec.AddPath("/api/v1/health", "get", map[string]any{ + spec.AddPath("/api/{{COMPONENT_NAME}}/health", "get", map[string]any{ "summary": "Health check", "tags": []string{"Health"}, "responses": map[string]any{ @@ -42,7 +42,7 @@ func NewServiceSpec() *openapi.OpenAPISpec { }) // List examples - spec.AddPath("/api/v1/examples", "get", map[string]any{ + spec.AddPath("/api/{{COMPONENT_NAME}}/examples", "get", map[string]any{ "summary": "List examples", "description": "Returns a paginated list of examples.", "tags": []string{"Examples"}, @@ -53,7 +53,7 @@ func NewServiceSpec() *openapi.OpenAPISpec { }) // Get example - spec.AddPath("/api/v1/examples/{id}", "get", map[string]any{ + spec.AddPath("/api/{{COMPONENT_NAME}}/examples/{id}", "get", map[string]any{ "summary": "Get example by ID", "tags": []string{"Examples"}, "parameters": []any{openapi.IDParam()}, @@ -64,7 +64,7 @@ func NewServiceSpec() *openapi.OpenAPISpec { }) // Create example - spec.AddPath("/api/v1/examples", "post", map[string]any{ + spec.AddPath("/api/{{COMPONENT_NAME}}/examples", "post", map[string]any{ "summary": "Create example", "description": "Creates a new example. Requires authentication.", "tags": []string{"Examples"}, @@ -79,7 +79,7 @@ func NewServiceSpec() *openapi.OpenAPISpec { }) // Update example - spec.AddPath("/api/v1/examples/{id}", "put", map[string]any{ + spec.AddPath("/api/{{COMPONENT_NAME}}/examples/{id}", "put", map[string]any{ "summary": "Update example", "description": "Updates an existing example. Requires authentication.", "tags": []string{"Examples"}, @@ -95,7 +95,7 @@ func NewServiceSpec() *openapi.OpenAPISpec { }) // Delete example - spec.AddPath("/api/v1/examples/{id}", "delete", map[string]any{ + spec.AddPath("/api/{{COMPONENT_NAME}}/examples/{id}", "delete", map[string]any{ "summary": "Delete example", "description": "Deletes an example by ID. Requires authentication.", "tags": []string{"Examples"}, diff --git a/internal/domain/component.go b/internal/domain/component.go index bde0bac..ba6b4f8 100644 --- a/internal/domain/component.go +++ b/internal/domain/component.go @@ -86,6 +86,11 @@ func (c ComponentType) IsGoComponent() bool { return c == ComponentTypeService || c == ComponentTypeWorker || c == ComponentTypeCLI } +// IsAppComponent returns true if this component type is a frontend app (and gets "/" path). +func (c ComponentType) IsAppComponent() bool { + return c == ComponentTypeAppAstro || c == ComponentTypeAppReact || c == ComponentTypeAppNextJS +} + // componentNameRegex validates component names (slug format: lowercase, alphanumeric, dashes). var componentNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) diff --git a/internal/domain/component_test.go b/internal/domain/component_test.go index ff79855..03173d1 100644 --- a/internal/domain/component_test.go +++ b/internal/domain/component_test.go @@ -180,3 +180,27 @@ func TestValidComponentTypes(t *testing.T) { } } } + +func TestComponentType_IsAppComponent(t *testing.T) { + tests := []struct { + name string + componentType ComponentType + expected bool + }{ + {"service", ComponentTypeService, false}, + {"worker", ComponentTypeWorker, false}, + {"app-astro", ComponentTypeAppAstro, true}, + {"app-react", ComponentTypeAppReact, true}, + {"app-nextjs", ComponentTypeAppNextJS, true}, + {"cli", ComponentTypeCLI, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.componentType.IsAppComponent() + if result != tc.expected { + t.Errorf("%s.IsAppComponent() = %v, want %v", tc.componentType, result, tc.expected) + } + }) + } +} diff --git a/internal/domain/deployment.go b/internal/domain/deployment.go index ec396f7..445597f 100644 --- a/internal/domain/deployment.go +++ b/internal/domain/deployment.go @@ -13,6 +13,7 @@ type DeploySpec struct { Replicas int // Number of replicas EnvVars map[string]string // Plain environment variables Secrets map[string]string // Secret environment variables (stored in K8s Secret) + BasePath string // URL path prefix for Ingress (e.g., "/api/auth", "/"); empty defaults to "/" } // DeploymentName returns the K8s resource name for this deployment. diff --git a/internal/handlers/infrastructure_mocks_test.go b/internal/handlers/infrastructure_mocks_test.go index be767de..3467b9c 100644 --- a/internal/handlers/infrastructure_mocks_test.go +++ b/internal/handlers/infrastructure_mocks_test.go @@ -287,3 +287,11 @@ func (m *mockDeployer) GetComponentLogs(_ context.Context, _, _ string, _ int) ( } return m.logs, nil } + +func (m *mockDeployer) AddIngressPath(_ context.Context, _, _, _, _ string, _ int) error { + return m.err +} + +func (m *mockDeployer) RemoveIngressPath(_ context.Context, _, _, _ string) error { + return m.err +} diff --git a/internal/port/deployer.go b/internal/port/deployer.go index b0796af..c224bec 100644 --- a/internal/port/deployer.go +++ b/internal/port/deployer.go @@ -60,4 +60,14 @@ type Deployer interface { // RemoveIngressHost removes a host from an existing project's ingress. // This is used when removing domain aliases from a project. RemoveIngressHost(ctx context.Context, projectName, host string) error + + // AddIngressPath adds or updates a path rule in the project's unified Ingress. + // For monorepo projects, all components share a single Ingress with path-based routing. + // The path is the URL prefix (e.g., "/api/auth", "/"). + AddIngressPath(ctx context.Context, projectName, host, path, serviceName string, servicePort int) error + + // RemoveIngressPath removes a path rule from the project's unified Ingress. + // If no paths remain for a host, the host rule is removed. + // If no rules remain, the Ingress is deleted. + RemoveIngressPath(ctx context.Context, projectName, host, path string) error } diff --git a/internal/service/component.go b/internal/service/component.go index 46ce1b5..41d4fb6 100644 --- a/internal/service/component.go +++ b/internal/service/component.go @@ -203,71 +203,6 @@ func (s *ComponentService) AddComponent(ctx context.Context, projectID string, r return component, nil } -// assignPort finds the next available port for a component type. -func (s *ComponentService) assignPort(ctx context.Context, projectID string, componentType domain.ComponentType) (int, error) { - // Get existing components to find the highest used port - components, err := s.ListComponents(ctx, projectID) - if err != nil { - return 0, err - } - - startingPort := componentType.StartingPort() - if startingPort == 0 { - return 0, nil // Component type doesn't need a port - } - - maxPort := startingPort - 1 - for _, c := range components { - // Only consider components that share the same port range - if c.Type.StartingPort() == startingPort && c.Port > maxPort { - maxPort = c.Port - } - } - - return maxPort + 1, nil -} - -// createInitialComponentDeployment creates a K8s Deployment for a newly added component. -// This ensures the deployment exists before CI runs, so kubectl set image succeeds. -// Failures are logged but don't fail the component creation. -func (s *ComponentService) createInitialComponentDeployment( - ctx context.Context, - projectID, projectDomain string, - component *domain.Component, -) { - // Skip if no deployer or component doesn't need a deployment - if s.deployer == nil || !component.Type.NeedsPort() { - return - } - - // Build the image path - uses "latest" as placeholder until CI builds a real image - image := fmt.Sprintf("%s/%s/%s:latest", s.registryURL, projectID, component.Name) - - spec := domain.DeploySpec{ - ProjectName: projectID, - ComponentPath: component.Path, - Image: image, - Domain: projectDomain, - Port: component.Port, - Replicas: 1, - } - - if err := s.deployer.Deploy(ctx, spec); err != nil { - s.logger.Warn("failed to create initial component deployment", - "project", projectID, - "component", component.Name, - "error", err, - ) - return - } - - s.logger.Info("created initial component deployment", - "project", projectID, - "component", component.Name, - "image", image, - ) -} - // prepareMonorepoUpdates reads existing monorepo files and prepares updates. func (s *ComponentService) prepareMonorepoUpdates( ctx context.Context, diff --git a/internal/service/component_deploy.go b/internal/service/component_deploy.go new file mode 100644 index 0000000..1eb1669 --- /dev/null +++ b/internal/service/component_deploy.go @@ -0,0 +1,109 @@ +package service + +import ( + "context" + "fmt" + + "github.com/orchard9/rdev/internal/domain" +) + +// createInitialComponentDeployment creates a K8s Deployment for a newly added component. +// This ensures the deployment exists before CI runs, so kubectl set image succeeds. +// For monorepo projects, updates the project's unified Ingress with path-based routing. +// Failures are logged but don't fail the component creation. +func (s *ComponentService) createInitialComponentDeployment( + ctx context.Context, + projectID, projectDomain string, + component *domain.Component, +) { + // Skip if no deployer or component doesn't need a deployment + if s.deployer == nil || !component.Type.NeedsPort() { + return + } + + // Build the image path - uses "latest" as placeholder until CI builds a real image + image := fmt.Sprintf("%s/%s/%s:latest", s.registryURL, projectID, component.Name) + + // Assign URL path based on component type + basePath := assignComponentPath(component) + + spec := domain.DeploySpec{ + ProjectName: projectID, + ComponentPath: component.Path, + Image: image, + Domain: projectDomain, + Port: component.Port, + Replicas: 1, + BasePath: basePath, + } + + // Create Deployment and Service (without Ingress - we manage that separately) + if err := s.deployer.Deploy(ctx, spec); err != nil { + s.logger.Warn("failed to create initial component deployment", + "project", projectID, + "component", component.Name, + "error", err, + ) + return + } + + // Add path to project's unified Ingress + serviceName := spec.DeploymentName() + if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil { + s.logger.Warn("failed to add ingress path for component", + "project", projectID, + "component", component.Name, + "path", basePath, + "error", err, + ) + // Continue anyway - the deployment/service exist and CI will work + } + + s.logger.Info("created initial component deployment", + "project", projectID, + "component", component.Name, + "image", image, + "path", basePath, + ) +} + +// assignComponentPath determines the URL path for a component. +// Services get /api/{name}, apps get /. +// +// LIMITATION: All app components (app-react, app-astro, app-nextjs) get "/" path. +// If a project has multiple apps, they will conflict on the same path. +// Future enhancement: track assigned paths and use /apps/{name} for additional apps. +func assignComponentPath(component *domain.Component) string { + switch { + case component.Type == domain.ComponentTypeService: + return "/api/" + component.Name + case component.Type.IsAppComponent(): + return "/" + default: + return "" // Workers, CLI don't get HTTP paths + } +} + +// assignPort finds the next available port for a component type. +func (s *ComponentService) assignPort(ctx context.Context, projectID string, componentType domain.ComponentType) (int, error) { + // Get existing components to find the highest used port + components, err := s.ListComponents(ctx, projectID) + if err != nil { + return 0, err + } + + startingPort := componentType.StartingPort() + if startingPort == 0 { + return 0, nil // Component type doesn't need a port + } + + maxPort := startingPort - 1 + for _, c := range components { + // Only consider components that share the same port range + if c.Type.StartingPort() == startingPort && c.Port > maxPort { + maxPort = c.Port + } + } + + return maxPort + 1, nil +} diff --git a/internal/service/component_test.go b/internal/service/component_test.go new file mode 100644 index 0000000..3640624 --- /dev/null +++ b/internal/service/component_test.go @@ -0,0 +1,82 @@ +package service + +import ( + "testing" + + "github.com/orchard9/rdev/internal/domain" +) + +func TestAssignComponentPath(t *testing.T) { + tests := []struct { + name string + component *domain.Component + expectedPath string + }{ + { + name: "service gets /api/{name}", + component: &domain.Component{ + Type: domain.ComponentTypeService, + Name: "auth", + }, + expectedPath: "/api/auth", + }, + { + name: "service with different name", + component: &domain.Component{ + Type: domain.ComponentTypeService, + Name: "users", + }, + expectedPath: "/api/users", + }, + { + name: "app-react gets /", + component: &domain.Component{ + Type: domain.ComponentTypeAppReact, + Name: "web", + }, + expectedPath: "/", + }, + { + name: "app-astro gets /", + component: &domain.Component{ + Type: domain.ComponentTypeAppAstro, + Name: "landing", + }, + expectedPath: "/", + }, + { + name: "app-nextjs gets /", + component: &domain.Component{ + Type: domain.ComponentTypeAppNextJS, + Name: "dashboard", + }, + expectedPath: "/", + }, + { + name: "worker gets empty path", + component: &domain.Component{ + Type: domain.ComponentTypeWorker, + Name: "processor", + }, + expectedPath: "", + }, + { + name: "cli gets empty path", + component: &domain.Component{ + Type: domain.ComponentTypeCLI, + Name: "tool", + }, + expectedPath: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := assignComponentPath(tc.component) + if result != tc.expectedPath { + t.Errorf("assignComponentPath(%s %s) = %q, want %q", + tc.component.Type, tc.component.Name, result, tc.expectedPath) + } + }) + } +} diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index cfa0571..b233c08 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -410,7 +410,20 @@ func (s *ProjectInfraService) storeCredential(ctx context.Context, projectID, ca // This is called after template seeding to ensure the deployment exists before // the CI pipeline runs `kubectl set image`. The deployment will be in ImagePullBackOff // until the first CI build completes and pushes the image. +// +// For monorepo (skeleton) projects, no root deployment is created - components +// create their own deployments via ComponentService.AddComponent(). func (s *ProjectInfraService) createInitialDeployment(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult) { + // Skip root deployment for monorepo (skeleton) projects. + // Skeleton projects have no root Dockerfile - components create their own deployments. + if req.Template == "skeleton" { + s.logger.Info("skipping root deployment for monorepo project", + "project", req.Name, + "template", req.Template, + ) + return + } + if s.deployer == nil { result.NextSteps = append(result.NextSteps, "Deployer not configured - run POST /projects/{id}/deploy after first build") return