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) |
|
| **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) |
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
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.
|
// Deployer manages Kubernetes deployments for projects.
|
||||||
type Deployer struct {
|
type Deployer struct {
|
||||||
client *kubernetes.Clientset
|
client *kubernetes.Clientset
|
||||||
|
ingressClient IngressClient
|
||||||
config Config
|
config Config
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +53,25 @@ func NewDeployer(client *kubernetes.Clientset, cfg Config) *Deployer {
|
|||||||
}
|
}
|
||||||
return &Deployer{
|
return &Deployer{
|
||||||
client: client,
|
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,
|
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)
|
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 {
|
if err := d.createOrUpdateIngress(ctx, spec); err != nil {
|
||||||
return fmt.Errorf("failed to create ingress: %w", err)
|
return fmt.Errorf("failed to create ingress: %w", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
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"
|
"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{
|
||||||
|
|||||||
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} */
|
/** @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: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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"},
|
||||||
|
|||||||
@ -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-]*$`)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
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.
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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
|
// 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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user