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>
This commit is contained in:
jordan 2026-02-04 01:31:50 -07:00
parent 619a57c240
commit 1790afd0ee
22 changed files with 1566 additions and 86 deletions

View File

@ -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) | | **Worker pool management** | [services/worker-pool.md](.claude/guides/services/worker-pool.md) |
| **Project templates** | [services/templates.md](.claude/guides/services/templates.md) | | **Project templates** | [services/templates.md](.claude/guides/services/templates.md) |
| **Composable monorepo templates** | [services/composable-monorepo.md](.claude/guides/services/composable-monorepo.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) | | **E2E testing strategy** | [services/e2e-testing-strategy.md](.claude/guides/services/e2e-testing-strategy.md) |
| **Cookbook tree system (checkpoints)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.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 orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.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) | | **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) |

View File

@ -79,7 +79,7 @@ steps:
description: Test API health endpoint description: Test API health endpoint
depends_on: [verify-site] depends_on: [verify-site]
action: shell 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 on_error: continue
teardown: teardown:

View File

@ -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 }}"

View File

@ -35,8 +35,9 @@ type Config struct {
// Deployer manages Kubernetes deployments for projects. // Deployer manages Kubernetes deployments for projects.
type Deployer struct { type Deployer struct {
client *kubernetes.Clientset client *kubernetes.Clientset
config Config ingressClient IngressClient
config Config
} }
// NewDeployer creates a new Deployer. // NewDeployer creates a new Deployer.
@ -51,8 +52,27 @@ func NewDeployer(client *kubernetes.Clientset, cfg Config) *Deployer {
cfg.Namespace = "projects" cfg.Namespace = "projects"
} }
return &Deployer{ return &Deployer{
client: client, client: client,
config: cfg, 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) return fmt.Errorf("failed to create service: %w", err)
} }
// Create or update Ingress // Create or update Ingress for single-app projects only.
if err := d.createOrUpdateIngress(ctx, spec); err != nil { // Monorepo components (with ComponentPath set) use unified project-level Ingress
return fmt.Errorf("failed to create ingress: %w", err) // 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 return nil

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
appsv1 "k8s.io/api/apps/v1" appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
@ -16,6 +17,37 @@ import (
"github.com/orchard9/rdev/internal/domain" "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<<attempt)):
}
}
return fmt.Errorf("failed after %d retries: %w", maxIngressRetries, lastErr)
}
// sanitizeLabelValue converts a component path to a valid K8s label value. // sanitizeLabelValue converts a component path to a valid K8s label value.
// K8s labels must be alphanumeric with '-', '_', or '.' and must start/end with alphanumeric. // K8s labels must be alphanumeric with '-', '_', or '.' and must start/end with alphanumeric.
// Example: "services/api" -> "services-api" // Example: "services/api" -> "services-api"
@ -249,6 +281,12 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw
labels["component"] = sanitizeLabelValue(spec.ComponentPath) labels["component"] = sanitizeLabelValue(spec.ComponentPath)
} }
// Use BasePath if specified, otherwise default to "/"
path := "/"
if spec.BasePath != "" {
path = spec.BasePath
}
return &networkingv1.Ingress{ return &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{ ObjectMeta: metav1.ObjectMeta{
Name: deploymentName, Name: deploymentName,
@ -271,7 +309,7 @@ func (d *Deployer) buildIngress(spec domain.DeploySpec, ns string, pathType netw
HTTP: &networkingv1.HTTPIngressRuleValue{ HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{ Paths: []networkingv1.HTTPIngressPath{
{ {
Path: "/", Path: path,
PathType: &pathType, PathType: &pathType,
Backend: networkingv1.IngressBackend{ Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{ Service: &networkingv1.IngressServiceBackend{

View File

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

View File

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

View File

@ -1,6 +1,11 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { 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: { theme: {
extend: {}, extend: {},
}, },

View File

@ -1,6 +1,12 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { 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: { theme: {
extend: {}, extend: {},
}, },

View File

@ -9,6 +9,10 @@ import (
) )
// RegisterRoutes registers all HTTP routes for the service. // 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) { func RegisterRoutes(application *app.App) {
logger := application.Logger() logger := application.Logger()
cfg := config.Load() cfg := config.Load()
@ -21,8 +25,9 @@ func RegisterRoutes(application *app.App) {
spec := NewServiceSpec() spec := NewServiceSpec()
application.EnableDocs(spec) application.EnableDocs(spec)
// Register API routes // Register API routes under /api/{service-name} to match ingress path routing.
application.Route("/api/v1", func(r app.Router) { // The ingress routes /api/{{COMPONENT_NAME}}/* to this service.
application.Route("/api/{{COMPONENT_NAME}}", func(r app.Router) {
r.Get("/health", healthHandler.Check) r.Get("/health", healthHandler.Check)
// Public routes (no auth required) // Public routes (no auth required)

View File

@ -30,7 +30,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
})) }))
// Health // Health
spec.AddPath("/api/v1/health", "get", map[string]any{ spec.AddPath("/api/{{COMPONENT_NAME}}/health", "get", map[string]any{
"summary": "Health check", "summary": "Health check",
"tags": []string{"Health"}, "tags": []string{"Health"},
"responses": map[string]any{ "responses": map[string]any{
@ -42,7 +42,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}) })
// List examples // List examples
spec.AddPath("/api/v1/examples", "get", map[string]any{ spec.AddPath("/api/{{COMPONENT_NAME}}/examples", "get", map[string]any{
"summary": "List examples", "summary": "List examples",
"description": "Returns a paginated list of examples.", "description": "Returns a paginated list of examples.",
"tags": []string{"Examples"}, "tags": []string{"Examples"},
@ -53,7 +53,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}) })
// Get example // 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", "summary": "Get example by ID",
"tags": []string{"Examples"}, "tags": []string{"Examples"},
"parameters": []any{openapi.IDParam()}, "parameters": []any{openapi.IDParam()},
@ -64,7 +64,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}) })
// Create example // Create example
spec.AddPath("/api/v1/examples", "post", map[string]any{ spec.AddPath("/api/{{COMPONENT_NAME}}/examples", "post", map[string]any{
"summary": "Create example", "summary": "Create example",
"description": "Creates a new example. Requires authentication.", "description": "Creates a new example. Requires authentication.",
"tags": []string{"Examples"}, "tags": []string{"Examples"},
@ -79,7 +79,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}) })
// Update example // 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", "summary": "Update example",
"description": "Updates an existing example. Requires authentication.", "description": "Updates an existing example. Requires authentication.",
"tags": []string{"Examples"}, "tags": []string{"Examples"},
@ -95,7 +95,7 @@ func NewServiceSpec() *openapi.OpenAPISpec {
}) })
// Delete example // 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", "summary": "Delete example",
"description": "Deletes an example by ID. Requires authentication.", "description": "Deletes an example by ID. Requires authentication.",
"tags": []string{"Examples"}, "tags": []string{"Examples"},

View File

@ -86,6 +86,11 @@ func (c ComponentType) IsGoComponent() bool {
return c == ComponentTypeService || c == ComponentTypeWorker || c == ComponentTypeCLI 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). // componentNameRegex validates component names (slug format: lowercase, alphanumeric, dashes).
var componentNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) var componentNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)

View File

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

View File

@ -13,6 +13,7 @@ type DeploySpec struct {
Replicas int // Number of replicas Replicas int // Number of replicas
EnvVars map[string]string // Plain environment variables EnvVars map[string]string // Plain environment variables
Secrets map[string]string // Secret environment variables (stored in K8s Secret) 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. // DeploymentName returns the K8s resource name for this deployment.

View File

@ -287,3 +287,11 @@ func (m *mockDeployer) GetComponentLogs(_ context.Context, _, _ string, _ int) (
} }
return m.logs, nil 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
}

View File

@ -60,4 +60,14 @@ type Deployer interface {
// RemoveIngressHost removes a host from an existing project's ingress. // RemoveIngressHost removes a host from an existing project's ingress.
// This is used when removing domain aliases from a project. // This is used when removing domain aliases from a project.
RemoveIngressHost(ctx context.Context, projectName, host string) error 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
} }

View File

@ -203,71 +203,6 @@ func (s *ComponentService) AddComponent(ctx context.Context, projectID string, r
return component, nil 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. // prepareMonorepoUpdates reads existing monorepo files and prepares updates.
func (s *ComponentService) prepareMonorepoUpdates( func (s *ComponentService) prepareMonorepoUpdates(
ctx context.Context, ctx context.Context,

View File

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

View File

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

View File

@ -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 // 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 // the CI pipeline runs `kubectl set image`. The deployment will be in ImagePullBackOff
// until the first CI build completes and pushes the image. // 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) { 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 { if s.deployer == nil {
result.NextSteps = append(result.NextSteps, "Deployer not configured - run POST /projects/{id}/deploy after first build") result.NextSteps = append(result.NextSteps, "Deployer not configured - run POST /projects/{id}/deploy after first build")
return return