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:
parent
619a57c240
commit
1790afd0ee
@ -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) |
|
||||
|
||||
@ -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:
|
||||
|
||||
96
cookbooks/trees/evolving-app.yaml
Normal file
96
cookbooks/trees/evolving-app.yaml
Normal 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 }}"
|
||||
@ -36,6 +36,7 @@ type Config struct {
|
||||
// Deployer manages Kubernetes deployments for projects.
|
||||
type Deployer struct {
|
||||
client *kubernetes.Clientset
|
||||
ingressClient IngressClient
|
||||
config Config
|
||||
}
|
||||
|
||||
@ -52,6 +53,25 @@ func NewDeployer(client *kubernetes.Clientset, cfg Config) *Deployer {
|
||||
}
|
||||
return &Deployer{
|
||||
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,10 +119,14 @@ func (d *Deployer) Deploy(ctx context.Context, spec domain.DeploySpec) error {
|
||||
return fmt.Errorf("failed to create service: %w", err)
|
||||
}
|
||||
|
||||
// Create or update Ingress
|
||||
// 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
|
||||
}
|
||||
|
||||
42
internal/adapter/deployer/k8s_client.go
Normal file
42
internal/adapter/deployer/k8s_client.go
Normal 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{})
|
||||
}
|
||||
175
internal/adapter/deployer/k8s_client_mock_test.go
Normal file
175
internal/adapter/deployer/k8s_client_mock_test.go
Normal 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
|
||||
}
|
||||
@ -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<<attempt)):
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed after %d retries: %w", maxIngressRetries, lastErr)
|
||||
}
|
||||
|
||||
// sanitizeLabelValue converts a component path to a valid K8s label value.
|
||||
// K8s labels must be alphanumeric with '-', '_', or '.' and must start/end with alphanumeric.
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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{
|
||||
|
||||
261
internal/adapter/deployer/resources_ingress.go
Normal file
261
internal/adapter/deployer/resources_ingress.go
Normal 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)
|
||||
})
|
||||
}
|
||||
640
internal/adapter/deployer/resources_test.go
Normal file
640
internal/adapter/deployer/resources_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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: {},
|
||||
},
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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-]*$`)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
109
internal/service/component_deploy.go
Normal file
109
internal/service/component_deploy.go
Normal 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
|
||||
}
|
||||
82
internal/service/component_test.go
Normal file
82
internal/service/component_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user