feat: Add multi-domain support with auto-generated slugs for landing page cookbook

Landing page cookbook implementation (Weeks 1-4):

Domain Infrastructure:
- Add project_domains table with migration (013_project_domains.sql)
- Add ProjectDomain model with domain types (primary_auto, primary_custom, alias)
- Add SlugGenerator and ProjectDomainRepository interfaces
- Implement postgres adapters for domain and slug management

Service Layer:
- Add domain CRUD methods to ProjectInfraService
- Generate 8-char random slugs for auto-domains
- Support custom subdomains during project creation
- Add site_live health check to project status
- Trigger CI build after template seeding

Handler Updates:
- Add DomainService interface and adapter pattern
- Rewrite domain handlers to use database-backed service
- Add proper error handling for duplicate/missing domains

CI Integration:
- Add TriggerBuild to CIProvider interface
- Implement TriggerBuild in Woodpecker adapter
- Manually trigger initial build after template seed

Cookbook & Scripts:
- Add landing-test.sh script for E2E testing
- Add release.sh for version releases
- Add logs.sh for quick log access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-28 12:55:59 -07:00
parent 89b832ce0d
commit c86516c53a
28 changed files with 2533 additions and 661 deletions

View File

@ -15,6 +15,7 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
| **Add a new handler/endpoint** | [backend/adding-handlers.md](.claude/guides/backend/adding-handlers.md) | | **Add a new handler/endpoint** | [backend/adding-handlers.md](.claude/guides/backend/adding-handlers.md) |
| **Understand hexagonal architecture** | [backend/hexagonal.md](.claude/guides/backend/hexagonal.md) | | **Understand hexagonal architecture** | [backend/hexagonal.md](.claude/guides/backend/hexagonal.md) |
| **Deploy to k3s** | [ops/deploying.md](.claude/guides/ops/deploying.md) | | **Deploy to k3s** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
| **Release a new version** | [ops/releasing.md](.claude/guides/ops/releasing.md) |
| **Work with Kubernetes adapters** | [services/kubernetes.md](.claude/guides/services/kubernetes.md) | | **Work with Kubernetes adapters** | [services/kubernetes.md](.claude/guides/services/kubernetes.md) |
| **Database / migrations** | [ops/database.md](.claude/guides/ops/database.md) | | **Database / migrations** | [ops/database.md](.claude/guides/ops/database.md) |
| **Manage credentials** | [ops/credentials.md](.claude/guides/ops/credentials.md) | | **Manage credentials** | [ops/credentials.md](.claude/guides/ops/credentials.md) |
@ -35,8 +36,10 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
## Quick Reference ## Quick Reference
```bash ```bash
# Set kubeconfig (REQUIRED) # Required env vars (add to ~/.zshrc)
export KUBECONFIG=~/.kube/orchard9-k3sf.yaml export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai"
export RDEV_API_KEY="<from rdev-credentials secret>"
# Run locally # Run locally
go run ./cmd/rdev-api go run ./cmd/rdev-api
@ -44,18 +47,33 @@ go run ./cmd/rdev-api
# Run tests # Run tests
go test ./... go test ./...
# Deploy # Release new version
kubectl apply -k deployments/k8s/base ./scripts/release.sh v0.8.1 "Description of changes"
# Deploy (after release)
kubectl apply -f deployments/k8s/base/rdev-api.yaml
kubectl rollout restart -n rdev deployment/rdev-api
# Verify pods # Verify pods
kubectl get pods -n rdev kubectl get pods -n rdev
# Check workers # View logs
kubectl get pods -n rdev -l rdev.orchard9.ai/role=worker ./scripts/logs.sh # Last 100 lines
./scripts/logs.sh -f # Follow/stream
./scripts/logs.sh -n 500 # Last 500 lines
./scripts/logs.sh -e # Errors only
./scripts/logs.sh -p # Previous crashed container
# Load credentials from .secrets to rdev-api # Shell aliases (after source ~/.zshrc)
./scripts/load-credentials.sh # localhost rdev-logs # Last 100 lines
./scripts/load-credentials.sh https://rdev.example.com # remote rdev-logs-f # Follow/stream
rdev-pods # List pods
# API calls (NOTE: $RDEV_API_KEY doesn't expand in curl -H, use the test script instead)
# ./cookbooks/scripts/landing-test.sh run|status|teardown <name>
curl -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/health
curl -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/projects
curl -H "X-API-Key: $RDEV_API_KEY" $RDEV_API_URL/work/stats
``` ```
## Architecture Overview ## Architecture Overview
@ -81,7 +99,12 @@ pkg/api/ # HTTP framework (app, responses)
deployments/k8s/ # Kustomize manifests deployments/k8s/ # Kustomize manifests
└── base/templates/ # Project templates └── base/templates/ # Project templates
scripts/ # Operational scripts scripts/ # Operational scripts
└── load-credentials.sh # Load secrets to rdev-api ├── load-credentials.sh # Load secrets to rdev-api
├── release.sh # Build, tag, push releases
└── logs.sh # View rdev-api logs
cookbooks/ # End-to-end workflow guides
├── landing-page.md # Landing page deployment flow
└── scripts/ # Executable cookbook scripts
``` ```
## Key Concepts ## Key Concepts
@ -95,17 +118,16 @@ scripts/ # Operational scripts
- **Webhooks**: Event subscriptions with retry delivery - **Webhooks**: Event subscriptions with retry delivery
- **Templates**: Project scaffolding with .woodpecker.yml, .claude/, and stack files - **Templates**: Project scaffolding with .woodpecker.yml, .claude/, and stack files
## threesix.ai Platform Roadmap ## threesix.ai Platform Status
See `k3s-fleet/tmp/address-the-gaps.md` for full implementation details: | Feature | Status | Description |
|---------|--------|-------------|
| Gap | Status | Description | | Woodpecker Auto-Activation | **Done** | CI enabled on project creation via SDK |
|-----|--------|-------------| | Project Templates | **Done** | Embedded templates (astro-landing, go-api, default) |
| Woodpecker Auto-Activation | Planned | Auto-enable CI on project creation | | Work Queue | **Done** | PostgreSQL with atomic dequeue, retry logic |
| Project Templates | Planned | Seed repos with .woodpecker.yml, .claude/ | | Multi-Provider Agents | **Done** | Claude Code + OpenCode via registry |
| Work Queue | Planned | Task queue for worker pool | | Webhooks | **Done** | Event dispatcher with retry delivery |
| Worker Pool | Planned | Shared claudebox workers (3-5 pods) | | Embedded Worker | **Done** | Goroutine in rdev-api, polls queue |
| Bot Communication | Planned | Webhook callbacks on task completion |
| Build Orchestration | Planned | Structured build specs via API | | Build Orchestration | Planned | Structured build specs via API |
## Constraints ## Constraints

View File

@ -62,6 +62,9 @@ import (
"github.com/orchard9/rdev/pkg/api" "github.com/orchard9/rdev/pkg/api"
) )
// version is set via ldflags at build time: -ldflags="-X main.version=v0.8.0"
var version = "dev"
func main() { func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo, Level: slog.LevelInfo,
@ -295,18 +298,9 @@ func main() {
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo) webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
workHandler := handlers.NewWorkHandler(workService) workHandler := handlers.NewWorkHandler(workService)
// Initialize infrastructure handler (for threesix.ai git/deploy/dns/ci) // Initialize domain and slug repositories
infraHandler := handlers.NewInfrastructureHandler( projectDomainRepo := postgres.NewProjectDomainRepository(database.DB)
giteaClient, slugGenerator := postgres.NewSlugRepository(database.DB)
dnsClient,
deployerAdapter,
projectRepo,
woodpeckerClient,
handlers.InfrastructureConfig{
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
DefaultDomain: infraCfg.DefaultDomain,
},
)
// Initialize project infrastructure service (orchestrates full project lifecycle) // Initialize project infrastructure service (orchestrates full project lifecycle)
projectInfraService := service.NewProjectInfraService( projectInfraService := service.NewProjectInfraService(
@ -316,6 +310,8 @@ func main() {
deployerAdapter, deployerAdapter,
woodpeckerClient, // CI provider for auto-activating repos woodpeckerClient, // CI provider for auto-activating repos
templateProvider, // Template provider for seeding repos templateProvider, // Template provider for seeding repos
projectDomainRepo,
slugGenerator,
service.ProjectInfraConfig{ service.ProjectInfraConfig{
DefaultGitOwner: infraCfg.GiteaDefaultOrg, DefaultGitOwner: infraCfg.GiteaDefaultOrg,
DefaultDomain: infraCfg.DefaultDomain, DefaultDomain: infraCfg.DefaultDomain,
@ -324,6 +320,24 @@ func main() {
}, },
) )
// Create domain service adapter for infrastructure handler
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)
// Initialize infrastructure handler (for threesix.ai git/deploy/dns/ci)
infraHandler := handlers.NewInfrastructureHandler(
giteaClient,
dnsClient,
deployerAdapter,
projectRepo,
woodpeckerClient,
domainServiceAdapter,
handlers.InfrastructureConfig{
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
DefaultDomain: infraCfg.DefaultDomain,
ClusterIP: infraCfg.ClusterIP,
},
)
// Initialize project management handler // Initialize project management handler
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger) projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger)
@ -400,15 +414,13 @@ func main() {
}) })
} }
buildExecutor := worker.NewBuildExecutor(agentRegistry, gitOps, logger) buildExecutor := worker.NewBuildExecutor(agentRegistry, gitOps, logger)
workerCfg := worker.DefaultWorkExecutorConfig()
workerCfg.Logger = logger
workExecutor := worker.NewWorkExecutor( workExecutor := worker.NewWorkExecutor(
workerService, workerService,
workService, workService,
buildExecutor, buildExecutor,
&worker.WorkExecutorConfig{ workerCfg,
PollPeriod: 5 * time.Second,
HeartbeatPeriod: 30 * time.Second,
Logger: logger,
},
) )
if err := workExecutor.Start(); err != nil { if err := workExecutor.Start(); err != nil {
logger.Error("failed to start work executor", "error", err) logger.Error("failed to start work executor", "error", err)
@ -463,6 +475,7 @@ func main() {
}) })
logger.Info("rdev-api starting", logger.Info("rdev-api starting",
"version", version,
"port", cfg.Port, "port", cfg.Port,
"db_host", cfg.DBHost, "db_host", cfg.DBHost,
"admin_key_set", cfg.AdminKey != "", "admin_key_set", cfg.AdminKey != "",

333
cookbooks/scripts/landing-test.sh Executable file
View File

@ -0,0 +1,333 @@
#!/bin/bash
# Landing Page Cookbook Test Script
# Tests the full landing page flow from cookbooks/landing-page.md
#
# Usage:
# ./cookbooks/scripts/landing-test.sh run # Run the full flow
# ./cookbooks/scripts/landing-test.sh teardown # Clean up test resources
# ./cookbooks/scripts/landing-test.sh status # Check current status
set -euo pipefail
# Configuration
API_URL="${RDEV_API_URL:-https://rdev.masq-ops.orchard9.ai}"
API_KEY="${RDEV_API_KEY:?RDEV_API_KEY environment variable required}"
PROJECT_NAME="${1:-landing-test}"
TEMPLATE="astro-landing"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
api_call() {
local method="$1"
local endpoint="$2"
local data="${3:-}"
if [[ -n "$data" ]]; then
curl -s -X "$method" "${API_URL}${endpoint}" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "$data"
else
curl -s -X "$method" "${API_URL}${endpoint}" \
-H "X-API-Key: ${API_KEY}"
fi
}
check_health() {
log_info "Checking API health..."
local response
response=$(curl -s "${API_URL}/health")
if echo "$response" | jq -e '.data.status == "ok"' > /dev/null 2>&1; then
log_success "API is healthy"
return 0
else
log_error "API health check failed"
echo "$response" | jq .
return 1
fi
}
run_flow() {
local project_name="${1:-landing-test}"
local custom_subdomain="${2:-}"
echo ""
echo "=========================================="
echo " Landing Page Cookbook Test"
echo " Project: $project_name"
if [[ -n "$custom_subdomain" ]]; then
echo " Custom subdomain: $custom_subdomain"
fi
echo "=========================================="
echo ""
# Step 0: Health check
check_health || exit 1
echo ""
# Step 1: Create project
log_info "Step 1: Creating project with $TEMPLATE template..."
local create_payload="{
\"name\": \"$project_name\",
\"description\": \"Cookbook test: landing page flow\",
\"template\": \"$TEMPLATE\""
if [[ -n "$custom_subdomain" ]]; then
create_payload="$create_payload,
\"custom_subdomain\": \"$custom_subdomain\""
fi
create_payload="$create_payload
}"
local create_response
create_response=$(api_call POST "/project" "$create_payload")
if echo "$create_response" | jq -e '.error' > /dev/null 2>&1; then
log_error "Failed to create project"
echo "$create_response" | jq .
exit 1
fi
log_success "Project created"
echo "$create_response" | jq '{
project_id: .data.project_id,
slug: .data.slug,
git_url: .data.git.html_url,
domain: .data.domain,
url: .data.url,
domains: .data.domains,
next_steps: .data.next_steps
}'
# Extract domain info
local primary_domain
local slug
primary_domain=$(echo "$create_response" | jq -r '.data.domain')
slug=$(echo "$create_response" | jq -r '.data.slug // empty')
if [[ -n "$slug" ]]; then
log_success "Auto-generated slug: $slug"
fi
# Check for manual steps
local next_steps
next_steps=$(echo "$create_response" | jq -r '.data.next_steps[]?' 2>/dev/null || echo "")
if [[ -n "$next_steps" ]]; then
echo ""
log_warn "Some steps require manual intervention:"
echo "$create_response" | jq -r '.data.next_steps[]'
echo ""
log_info "Check API logs for details: ./scripts/logs.sh -e"
fi
echo ""
# Step 2: List all domains
log_info "Step 2: Listing all project domains..."
local domains_response
domains_response=$(api_call GET "/projects/$project_name/domains")
if echo "$domains_response" | jq -e '.error' > /dev/null 2>&1; then
log_warn "Could not list domains"
echo "$domains_response" | jq .
else
local domain_count
domain_count=$(echo "$domains_response" | jq -r '.data.total')
log_success "Found $domain_count domain(s)"
echo "$domains_response" | jq '.data.domains[] | {domain, type, verified}'
fi
echo ""
# Step 3: Verify project status
log_info "Step 3: Verifying project status..."
sleep 2 # Give DNS/Gitea a moment
local status_response
status_response=$(api_call GET "/project/$project_name")
if echo "$status_response" | jq -e '.error' > /dev/null 2>&1; then
log_warn "Could not fetch project status"
echo "$status_response" | jq .
else
log_success "Project status retrieved"
echo "$status_response" | jq '{
name: .data.name,
slug: .data.slug,
domain: .data.domain,
url: .data.url,
domains: .data.domains,
git: .data.git.html_url,
deployment: .data.deployment
}'
fi
echo ""
# Step 4: Check DNS
log_info "Step 4: Checking DNS resolution..."
if host "$primary_domain" > /dev/null 2>&1; then
log_success "DNS resolves: $primary_domain"
host "$primary_domain" | head -1
else
log_warn "DNS not yet resolving: $primary_domain (may take a few minutes)"
fi
echo ""
# Step 5: Summary
echo "=========================================="
echo " Summary"
echo "=========================================="
echo ""
echo " Git repo: $(echo "$create_response" | jq -r '.data.git.html_url')"
if [[ -n "$slug" ]]; then
echo " Slug: $slug"
fi
echo " Primary: https://$primary_domain"
echo ""
echo " All domains:"
echo "$domains_response" | jq -r '.data.domains[]? | " - \(.domain) (\(.type))"' 2>/dev/null || echo " (none listed)"
echo ""
echo " Next steps:"
echo " 1. If CI/template failed, check logs: ./scripts/logs.sh -e"
echo " 2. Push code to trigger CI build"
echo " 3. Monitor pipeline: curl -s -H 'X-API-Key: \$RDEV_API_KEY' $API_URL/projects/$project_name/pipelines | jq"
echo " 4. Verify site: curl -I https://$primary_domain"
echo ""
echo " Add custom domain:"
echo " curl -X POST -H 'X-API-Key: \$RDEV_API_KEY' -H 'Content-Type: application/json' \\"
echo " -d '{\"domain\":\"my-site.threesix.ai\"}' \\"
echo " $API_URL/projects/$project_name/domains"
echo ""
echo " Teardown:"
echo " ./cookbooks/scripts/landing-test.sh teardown $project_name"
echo ""
}
teardown() {
local project_name="${1:-landing-test}"
echo ""
echo "=========================================="
echo " Teardown: $project_name"
echo "=========================================="
echo ""
# First, list domains that will be deleted
log_info "Checking domains to be deleted..."
local domains_response
domains_response=$(api_call GET "/projects/$project_name/domains")
if echo "$domains_response" | jq -e '.data.total' > /dev/null 2>&1; then
local domain_count
domain_count=$(echo "$domains_response" | jq -r '.data.total')
log_info "Will delete $domain_count domain(s):"
echo "$domains_response" | jq -r '.data.domains[]? | " - \(.domain)"'
echo ""
fi
log_info "Deleting project $project_name..."
local response
response=$(api_call DELETE "/project/$project_name")
if echo "$response" | jq -e '.data.status == "deleted"' > /dev/null 2>&1; then
log_success "Project deleted"
echo "$response" | jq .
elif echo "$response" | jq -e '.error.code == "NOT_FOUND"' > /dev/null 2>&1; then
log_warn "Project not found (already deleted?)"
else
log_error "Failed to delete project"
echo "$response" | jq .
exit 1
fi
echo ""
log_info "Note: Gitea repo is preserved for safety. Delete manually if needed:"
echo " https://git.threesix.ai/jordan/$project_name/settings"
echo ""
}
status() {
local project_name="${1:-landing-test}"
echo ""
log_info "Fetching status for: $project_name"
echo ""
local response
response=$(api_call GET "/project/$project_name")
if echo "$response" | jq -e '.error' > /dev/null 2>&1; then
log_error "Project not found or error"
echo "$response" | jq .
exit 1
fi
echo "$response" | jq '{
name: .data.name,
description: .data.description,
slug: .data.slug,
domain: .data.domain,
url: .data.url,
domains: .data.domains,
git: .data.git,
deployment: .data.deployment
}'
echo ""
log_info "Listing all domains..."
local domains_response
domains_response=$(api_call GET "/projects/$project_name/domains")
if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then
echo "$domains_response" | jq '.data.domains'
fi
}
# Main
case "${1:-}" in
run)
shift
run_flow "${1:-landing-test}" "${2:-}"
;;
teardown)
shift
teardown "${1:-landing-test}"
;;
status)
shift
status "${1:-landing-test}"
;;
*)
echo "Usage: $0 {run|teardown|status} [project-name] [custom-subdomain]"
echo ""
echo "Commands:"
echo " run [name] [subdomain] Create project and run full flow"
echo " teardown [name] Delete project and clean up"
echo " status [name] Check current project status"
echo ""
echo "Features tested:"
echo " - Auto-generated random slug (8-char) for primary domain"
echo " - Optional custom subdomain (e.g., 'my-app' -> my-app.threesix.ai)"
echo " - Multi-domain listing via /projects/{id}/domains"
echo " - DNS record creation and cleanup"
echo ""
echo "Environment:"
echo " RDEV_API_URL API endpoint (default: https://rdev.masq-ops.orchard9.ai)"
echo " RDEV_API_KEY API key (required)"
echo ""
echo "Examples:"
echo " $0 run # Test with auto-slug only"
echo " $0 run my-landing # Test with custom project name"
echo " $0 run my-landing my-site # Also create my-site.threesix.ai"
echo " $0 status my-landing # Check status and domains"
echo " $0 teardown my-landing # Clean up (deletes all domains)"
exit 1
;;
esac

26
go.mod
View File

@ -14,7 +14,7 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0 go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0 go.opentelemetry.io/otel/trace v1.39.0
go.woodpecker-ci.org/woodpecker/v2 v2.8.3 go.woodpecker-ci.org/woodpecker/v3 v3.13.0
k8s.io/api v0.35.0 k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0 k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0 k8s.io/client-go v0.35.0
@ -25,7 +25,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
@ -33,7 +33,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.4 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
@ -44,7 +44,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect
@ -56,17 +56,17 @@ require (
go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.44.0 // indirect golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.47.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.37.0 // indirect golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.9.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

63
go.sum
View File

@ -13,8 +13,9 @@ github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F9
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
@ -32,8 +33,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@ -77,12 +78,14 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@ -123,8 +126,8 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.woodpecker-ci.org/woodpecker/v2 v2.8.3 h1:g54xYwrL4RhCTTyKtjYPDB9ePnUsqRx6qkqlnAcFdJg= go.woodpecker-ci.org/woodpecker/v3 v3.13.0 h1:XSokm3nwTbUJTUgf7uQ1zh/BYj3G6n6cTFprQUbpw8M=
go.woodpecker-ci.org/woodpecker/v2 v2.8.3/go.mod h1:nvdmUnQJMqm8UzJOlJ50MYYq/uv8oyOqhBBr7SdoNPw= go.woodpecker-ci.org/woodpecker/v3 v3.13.0/go.mod h1:cmsKk4jzDRFUZtiBipYi/fSHvePz/RT05t1LsDXNRDw=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@ -132,45 +135,45 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@ -53,7 +53,7 @@ func (c *Client) CreateRepo(ctx context.Context, name, description string, priva
Name: name, Name: name,
Description: description, Description: description,
Private: private, Private: private,
AutoInit: true, AutoInit: false, // Empty repo - template seeding will create all files
DefaultBranch: "main", DefaultBranch: "main",
} }

View File

@ -0,0 +1,315 @@
// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// ProjectDomainRepository implements port.ProjectDomainRepository using PostgreSQL.
type ProjectDomainRepository struct {
db *sql.DB
}
// NewProjectDomainRepository creates a new PostgreSQL project domain repository.
func NewProjectDomainRepository(db *sql.DB) *ProjectDomainRepository {
return &ProjectDomainRepository{db: db}
}
// Ensure ProjectDomainRepository implements port.ProjectDomainRepository at compile time.
var _ port.ProjectDomainRepository = (*ProjectDomainRepository)(nil)
// Create adds a new domain association for a project.
func (r *ProjectDomainRepository) Create(ctx context.Context, pd *domain.ProjectDomain) error {
now := time.Now()
err := r.db.QueryRowContext(ctx, `
INSERT INTO project_domains (project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
RETURNING id
`, pd.ProjectID, strings.ToLower(pd.Domain), pd.Type, nullString(pd.DNSRecordID),
nullString(pd.DNSRecordType), pd.Verified, now).Scan(&pd.ID)
if err != nil {
if strings.Contains(err.Error(), "unique_domain") || strings.Contains(err.Error(), "duplicate key") {
return fmt.Errorf("domain %q already exists: %w", pd.Domain, domain.ErrDuplicateDomain)
}
return fmt.Errorf("create project domain: %w", err)
}
pd.CreatedAt = now
pd.UpdatedAt = now
return nil
}
// GetByID retrieves a domain by its ID.
func (r *ProjectDomainRepository) GetByID(ctx context.Context, id int64) (*domain.ProjectDomain, error) {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE id = $1
`, id).Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get project domain by id: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
return pd, nil
}
// GetByDomain retrieves a domain by its FQDN.
func (r *ProjectDomainRepository) GetByDomain(ctx context.Context, fqdn string) (*domain.ProjectDomain, error) {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE LOWER(domain) = LOWER($1)
`, fqdn).Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get project domain by fqdn: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
return pd, nil
}
// ListByProject returns all domains for a project.
func (r *ProjectDomainRepository) ListByProject(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE project_id = $1
ORDER BY
CASE type
WHEN 'primary_auto' THEN 1
WHEN 'primary_custom' THEN 2
ELSE 3
END,
created_at
`, projectID)
if err != nil {
return nil, fmt.Errorf("list project domains: %w", err)
}
defer func() { _ = rows.Close() }()
var domains []*domain.ProjectDomain
for rows.Next() {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
if err := rows.Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan project domain: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
domains = append(domains, pd)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate project domains: %w", err)
}
return domains, nil
}
// GetPrimary returns the primary domain for a project.
// Prefers primary_custom over primary_auto if both exist.
func (r *ProjectDomainRepository) GetPrimary(ctx context.Context, projectID string) (*domain.ProjectDomain, error) {
pd := &domain.ProjectDomain{}
var dnsRecordID, dnsRecordType sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, domain, type, dns_record_id, dns_record_type, verified, created_at, updated_at
FROM project_domains
WHERE project_id = $1 AND type IN ('primary_auto', 'primary_custom')
ORDER BY CASE type WHEN 'primary_custom' THEN 1 ELSE 2 END
LIMIT 1
`, projectID).Scan(&pd.ID, &pd.ProjectID, &pd.Domain, &pd.Type, &dnsRecordID, &dnsRecordType,
&pd.Verified, &pd.CreatedAt, &pd.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get primary domain: %w", err)
}
pd.DNSRecordID = dnsRecordID.String
pd.DNSRecordType = dnsRecordType.String
return pd, nil
}
// Update modifies an existing domain record.
func (r *ProjectDomainRepository) Update(ctx context.Context, pd *domain.ProjectDomain) error {
result, err := r.db.ExecContext(ctx, `
UPDATE project_domains
SET domain = $1, type = $2, dns_record_id = $3, dns_record_type = $4, verified = $5, updated_at = NOW()
WHERE id = $6
`, strings.ToLower(pd.Domain), pd.Type, nullString(pd.DNSRecordID),
nullString(pd.DNSRecordType), pd.Verified, pd.ID)
if err != nil {
if strings.Contains(err.Error(), "unique_domain") || strings.Contains(err.Error(), "duplicate key") {
return fmt.Errorf("domain %q already exists: %w", pd.Domain, domain.ErrDuplicateDomain)
}
return fmt.Errorf("update project domain: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("domain not found")
}
return nil
}
// Delete removes a domain by ID.
func (r *ProjectDomainRepository) Delete(ctx context.Context, id int64) error {
result, err := r.db.ExecContext(ctx, `
DELETE FROM project_domains WHERE id = $1
`, id)
if err != nil {
return fmt.Errorf("delete project domain: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("domain not found")
}
return nil
}
// DeleteByDomain removes a domain by FQDN.
func (r *ProjectDomainRepository) DeleteByDomain(ctx context.Context, fqdn string) error {
result, err := r.db.ExecContext(ctx, `
DELETE FROM project_domains WHERE LOWER(domain) = LOWER($1)
`, fqdn)
if err != nil {
return fmt.Errorf("delete project domain by fqdn: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rows == 0 {
return fmt.Errorf("domain not found")
}
return nil
}
// DeleteByProject removes all domains for a project.
func (r *ProjectDomainRepository) DeleteByProject(ctx context.Context, projectID string) error {
_, err := r.db.ExecContext(ctx, `
DELETE FROM project_domains WHERE project_id = $1
`, projectID)
if err != nil {
return fmt.Errorf("delete project domains: %w", err)
}
return nil
}
// Exists checks if a domain already exists.
func (r *ProjectDomainRepository) Exists(ctx context.Context, fqdn string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM project_domains WHERE LOWER(domain) = LOWER($1))
`, fqdn).Scan(&exists)
if err != nil {
return false, fmt.Errorf("check domain exists: %w", err)
}
return exists, nil
}
// CountByProject returns the number of domains for a project.
func (r *ProjectDomainRepository) CountByProject(ctx context.Context, projectID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*) FROM project_domains WHERE project_id = $1
`, projectID).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count project domains: %w", err)
}
return count, nil
}
// SlugRepository implements port.SlugGenerator using PostgreSQL.
type SlugRepository struct {
db *sql.DB
maxRetries int
}
// NewSlugRepository creates a new PostgreSQL slug generator.
func NewSlugRepository(db *sql.DB) *SlugRepository {
return &SlugRepository{db: db, maxRetries: 10}
}
// Ensure SlugRepository implements port.SlugGenerator at compile time.
var _ port.SlugGenerator = (*SlugRepository)(nil)
// Generate creates a new unique slug.
func (r *SlugRepository) Generate(ctx context.Context) (string, error) {
for range r.maxRetries {
slug, err := domain.GenerateSlug()
if err != nil {
return "", fmt.Errorf("generate slug: %w", err)
}
unique, err := r.IsUnique(ctx, slug)
if err != nil {
return "", err
}
if unique {
return slug, nil
}
}
return "", fmt.Errorf("failed to generate unique slug after %d attempts", r.maxRetries)
}
// IsUnique checks if a slug is unique (not in use).
func (r *SlugRepository) IsUnique(ctx context.Context, slug string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS(SELECT 1 FROM projects WHERE slug = $1)
`, slug).Scan(&exists)
if err != nil {
return false, fmt.Errorf("check slug unique: %w", err)
}
return !exists, nil
}

View File

@ -114,13 +114,24 @@ func (p *Provider) SeedRepo(ctx context.Context, owner, repo, templateName strin
// Create file in repo via Gitea API // Create file in repo via Gitea API
// Gitea expects base64-encoded content // Gitea expects base64-encoded content
encodedContent := base64.StdEncoding.EncodeToString([]byte(interpolated)) encodedContent := base64.StdEncoding.EncodeToString([]byte(interpolated))
_, _, err = p.giteaClient.CreateFile(owner, repo, relPath, gitea.CreateFileOptions{
// For empty repos (AutoInit: false), the first file must create the branch
// using NewBranchName. Subsequent files use the existing branch.
opts := gitea.CreateFileOptions{
Content: encodedContent, Content: encodedContent,
FileOptions: gitea.FileOptions{ FileOptions: gitea.FileOptions{
Message: "Add " + relPath + " from template", Message: "Add " + relPath + " from template",
BranchName: "main",
}, },
}) }
if filesCreated == 0 {
// First file: create the main branch
opts.NewBranchName = "main"
} else {
// Subsequent files: use existing main branch
opts.BranchName = "main"
}
_, _, err = p.giteaClient.CreateFile(owner, repo, relPath, opts)
if err != nil { if err != nil {
return fmt.Errorf("failed to create file %s: %w", relPath, err) return fmt.Errorf("failed to create file %s: %w", relPath, err)
} }

View File

@ -22,7 +22,7 @@ import (
"strings" "strings"
"time" "time"
"go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker" "go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port" "github.com/orchard9/rdev/internal/port"
@ -117,33 +117,76 @@ func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*
fullName := owner + "/" + repo fullName := owner + "/" + repo
// First, sync the repo list to ensure we have the latest from forge // Retry loop for newly created repos - Woodpecker sync from Gitea is async
// This is important for newly created repos // and can take 30+ seconds for newly created repos to appear with valid metadata
if _, err := c.client.RepoListOpts(true); err != nil {
c.logger.Debug("failed to sync repo list from forge", "error", err)
// Continue anyway - repo might already be synced
}
// Find the repo in Woodpecker's list (may include inactive repos)
repos, err := c.client.RepoList()
if err != nil {
return nil, fmt.Errorf("failed to list repos: %w", err)
}
var targetRepo *woodpecker.Repo var targetRepo *woodpecker.Repo
for _, r := range repos { var lastErr error
if strings.EqualFold(r.FullName, fullName) { maxAttempts := 15
targetRepo = r retryDelay := 3 * time.Second
for attempt := 1; attempt <= maxAttempts; attempt++ {
// Check context before each attempt
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Sync and get ALL repos (including inactive) - new repos start inactive
repos, err := c.client.RepoList(woodpecker.RepoListOptions{All: true})
if err != nil {
lastErr = fmt.Errorf("failed to list repos: %w", err)
c.logger.Debug("failed to list repos", "error", err, "attempt", attempt)
time.Sleep(retryDelay)
continue
}
for _, r := range repos {
if strings.EqualFold(r.FullName, fullName) {
targetRepo = r
break
}
}
if targetRepo == nil {
// Repo not found in list - try direct lookup
targetRepo, err = c.client.RepoLookup(fullName)
if err != nil {
// SDK bug: RepoLookup returns non-nil empty struct on error
targetRepo = nil
lastErr = fmt.Errorf("repo not found in Woodpecker: %s", fullName)
if attempt < maxAttempts {
c.logger.Debug("repo not found, retrying", "repo", fullName, "attempt", attempt, "max", maxAttempts)
time.Sleep(retryDelay)
continue
}
}
}
// Check if repo was found AND has valid ForgeRemoteID (metadata sync complete)
if targetRepo != nil && targetRepo.ForgeRemoteID != "" {
break break
} }
// Repo found but ForgeRemoteID empty - metadata sync incomplete, retry
if targetRepo != nil && targetRepo.ForgeRemoteID == "" {
lastErr = fmt.Errorf("repo %s found but forge metadata not synced yet", fullName)
if attempt < maxAttempts {
c.logger.Debug("repo found but forge_remote_id empty, retrying", "repo", fullName, "attempt", attempt)
targetRepo = nil // Reset for next attempt
time.Sleep(retryDelay)
continue
}
}
} }
if targetRepo == nil { if targetRepo == nil {
// Repo not found - try to look it up directly return nil, fmt.Errorf("%w (tried %d times)", lastErr, maxAttempts)
targetRepo, err = c.client.RepoLookup(fullName) }
if err != nil {
return nil, fmt.Errorf("repo not found in Woodpecker: %s (ensure forge is synced)", fullName) // Final check: ensure ForgeRemoteID is valid (non-empty)
} if targetRepo.ForgeRemoteID == "" {
return nil, fmt.Errorf("repo %s found but forge metadata never synced (tried %d times)", fullName, maxAttempts)
} }
// If already active, just return it // If already active, just return it
@ -158,7 +201,7 @@ func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*
} }
// Activate the repo using the forge remote ID // Activate the repo using the forge remote ID
activatedRepo, err := c.client.RepoPost(forgeID) activatedRepo, err := c.client.RepoPost(woodpecker.RepoPostOptions{ForgeRemoteID: forgeID})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to activate repo: %w", err) return nil, fmt.Errorf("failed to activate repo: %w", err)
} }
@ -219,7 +262,7 @@ func (c *Client) ListRepos(ctx context.Context) ([]*domain.CIRepo, error) {
default: default:
} }
repos, err := c.client.RepoList() repos, err := c.client.RepoList(woodpecker.RepoListOptions{})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list repos: %w", err) return nil, fmt.Errorf("failed to list repos: %w", err)
} }
@ -302,7 +345,7 @@ func (c *Client) ListPipelines(ctx context.Context, owner, repo string) ([]*doma
return nil, fmt.Errorf("repo not found: %s", fullName) return nil, fmt.Errorf("repo not found: %s", fullName)
} }
pipelines, err := c.client.PipelineList(r.ID) pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list pipelines: %w", err) return nil, fmt.Errorf("failed to list pipelines: %w", err)
} }
@ -360,6 +403,33 @@ func pipelineFromWoodpecker(p *woodpecker.Pipeline) *domain.CIPipeline {
} }
} }
// TriggerBuild manually starts a new pipeline build on the specified branch.
func (c *Client) TriggerBuild(ctx context.Context, owner, repo, branch string) (int64, error) {
select {
case <-ctx.Done():
return 0, ctx.Err()
default:
}
fullName := owner + "/" + repo
r, err := c.client.RepoLookup(fullName)
if err != nil {
return 0, fmt.Errorf("repo not found: %s", fullName)
}
// Create a new pipeline for the branch
pipeline, err := c.client.PipelineCreate(r.ID, &woodpecker.PipelineOptions{
Branch: branch,
})
if err != nil {
return 0, fmt.Errorf("failed to trigger build: %w", err)
}
c.logger.Info("build triggered", "repo", fullName, "branch", branch, "pipeline", pipeline.Number)
return pipeline.Number, nil
}
// repoFromWoodpecker converts a woodpecker.Repo to domain.CIRepo. // repoFromWoodpecker converts a woodpecker.Repo to domain.CIRepo.
func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo { func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo {
// Parse forge remote ID (string in SDK, int64 in our domain) // Parse forge remote ID (string in SDK, int64 in our domain)
@ -380,7 +450,7 @@ func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo {
FullName: r.FullName, FullName: r.FullName,
CloneURL: r.Clone, CloneURL: r.Clone,
Active: r.IsActive, Active: r.IsActive,
AllowPullRequests: r.AllowPullRequests, AllowPullRequests: r.AllowPull, // Renamed in SDK v3
Visibility: r.Visibility, // Already a string in SDK Visibility: r.Visibility,
} }
} }

View File

@ -0,0 +1,99 @@
-- Add slug to projects and create project_domains table for flexible multi-domain support.
-- This enables:
-- 1. Auto-generated random subdomains (e.g., k7m2x9p4.threesix.ai)
-- 2. Optional custom subdomains (e.g., my-app.threesix.ai)
-- 3. Multiple alias domains per project (e.g., www.myapp.com, myapp.com)
-- 4. Clean tracking of all DNS records for resource management
-- Add slug column to projects (immutable, auto-generated identifier)
ALTER TABLE projects ADD COLUMN IF NOT EXISTS
slug VARCHAR(16);
-- Backfill existing projects with slugs derived from their IDs
-- Uses first 8 chars of MD5 hash for consistent, reproducible slugs
UPDATE projects
SET slug = LOWER(SUBSTRING(MD5(id), 1, 8))
WHERE slug IS NULL;
-- Make slug NOT NULL and UNIQUE after backfill
ALTER TABLE projects ALTER COLUMN slug SET NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug);
-- Project domains table for flexible multi-domain support
CREATE TABLE IF NOT EXISTS project_domains (
id SERIAL PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
domain VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('primary_auto', 'primary_custom', 'alias')),
dns_record_id VARCHAR(64), -- Cloudflare record ID for cleanup
dns_record_type VARCHAR(10), -- A, CNAME, etc.
verified BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT unique_domain UNIQUE (domain)
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_project_domains_project_id ON project_domains(project_id);
CREATE INDEX IF NOT EXISTS idx_project_domains_type ON project_domains(type);
-- Migrate existing domain data to project_domains table
-- Only insert if the domain column has a value and doesn't already exist in project_domains
INSERT INTO project_domains (project_id, domain, type, dns_record_type, verified, created_at, updated_at)
SELECT
id,
domain,
'primary_auto',
'A',
TRUE,
created_at,
updated_at
FROM projects
WHERE domain IS NOT NULL
AND domain != ''
AND NOT EXISTS (
SELECT 1 FROM project_domains pd WHERE pd.domain = projects.domain
);
-- Migrate custom_domain if present
INSERT INTO project_domains (project_id, domain, type, dns_record_type, verified, created_at, updated_at)
SELECT
id,
custom_domain,
'primary_custom',
'A',
TRUE,
created_at,
updated_at
FROM projects
WHERE custom_domain IS NOT NULL
AND custom_domain != ''
AND NOT EXISTS (
SELECT 1 FROM project_domains pd WHERE pd.domain = projects.custom_domain
);
-- Update trigger for project_domains updated_at
CREATE OR REPLACE FUNCTION update_project_domains_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS project_domains_updated_at ON project_domains;
CREATE TRIGGER project_domains_updated_at
BEFORE UPDATE ON project_domains
FOR EACH ROW
EXECUTE FUNCTION update_project_domains_updated_at();
-- Comments
COMMENT ON TABLE project_domains IS 'Domains associated with projects - supports multiple domains per project';
COMMENT ON COLUMN project_domains.project_id IS 'Reference to parent project';
COMMENT ON COLUMN project_domains.domain IS 'Fully qualified domain name (e.g., k7m2x9p4.threesix.ai or www.myapp.com)';
COMMENT ON COLUMN project_domains.type IS 'Domain type: primary_auto (system-generated), primary_custom (user subdomain), alias (external domain)';
COMMENT ON COLUMN project_domains.dns_record_id IS 'Cloudflare DNS record ID for automated cleanup';
COMMENT ON COLUMN project_domains.dns_record_type IS 'DNS record type: A, CNAME, etc.';
COMMENT ON COLUMN project_domains.verified IS 'Whether domain ownership has been verified (for external domains)';
COMMENT ON COLUMN projects.slug IS 'Immutable 8-char random identifier used for auto-generated subdomain';

View File

@ -62,6 +62,10 @@ var (
ErrWebhookNotFound = errors.New("webhook not found") ErrWebhookNotFound = errors.New("webhook not found")
ErrInvalidWebhook = errors.New("invalid webhook configuration") ErrInvalidWebhook = errors.New("invalid webhook configuration")
// Domain errors
ErrDuplicateDomain = errors.New("domain already exists")
ErrDomainNotFound = errors.New("domain not found")
// Audit errors // Audit errors
ErrAuditNotFound = errors.New("audit log entry not found") ErrAuditNotFound = errors.New("audit log entry not found")

View File

@ -0,0 +1,152 @@
// Package domain contains pure domain models with no external dependencies.
package domain
import (
"crypto/rand"
"fmt"
"regexp"
"strings"
"time"
)
// DomainType represents the type of domain association with a project.
type DomainType string
const (
// DomainTypePrimaryAuto is a system-generated random subdomain (e.g., k7m2x9p4.threesix.ai).
DomainTypePrimaryAuto DomainType = "primary_auto"
// DomainTypePrimaryCustom is a user-chosen subdomain (e.g., my-app.threesix.ai).
DomainTypePrimaryCustom DomainType = "primary_custom"
// DomainTypeAlias is an additional domain pointing to the project (e.g., www.myapp.com).
DomainTypeAlias DomainType = "alias"
)
// Valid returns true if the domain type is recognized.
func (t DomainType) Valid() bool {
switch t {
case DomainTypePrimaryAuto, DomainTypePrimaryCustom, DomainTypeAlias:
return true
}
return false
}
// IsPrimary returns true if this is a primary domain (auto or custom).
func (t DomainType) IsPrimary() bool {
return t == DomainTypePrimaryAuto || t == DomainTypePrimaryCustom
}
// ProjectDomain represents a domain associated with a project.
type ProjectDomain struct {
ID int64
ProjectID string
Domain string
Type DomainType
DNSRecordID string // Cloudflare record ID for cleanup
DNSRecordType string // A, CNAME, etc.
Verified bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Slug generation constants.
const (
// SlugLength is the length of generated slugs.
SlugLength = 8
// slugChars are the allowed characters for slugs.
// Excludes ambiguous chars: 0/o, 1/l/i to prevent confusion.
slugChars = "23456789abcdefghjkmnpqrstuvwxyz"
)
// slugCharRegex validates that a slug contains only allowed characters.
var slugCharRegex = regexp.MustCompile(`^[23456789abcdefghjkmnpqrstuvwxyz]+$`)
// GenerateSlug creates a random 8-character slug for project identification.
// Uses a restricted character set that excludes ambiguous characters.
func GenerateSlug() (string, error) {
bytes := make([]byte, SlugLength)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
result := make([]byte, SlugLength)
for i := range SlugLength {
result[i] = slugChars[int(bytes[i])%len(slugChars)]
}
return string(result), nil
}
// ValidateSlug checks if a slug is valid (correct length and characters).
func ValidateSlug(slug string) error {
if len(slug) != SlugLength {
return fmt.Errorf("slug must be exactly %d characters", SlugLength)
}
if !slugCharRegex.MatchString(slug) {
return fmt.Errorf("slug contains invalid characters")
}
return nil
}
// Domain validation patterns.
var (
// subdomainRegex validates subdomains under a base domain.
// Lowercase letters, digits, and hyphens. Must start with a letter.
subdomainRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
// fqdnRegex validates fully qualified domain names.
fqdnRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$`)
)
// ValidateSubdomain validates a subdomain name (without the base domain).
func ValidateSubdomain(subdomain string) error {
if subdomain == "" {
return fmt.Errorf("subdomain cannot be empty")
}
if len(subdomain) > 63 {
return fmt.Errorf("subdomain cannot exceed 63 characters")
}
if !subdomainRegex.MatchString(subdomain) {
return fmt.Errorf("subdomain must be lowercase alphanumeric with hyphens, starting with a letter")
}
if reservedProjectNames[subdomain] {
return fmt.Errorf("subdomain %q is reserved", subdomain)
}
return nil
}
// ValidateFQDN validates a fully qualified domain name.
func ValidateFQDN(domain string) error {
if domain == "" {
return fmt.Errorf("domain cannot be empty")
}
if len(domain) > 253 {
return fmt.Errorf("domain cannot exceed 253 characters")
}
domain = strings.ToLower(domain)
if !fqdnRegex.MatchString(domain) {
return fmt.Errorf("invalid domain format")
}
return nil
}
// IsSubdomainOf checks if domain is a subdomain of baseDomain.
func IsSubdomainOf(domain, baseDomain string) bool {
domain = strings.ToLower(domain)
baseDomain = strings.ToLower(baseDomain)
suffix := "." + baseDomain
return strings.HasSuffix(domain, suffix)
}
// ExtractSubdomain extracts the subdomain portion from a full domain.
// Returns empty string if domain is not a subdomain of baseDomain.
func ExtractSubdomain(domain, baseDomain string) string {
domain = strings.ToLower(domain)
baseDomain = strings.ToLower(baseDomain)
suffix := "." + baseDomain
if subdomain, found := strings.CutSuffix(domain, suffix); found {
return subdomain
}
return ""
}

View File

@ -0,0 +1,212 @@
package domain
import (
"testing"
)
func TestGenerateSlug(t *testing.T) {
t.Run("generates correct length", func(t *testing.T) {
slug, err := GenerateSlug()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(slug) != SlugLength {
t.Errorf("slug length = %d, want %d", len(slug), SlugLength)
}
})
t.Run("contains only allowed characters", func(t *testing.T) {
for i := 0; i < 100; i++ {
slug, err := GenerateSlug()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if err := ValidateSlug(slug); err != nil {
t.Errorf("generated slug %q failed validation: %v", slug, err)
}
}
})
t.Run("generates unique slugs", func(t *testing.T) {
seen := make(map[string]bool)
for i := 0; i < 1000; i++ {
slug, err := GenerateSlug()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if seen[slug] {
t.Errorf("duplicate slug generated: %s", slug)
}
seen[slug] = true
}
})
}
func TestValidateSlug(t *testing.T) {
tests := []struct {
slug string
wantErr bool
}{
{"k7m2x9p4", false},
{"abcdefgh", false},
{"23456789", false},
{"", true}, // empty
{"short", true}, // too short
{"toolongslug", true}, // too long
{"ABCDEFGH", true}, // uppercase
{"k7m2x9p0", true}, // contains 0
{"k7m2x9p1", true}, // contains 1
{"k7m2x9po", true}, // contains o
{"k7m2x9pl", true}, // contains l
{"k7m2x9pi", true}, // contains i
{"k7m2-9p4", true}, // contains hyphen
{"k7m2_9p4", true}, // contains underscore
}
for _, tt := range tests {
t.Run(tt.slug, func(t *testing.T) {
err := ValidateSlug(tt.slug)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateSlug(%q) error = %v, wantErr = %v", tt.slug, err, tt.wantErr)
}
})
}
}
func TestDomainType(t *testing.T) {
t.Run("Valid", func(t *testing.T) {
tests := []struct {
dtype DomainType
want bool
}{
{DomainTypePrimaryAuto, true},
{DomainTypePrimaryCustom, true},
{DomainTypeAlias, true},
{DomainType("invalid"), false},
{DomainType(""), false},
}
for _, tt := range tests {
if got := tt.dtype.Valid(); got != tt.want {
t.Errorf("%q.Valid() = %v, want %v", tt.dtype, got, tt.want)
}
}
})
t.Run("IsPrimary", func(t *testing.T) {
tests := []struct {
dtype DomainType
want bool
}{
{DomainTypePrimaryAuto, true},
{DomainTypePrimaryCustom, true},
{DomainTypeAlias, false},
}
for _, tt := range tests {
if got := tt.dtype.IsPrimary(); got != tt.want {
t.Errorf("%q.IsPrimary() = %v, want %v", tt.dtype, got, tt.want)
}
}
})
}
func TestValidateSubdomain(t *testing.T) {
tests := []struct {
subdomain string
wantErr bool
}{
{"my-app", false},
{"myapp123", false},
{"a", false},
{"landing-page", false},
{"", true}, // empty
{"-myapp", true}, // starts with hyphen
{"123app", true}, // starts with number
{"my_app", true}, // contains underscore
{"MyApp", true}, // contains uppercase
{"my.app", true}, // contains dot
{"www", true}, // reserved
{"api", true}, // reserved
{"git", true}, // reserved
}
for _, tt := range tests {
t.Run(tt.subdomain, func(t *testing.T) {
err := ValidateSubdomain(tt.subdomain)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateSubdomain(%q) error = %v, wantErr = %v", tt.subdomain, err, tt.wantErr)
}
})
}
}
func TestValidateFQDN(t *testing.T) {
tests := []struct {
domain string
wantErr bool
}{
{"example.com", false},
{"www.example.com", false},
{"sub.domain.example.com", false},
{"my-app.threesix.ai", false},
{"a.b.c", false},
{"", true},
{"example", false}, // single label is valid
{"-example.com", true}, // starts with hyphen
{"example-.com", true}, // ends with hyphen
{"exam ple.com", true}, // contains space
{"example..com", true}, // double dot
}
for _, tt := range tests {
t.Run(tt.domain, func(t *testing.T) {
err := ValidateFQDN(tt.domain)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateFQDN(%q) error = %v, wantErr = %v", tt.domain, err, tt.wantErr)
}
})
}
}
func TestIsSubdomainOf(t *testing.T) {
tests := []struct {
domain string
baseDomain string
want bool
}{
{"my-app.threesix.ai", "threesix.ai", true},
{"deep.sub.threesix.ai", "threesix.ai", true},
{"example.com", "threesix.ai", false},
{"threesix.ai", "threesix.ai", false}, // same domain, not subdomain
{"MY-APP.THREESIX.AI", "threesix.ai", true}, // case insensitive
}
for _, tt := range tests {
t.Run(tt.domain, func(t *testing.T) {
if got := IsSubdomainOf(tt.domain, tt.baseDomain); got != tt.want {
t.Errorf("IsSubdomainOf(%q, %q) = %v, want %v", tt.domain, tt.baseDomain, got, tt.want)
}
})
}
}
func TestExtractSubdomain(t *testing.T) {
tests := []struct {
domain string
baseDomain string
want string
}{
{"my-app.threesix.ai", "threesix.ai", "my-app"},
{"deep.sub.threesix.ai", "threesix.ai", "deep.sub"},
{"example.com", "threesix.ai", ""},
{"threesix.ai", "threesix.ai", ""},
{"MY-APP.THREESIX.AI", "threesix.ai", "my-app"}, // case normalized
}
for _, tt := range tests {
t.Run(tt.domain, func(t *testing.T) {
if got := ExtractSubdomain(tt.domain, tt.baseDomain); got != tt.want {
t.Errorf("ExtractSubdomain(%q, %q) = %q, want %q", tt.domain, tt.baseDomain, got, tt.want)
}
})
}
}

View File

@ -0,0 +1,40 @@
// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
)
// DomainServiceAdapter adapts ProjectInfraService to the DomainService interface.
type DomainServiceAdapter struct {
svc *service.ProjectInfraService
}
// NewDomainServiceAdapter creates an adapter for the ProjectInfraService.
func NewDomainServiceAdapter(svc *service.ProjectInfraService) *DomainServiceAdapter {
return &DomainServiceAdapter{svc: svc}
}
// ListDomains returns all domains for a project.
func (a *DomainServiceAdapter) ListDomains(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error) {
return a.svc.ListDomains(ctx, projectID)
}
// AddDomain adds a new domain to a project.
func (a *DomainServiceAdapter) AddDomain(ctx context.Context, req DomainAddRequest) (*domain.ProjectDomain, error) {
return a.svc.AddDomain(ctx, service.AddDomainRequest{
ProjectID: req.ProjectID,
Domain: req.Domain,
Type: req.Type,
RecordType: req.RecordType,
Proxied: req.Proxied,
})
}
// RemoveDomain removes a domain from a project.
func (a *DomainServiceAdapter) RemoveDomain(ctx context.Context, projectID, fqdn string) error {
return a.svc.RemoveDomain(ctx, projectID, fqdn)
}

View File

@ -14,13 +14,31 @@ import (
"github.com/orchard9/rdev/pkg/api" "github.com/orchard9/rdev/pkg/api"
) )
// DomainAddRequest contains parameters for adding a domain.
type DomainAddRequest struct {
ProjectID string
Domain string
Type domain.DomainType
RecordType string
Proxied bool
}
// DomainService defines the interface for domain management operations.
// This interface is implemented by service.ProjectInfraService.
type DomainService interface {
ListDomains(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error)
AddDomain(ctx context.Context, req DomainAddRequest) (*domain.ProjectDomain, error)
RemoveDomain(ctx context.Context, projectID, fqdn string) error
}
// InfrastructureHandler handles git, deployment, DNS, and CI pipeline endpoints. // InfrastructureHandler handles git, deployment, DNS, and CI pipeline endpoints.
type InfrastructureHandler struct { type InfrastructureHandler struct {
gitRepo port.GitRepository gitRepo port.GitRepository
dns port.DNSProvider dns port.DNSProvider
deployer port.Deployer deployer port.Deployer
projects port.ProjectRepository projects port.ProjectRepository
ciProvider port.CIProvider ciProvider port.CIProvider
domainService DomainService
// Config // Config
defaultGitOwner string defaultGitOwner string
@ -50,6 +68,7 @@ func NewInfrastructureHandler(
deployer port.Deployer, deployer port.Deployer,
projects port.ProjectRepository, projects port.ProjectRepository,
ciProvider port.CIProvider, ciProvider port.CIProvider,
domainService DomainService,
cfg InfrastructureConfig, cfg InfrastructureConfig,
) *InfrastructureHandler { ) *InfrastructureHandler {
return &InfrastructureHandler{ return &InfrastructureHandler{
@ -58,6 +77,7 @@ func NewInfrastructureHandler(
deployer: deployer, deployer: deployer,
projects: projects, projects: projects,
ciProvider: ciProvider, ciProvider: ciProvider,
domainService: domainService,
defaultGitOwner: cfg.DefaultGitOwner, defaultGitOwner: cfg.DefaultGitOwner,
defaultDomain: cfg.DefaultDomain, defaultDomain: cfg.DefaultDomain,
clusterIP: cfg.ClusterIP, clusterIP: cfg.ClusterIP,

View File

@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -15,22 +16,23 @@ import (
// DomainAliasRequest is the request body for POST /projects/{id}/domains. // DomainAliasRequest is the request body for POST /projects/{id}/domains.
type DomainAliasRequest struct { type DomainAliasRequest struct {
Domain string `json:"domain"` // The domain to add (e.g., "www.threesix.ai") Domain string `json:"domain"` // The domain to add (e.g., "my-app.threesix.ai" or "custom.example.com")
Type string `json:"type,omitempty"` // "A" or "CNAME" (default: "A") Type string `json:"type,omitempty"` // "A" or "CNAME" (default: "A")
Proxied bool `json:"proxied,omitempty"` // Cloudflare proxy (default: false) Proxied bool `json:"proxied,omitempty"` // Cloudflare proxy (default: false)
Content string `json:"content,omitempty"` // Target (default: cluster IP for A records) DomainType string `json:"domain_type,omitempty"` // "primary_custom" or "alias" (default: "alias")
} }
// DomainAliasResponse is the response for domain alias operations. // DomainResponse is the response for domain operations.
type DomainAliasResponse struct { type DomainResponse struct {
Domain string `json:"domain"` ID int64 `json:"id"`
Type string `json:"type"` Domain string `json:"domain"`
Content string `json:"content"` Type string `json:"type"` // DomainType: primary_auto, primary_custom, alias
TTL int `json:"ttl"` RecordType string `json:"record_type"` // DNS record type: A, CNAME
Proxied bool `json:"proxied"` Verified bool `json:"verified"`
CreatedAt string `json:"created_at"`
} }
// ListDomains returns all DNS records associated with a project. // ListDomains returns all domains associated with a project.
// GET /projects/{id}/domains // GET /projects/{id}/domains
func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Request) { func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id") projectID := chi.URLParam(r, "id")
@ -42,66 +44,38 @@ func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Reque
return return
} }
if h.dns == nil { if h.domainService == nil {
api.WriteInternalError(w, r, "DNS provider not configured") api.WriteInternalError(w, r, "domain service not configured")
return return
} }
// List all A records and find ones matching this project domains, err := h.domainService.ListDomains(ctx, projectID)
aRecords, err := h.dns.ListRecords(ctx, "A")
if err != nil { if err != nil {
api.WriteInternalError(w, r, "failed to list DNS records") api.WriteInternalError(w, r, fmt.Sprintf("failed to list domains: %v", err))
return return
} }
// Also list CNAME records // Convert to response format
cnameRecords, err := h.dns.ListRecords(ctx, "CNAME") response := make([]DomainResponse, 0, len(domains))
if err != nil { for _, d := range domains {
api.WriteInternalError(w, r, "failed to list DNS records") response = append(response, DomainResponse{
return ID: d.ID,
} Domain: d.Domain,
Type: string(d.Type),
// Filter records that belong to this project: RecordType: d.DNSRecordType,
// - Primary: {projectID}.{defaultDomain} Verified: d.Verified,
// - Aliases: any record pointing to the cluster IP or the project's primary domain CreatedAt: d.CreatedAt.Format(time.RFC3339),
primaryDomain := projectID + "." + h.defaultDomain })
var domains []DomainAliasResponse
for _, rec := range aRecords {
name := rec.Name
// Normalize: if name matches the project's subdomain or points to our cluster IP
if name == primaryDomain || (rec.Content == h.clusterIP && isProjectDomain(name, projectID, h.defaultDomain)) {
domains = append(domains, DomainAliasResponse{
Domain: name,
Type: rec.Type,
Content: rec.Content,
TTL: rec.TTL,
Proxied: rec.Proxied,
})
}
}
for _, rec := range cnameRecords {
// CNAME records pointing to the project's primary domain
if rec.Content == primaryDomain {
domains = append(domains, DomainAliasResponse{
Domain: rec.Name,
Type: rec.Type,
Content: rec.Content,
TTL: rec.TTL,
Proxied: rec.Proxied,
})
}
} }
api.WriteSuccess(w, r, map[string]any{ api.WriteSuccess(w, r, map[string]any{
"project_id": projectID, "project_id": projectID,
"domains": domains, "domains": response,
"total": len(domains), "total": len(response),
}) })
} }
// AddDomainAlias adds a DNS alias for a project. // AddDomainAlias adds a domain to a project.
// POST /projects/{id}/domains // POST /projects/{id}/domains
func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Request) { func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id") projectID := chi.URLParam(r, "id")
@ -113,8 +87,8 @@ func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Re
return return
} }
if h.dns == nil { if h.domainService == nil {
api.WriteInternalError(w, r, "DNS provider not configured") api.WriteInternalError(w, r, "domain service not configured")
return return
} }
@ -139,57 +113,46 @@ func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Re
return return
} }
// Determine content // Determine domain type
content := req.Content domainType := domain.DomainTypeAlias
if content == "" { if req.DomainType == "primary_custom" {
switch recordType { domainType = domain.DomainTypePrimaryCustom
case "A":
if h.clusterIP == "" {
api.WriteBadRequest(w, r, "cluster IP not configured and no content provided")
return
}
content = h.clusterIP
case "CNAME":
// Default CNAME target is the project's primary domain
content = projectID + "." + h.defaultDomain
}
} }
// Determine the DNS name pd, err := h.domainService.AddDomain(ctx, DomainAddRequest{
// If domain is a full FQDN under our zone, extract the subdomain for the API call ProjectID: projectID,
dnsName := req.Domain Domain: req.Domain,
if isSubdomain(req.Domain, h.defaultDomain) { Type: domainType,
dnsName = getSubdomain(req.Domain, h.defaultDomain) RecordType: recordType,
} Proxied: req.Proxied,
record, err := h.dns.CreateRecord(ctx, domain.DNSRecord{
Type: recordType,
Name: dnsName,
Content: content,
TTL: 1,
Proxied: req.Proxied,
}) })
if err != nil { if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create DNS record: %v", err)) if errors.Is(err, domain.ErrDuplicateDomain) {
api.WriteBadRequest(w, r, "domain already exists")
return
}
api.WriteInternalError(w, r, fmt.Sprintf("failed to add domain: %v", err))
return return
} }
note := "Domain alias configured" note := "Domain configured"
if !isSubdomain(req.Domain, h.defaultDomain) && recordType == "A" { if !isSubdomain(req.Domain, h.defaultDomain) && recordType == "A" {
note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP) note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP)
} }
api.WriteCreated(w, r, map[string]any{ api.WriteCreated(w, r, map[string]any{
"project": projectID, "id": pd.ID,
"domain": record.Name, "project": projectID,
"type": record.Type, "domain": pd.Domain,
"content": record.Content, "type": string(pd.Type),
"status": "configured", "record_type": pd.DNSRecordType,
"note": note, "verified": pd.Verified,
"status": "configured",
"note": note,
}) })
} }
// RemoveDomainAlias removes a DNS alias from a project. // RemoveDomainAlias removes a domain from a project.
// DELETE /projects/{id}/domains/{domain} // DELETE /projects/{id}/domains/{domain}
func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http.Request) { func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id") projectID := chi.URLParam(r, "id")
@ -207,54 +170,29 @@ func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http
return return
} }
if h.dns == nil { if h.domainService == nil {
api.WriteInternalError(w, r, "DNS provider not configured") api.WriteInternalError(w, r, "domain service not configured")
return return
} }
// Prevent deleting the project's primary domain through this endpoint err := h.domainService.RemoveDomain(ctx, projectID, aliasDomain)
primaryDomain := projectID + "." + h.defaultDomain if err != nil {
if aliasDomain == primaryDomain { if errors.Is(err, domain.ErrDomainNotFound) {
api.WriteBadRequest(w, r, "cannot remove primary project domain through alias endpoint; use DELETE /project/{name} instead") api.WriteNotFound(w, r, fmt.Sprintf("domain not found: %s", aliasDomain))
return
}
// Check for primary domain protection
if strings.Contains(err.Error(), "auto-generated primary domain") {
api.WriteBadRequest(w, r, "cannot remove auto-generated primary domain; delete the project instead")
return
}
api.WriteInternalError(w, r, fmt.Sprintf("failed to remove domain: %v", err))
return return
} }
// Check if the record exists before attempting deletion
dnsName := aliasDomain
if isSubdomain(aliasDomain, h.defaultDomain) {
dnsName = getSubdomain(aliasDomain, h.defaultDomain)
}
// Check both A and CNAME records
aRecord, _ := h.dns.FindRecord(ctx, "A", dnsName)
cnameRecord, _ := h.dns.FindRecord(ctx, "CNAME", dnsName)
if aRecord == nil && cnameRecord == nil {
api.WriteNotFound(w, r, fmt.Sprintf("no DNS record found for %s", aliasDomain))
return
}
// Delete whichever record(s) exist
if aRecord != nil {
_ = h.dns.DeleteRecordByName(ctx, "A", dnsName)
}
if cnameRecord != nil {
_ = h.dns.DeleteRecordByName(ctx, "CNAME", dnsName)
}
api.WriteSuccess(w, r, map[string]string{ api.WriteSuccess(w, r, map[string]string{
"project": projectID, "project": projectID,
"domain": aliasDomain, "domain": aliasDomain,
"status": "removed", "status": "removed",
}) })
} }
// isProjectDomain checks if a DNS name is associated with a project.
// It matches: {projectID}.{baseDomain} or any subdomain pattern containing the project ID.
func isProjectDomain(name, projectID, baseDomain string) bool {
// Exact match: landing.threesix.ai
if name == projectID+"."+baseDomain {
return true
}
return false
}

View File

@ -2,6 +2,7 @@ package handlers
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -11,18 +12,79 @@ import (
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
) )
func TestInfrastructureHandler_ListDomains(t *testing.T) { // mockDomainService implements DomainService for testing.
t.Run("returns matching A records", func(t *testing.T) { type mockDomainService struct {
_, _, dns, _, router := setupInfraHandler() domains map[string][]*domain.ProjectDomain // projectID -> domains
err error
}
// Add records — one matching the project, one unrelated func newMockDomainService() *mockDomainService {
dns.records["landing.threesix.ai"] = &domain.DNSRecord{ return &mockDomainService{
ID: "rec-1", Type: "A", Name: "landing.threesix.ai", domains: make(map[string][]*domain.ProjectDomain),
Content: "208.122.204.172", TTL: 1, }
}
func (m *mockDomainService) ListDomains(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error) {
if m.err != nil {
return nil, m.err
}
return m.domains[projectID], nil
}
func (m *mockDomainService) AddDomain(ctx context.Context, req DomainAddRequest) (*domain.ProjectDomain, error) {
if m.err != nil {
return nil, m.err
}
pd := &domain.ProjectDomain{
ID: int64(len(m.domains[req.ProjectID]) + 1),
ProjectID: req.ProjectID,
Domain: req.Domain,
Type: req.Type,
DNSRecordType: req.RecordType,
Verified: true,
}
m.domains[req.ProjectID] = append(m.domains[req.ProjectID], pd)
return pd, nil
}
func (m *mockDomainService) RemoveDomain(ctx context.Context, projectID, fqdn string) error {
if m.err != nil {
return m.err
}
domains := m.domains[projectID]
for i, d := range domains {
if d.Domain == fqdn {
// Check if it's the primary auto domain
if d.Type == domain.DomainTypePrimaryAuto {
return domain.ErrDomainNotFound // Mimic primary domain protection
}
m.domains[projectID] = append(domains[:i], domains[i+1:]...)
return nil
} }
dns.records["other.threesix.ai"] = &domain.DNSRecord{ }
ID: "rec-2", Type: "A", Name: "other.threesix.ai", return domain.ErrDomainNotFound
Content: "208.122.204.172", TTL: 1, }
func setupInfraDomainHandler() (*InfrastructureHandler, *mockDomainService, chi.Router) {
domainSvc := newMockDomainService()
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, domainSvc, InfrastructureConfig{
DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai",
ClusterIP: "208.122.204.172",
})
r := chi.NewRouter()
h.Mount(r)
return h, domainSvc, r
}
func TestInfrastructureHandler_ListDomains(t *testing.T) {
t.Run("returns domains from database", func(t *testing.T) {
_, domainSvc, router := setupInfraDomainHandler()
// Add domains to mock service
domainSvc.domains["landing"] = []*domain.ProjectDomain{
{ID: 1, ProjectID: "landing", Domain: "abc12345.threesix.ai", Type: domain.DomainTypePrimaryAuto, DNSRecordType: "A", Verified: true},
{ID: 2, ProjectID: "landing", Domain: "landing.threesix.ai", Type: domain.DomainTypePrimaryCustom, DNSRecordType: "A", Verified: true},
} }
req := httptest.NewRequest("GET", "/projects/landing/domains", nil) req := httptest.NewRequest("GET", "/projects/landing/domains", nil)
@ -33,37 +95,6 @@ func TestInfrastructureHandler_ListDomains(t *testing.T) {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
} }
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data := resp["data"].(map[string]any)
total := int(data["total"].(float64))
if total != 1 {
t.Errorf("total = %d, want 1 (only landing.threesix.ai)", total)
}
})
t.Run("returns CNAME aliases", func(t *testing.T) {
_, _, dns, _, router := setupInfraHandler()
dns.records["landing.threesix.ai"] = &domain.DNSRecord{
ID: "rec-1", Type: "A", Name: "landing.threesix.ai",
Content: "208.122.204.172", TTL: 1,
}
dns.records["www.threesix.ai"] = &domain.DNSRecord{
ID: "rec-2", Type: "CNAME", Name: "www.threesix.ai",
Content: "landing.threesix.ai", TTL: 1,
}
req := httptest.NewRequest("GET", "/projects/landing/domains", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string]any var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err) t.Fatalf("unmarshal: %v", err)
@ -71,12 +102,12 @@ func TestInfrastructureHandler_ListDomains(t *testing.T) {
data := resp["data"].(map[string]any) data := resp["data"].(map[string]any)
total := int(data["total"].(float64)) total := int(data["total"].(float64))
if total != 2 { if total != 2 {
t.Errorf("total = %d, want 2 (A + CNAME)", total) t.Errorf("total = %d, want 2", total)
} }
}) })
t.Run("DNS not configured", func(t *testing.T) { t.Run("domain service not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, InfrastructureConfig{ h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{
DefaultGitOwner: "threesix", DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai", DefaultDomain: "threesix.ai",
ClusterIP: "208.122.204.172", ClusterIP: "208.122.204.172",
@ -95,8 +126,8 @@ func TestInfrastructureHandler_ListDomains(t *testing.T) {
} }
func TestInfrastructureHandler_AddDomainAlias(t *testing.T) { func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
t.Run("add A record alias", func(t *testing.T) { t.Run("add domain alias", func(t *testing.T) {
_, _, dns, _, router := setupInfraHandler() _, domainSvc, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai"}) body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai"})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
@ -106,17 +137,17 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
} }
if len(dns.records) != 1 { if len(domainSvc.domains["landing"]) != 1 {
t.Errorf("DNS records = %d, want 1", len(dns.records)) t.Errorf("domains = %d, want 1", len(domainSvc.domains["landing"]))
} }
}) })
t.Run("add CNAME alias", func(t *testing.T) { t.Run("add primary custom domain", func(t *testing.T) {
_, _, dns, _, router := setupInfraHandler() _, domainSvc, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{ body, _ := json.Marshal(DomainAliasRequest{
Domain: "www.threesix.ai", Domain: "mysite.threesix.ai",
Type: "CNAME", DomainType: "primary_custom",
}) })
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -125,19 +156,16 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
if rec.Code != http.StatusCreated { if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String()) t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
} }
// CNAME should target landing.threesix.ai if len(domainSvc.domains["landing"]) != 1 {
for _, r := range dns.records { t.Errorf("domains = %d, want 1", len(domainSvc.domains["landing"]))
if r.Type != "CNAME" { }
t.Errorf("type = %s, want CNAME", r.Type) if domainSvc.domains["landing"][0].Type != domain.DomainTypePrimaryCustom {
} t.Errorf("type = %s, want primary_custom", domainSvc.domains["landing"][0].Type)
if r.Content != "landing.threesix.ai" {
t.Errorf("content = %s, want landing.threesix.ai", r.Content)
}
} }
}) })
t.Run("invalid type", func(t *testing.T) { t.Run("invalid type", func(t *testing.T) {
_, _, _, _, router := setupInfraHandler() _, _, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai", Type: "MX"}) body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai", Type: "MX"})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
@ -150,7 +178,7 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
}) })
t.Run("missing domain", func(t *testing.T) { t.Run("missing domain", func(t *testing.T) {
_, _, _, _, router := setupInfraHandler() _, _, router := setupInfraDomainHandler()
body, _ := json.Marshal(DomainAliasRequest{}) body, _ := json.Marshal(DomainAliasRequest{})
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
@ -162,8 +190,8 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
} }
}) })
t.Run("DNS not configured", func(t *testing.T) { t.Run("domain service not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, InfrastructureConfig{ h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{
DefaultGitOwner: "threesix", DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai", DefaultDomain: "threesix.ai",
ClusterIP: "208.122.204.172", ClusterIP: "208.122.204.172",
@ -184,10 +212,9 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
func TestInfrastructureHandler_RemoveDomainAlias(t *testing.T) { func TestInfrastructureHandler_RemoveDomainAlias(t *testing.T) {
t.Run("removes alias", func(t *testing.T) { t.Run("removes alias", func(t *testing.T) {
_, _, dns, _, router := setupInfraHandler() _, domainSvc, router := setupInfraDomainHandler()
dns.records["www"] = &domain.DNSRecord{ domainSvc.domains["landing"] = []*domain.ProjectDomain{
ID: "rec-www", Type: "A", Name: "www", {ID: 1, ProjectID: "landing", Domain: "www.threesix.ai", Type: domain.DomainTypeAlias, DNSRecordType: "A"},
Content: "208.122.204.172",
} }
req := httptest.NewRequest("DELETE", "/projects/landing/domains/www.threesix.ai", nil) req := httptest.NewRequest("DELETE", "/projects/landing/domains/www.threesix.ai", nil)
@ -197,23 +224,13 @@ func TestInfrastructureHandler_RemoveDomainAlias(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String()) t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
} }
}) if len(domainSvc.domains["landing"]) != 0 {
t.Errorf("domains = %d, want 0", len(domainSvc.domains["landing"]))
t.Run("prevents removing primary domain", func(t *testing.T) {
_, _, _, _, router := setupInfraHandler()
req := httptest.NewRequest("DELETE", "/projects/landing/domains/landing.threesix.ai", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
} }
}) })
t.Run("not found", func(t *testing.T) { t.Run("not found", func(t *testing.T) {
_, _, dns, _, router := setupInfraHandler() _, _, router := setupInfraDomainHandler()
dns.err = nil // No records stored
req := httptest.NewRequest("DELETE", "/projects/landing/domains/nonexistent.threesix.ai", nil) req := httptest.NewRequest("DELETE", "/projects/landing/domains/nonexistent.threesix.ai", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
@ -224,23 +241,3 @@ func TestInfrastructureHandler_RemoveDomainAlias(t *testing.T) {
} }
}) })
} }
func TestIsProjectDomain(t *testing.T) {
tests := []struct {
name, projectID, baseDomain string
want bool
}{
{"landing.threesix.ai", "landing", "threesix.ai", true},
{"other.threesix.ai", "landing", "threesix.ai", false},
{"landing.example.com", "landing", "threesix.ai", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isProjectDomain(tt.name, tt.projectID, tt.baseDomain)
if got != tt.want {
t.Errorf("isProjectDomain(%q, %q, %q) = %v, want %v",
tt.name, tt.projectID, tt.baseDomain, got, tt.want)
}
})
}
}

View File

@ -67,8 +67,15 @@ func (m *mockCIProvider) GetPipeline(_ context.Context, owner, repo string, numb
return nil, fmt.Errorf("pipeline %d not found", number) return nil, fmt.Errorf("pipeline %d not found", number)
} }
func (m *mockCIProvider) TriggerBuild(_ context.Context, owner, repo, branch string) (int64, error) {
if m.err != nil {
return 0, m.err
}
return 1, nil
}
func setupInfraHandlerWithCI(ci port.CIProvider) chi.Router { func setupInfraHandlerWithCI(ci port.CIProvider) chi.Router {
h := NewInfrastructureHandler(nil, nil, nil, nil, ci, InfrastructureConfig{ h := NewInfrastructureHandler(nil, nil, nil, nil, ci, nil, InfrastructureConfig{
DefaultGitOwner: "threesix", DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai", DefaultDomain: "threesix.ai",
}) })

View File

@ -245,7 +245,7 @@ func setupInfraHandler() (*InfrastructureHandler, *mockGitRepository, *mockDNSPr
git := newMockGitRepository() git := newMockGitRepository()
dns := newMockDNSProvider() dns := newMockDNSProvider()
deployer := newMockDeployer() deployer := newMockDeployer()
h := NewInfrastructureHandler(git, dns, deployer, nil, nil, InfrastructureConfig{ h := NewInfrastructureHandler(git, dns, deployer, nil, nil, nil, InfrastructureConfig{
DefaultGitOwner: "threesix", DefaultGitOwner: "threesix",
DefaultDomain: "threesix.ai", DefaultDomain: "threesix.ai",
ClusterIP: "208.122.204.172", ClusterIP: "208.122.204.172",
@ -298,7 +298,7 @@ func TestInfrastructureHandler_CreateRepo(t *testing.T) {
}) })
t.Run("git not configured", func(t *testing.T) { t.Run("git not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, InfrastructureConfig{}) h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{})
r := chi.NewRouter() r := chi.NewRouter()
h.Mount(r) h.Mount(r)
@ -392,7 +392,7 @@ func TestInfrastructureHandler_Deploy(t *testing.T) {
}) })
t.Run("deployer not configured", func(t *testing.T) { t.Run("deployer not configured", func(t *testing.T) {
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, InfrastructureConfig{}) h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{})
r := chi.NewRouter() r := chi.NewRouter()
h.Mount(r) h.Mount(r)

View File

@ -57,7 +57,9 @@ type CreateRequest struct {
// Create creates a new project with git repo and DNS. // Create creates a new project with git repo and DNS.
// POST /project // POST /project
func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request) { func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) // 90 second timeout to allow for Woodpecker sync retry (up to 45s)
// plus Gitea repo creation, DNS, and template seeding
ctx, cancel := context.WithTimeout(r.Context(), 90*time.Second)
defer cancel() defer cancel()
if h.infraService == nil { if h.infraService == nil {

View File

@ -35,4 +35,8 @@ type CIProvider interface {
// GetPipeline returns a specific pipeline execution by number. // GetPipeline returns a specific pipeline execution by number.
GetPipeline(ctx context.Context, owner, repo string, number int64) (*domain.CIPipeline, error) GetPipeline(ctx context.Context, owner, repo string, number int64) (*domain.CIPipeline, error)
// TriggerBuild manually starts a new pipeline build on the specified branch.
// Returns the pipeline number of the triggered build.
TriggerBuild(ctx context.Context, owner, repo, branch string) (int64, error)
} }

View File

@ -0,0 +1,57 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import (
"context"
"github.com/orchard9/rdev/internal/domain"
)
// ProjectDomainRepository manages project-domain associations.
type ProjectDomainRepository interface {
// Create adds a new domain association for a project.
// Returns error if domain already exists (unique constraint).
Create(ctx context.Context, pd *domain.ProjectDomain) error
// GetByID retrieves a domain by its ID.
GetByID(ctx context.Context, id int64) (*domain.ProjectDomain, error)
// GetByDomain retrieves a domain by its FQDN.
// Returns nil, nil if not found.
GetByDomain(ctx context.Context, fqdn string) (*domain.ProjectDomain, error)
// ListByProject returns all domains for a project.
ListByProject(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error)
// GetPrimary returns the primary domain for a project.
// Prefers primary_custom over primary_auto if both exist.
GetPrimary(ctx context.Context, projectID string) (*domain.ProjectDomain, error)
// Update modifies an existing domain record.
Update(ctx context.Context, pd *domain.ProjectDomain) error
// Delete removes a domain by ID.
Delete(ctx context.Context, id int64) error
// DeleteByDomain removes a domain by FQDN.
DeleteByDomain(ctx context.Context, fqdn string) error
// DeleteByProject removes all domains for a project.
DeleteByProject(ctx context.Context, projectID string) error
// Exists checks if a domain already exists.
Exists(ctx context.Context, fqdn string) (bool, error)
// CountByProject returns the number of domains for a project.
CountByProject(ctx context.Context, projectID string) (int, error)
}
// SlugGenerator generates unique slugs for projects.
type SlugGenerator interface {
// Generate creates a new unique slug.
// Implementations should retry on collision.
Generate(ctx context.Context) (string, error)
// IsUnique checks if a slug is unique (not in use).
IsUnique(ctx context.Context, slug string) (bool, error)
}

View File

@ -6,6 +6,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"time" "time"
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
@ -28,6 +29,8 @@ type ProjectInfraService struct {
deployer port.Deployer deployer port.Deployer
ciProvider port.CIProvider ciProvider port.CIProvider
templateProvider port.TemplateProvider templateProvider port.TemplateProvider
domainRepo port.ProjectDomainRepository
slugGenerator port.SlugGenerator
logger *slog.Logger logger *slog.Logger
// Config // Config
@ -52,6 +55,8 @@ func NewProjectInfraService(
deployer port.Deployer, deployer port.Deployer,
ciProvider port.CIProvider, ciProvider port.CIProvider,
templateProvider port.TemplateProvider, templateProvider port.TemplateProvider,
domainRepo port.ProjectDomainRepository,
slugGenerator port.SlugGenerator,
cfg ProjectInfraConfig, cfg ProjectInfraConfig,
) *ProjectInfraService { ) *ProjectInfraService {
logger := cfg.Logger logger := cfg.Logger
@ -65,6 +70,8 @@ func NewProjectInfraService(
deployer: deployer, deployer: deployer,
ciProvider: ciProvider, ciProvider: ciProvider,
templateProvider: templateProvider, templateProvider: templateProvider,
domainRepo: domainRepo,
slugGenerator: slugGenerator,
logger: logger, logger: logger,
defaultGitOwner: cfg.DefaultGitOwner, defaultGitOwner: cfg.DefaultGitOwner,
defaultDomain: cfg.DefaultDomain, defaultDomain: cfg.DefaultDomain,
@ -74,10 +81,11 @@ func NewProjectInfraService(
// CreateProjectRequest contains parameters for creating a new project. // CreateProjectRequest contains parameters for creating a new project.
type CreateProjectRequest struct { type CreateProjectRequest struct {
Name string Name string
Description string Description string
Private bool Private bool
Template string // Template to seed the repo with (default: "default") Template string // Template to seed the repo with (default: "default")
CustomSubdomain string // Optional: custom subdomain (e.g., "my-app" for my-app.threesix.ai)
} }
// CreateProjectResult contains the result of project creation. // CreateProjectResult contains the result of project creation.
@ -85,6 +93,7 @@ type CreateProjectResult struct {
ProjectID string ProjectID string
Name string Name string
Description string Description string
Slug string // Auto-generated unique identifier
// Git info // Git info
GitRepoOwner string GitRepoOwner string
@ -93,162 +102,23 @@ type CreateProjectResult struct {
CloneHTTP string CloneHTTP string
HTMLURL string HTMLURL string
// Domain info // Domain info (primary domain for backward compatibility)
Domain string Domain string
URL string URL string
// All domains associated with the project
Domains []*domain.ProjectDomain
// Next steps // Next steps
NextSteps []string NextSteps []string
} }
// CreateProject creates a new project with git repo and DNS. // ProjectStatus represents the current status of a project.
// This is the main orchestration method for /project create.
func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProjectRequest) (*CreateProjectResult, error) {
// Validate project name first
if err := ValidateProjectName(req.Name); err != nil {
return nil, fmt.Errorf("%w: %w", domain.ErrInvalidProjectName, err)
}
s.logger.Info("creating project", "name", req.Name)
// 1. Create project in database
projectID := req.Name // Use name as ID for simplicity
now := time.Now()
_, err := s.db.ExecContext(ctx, `
INSERT INTO projects (id, name, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
description = EXCLUDED.description,
updated_at = EXCLUDED.updated_at
`, projectID, req.Name, req.Description, now, now)
if err != nil {
return nil, fmt.Errorf("failed to create project in database: %w", err)
}
result := &CreateProjectResult{
ProjectID: projectID,
Name: req.Name,
Description: req.Description,
Domain: req.Name + "." + s.defaultDomain,
}
result.URL = "https://" + result.Domain
// 2. Create git repository
if s.gitRepo != nil {
repo, err := s.gitRepo.CreateRepo(ctx, req.Name, req.Description, req.Private)
if err != nil {
s.logger.Error("failed to create git repo", "error", err)
result.NextSteps = append(result.NextSteps, "Create git repo manually: failed to auto-create")
} else {
result.GitRepoOwner = repo.Owner
result.GitRepoName = repo.Name
result.CloneSSH = repo.CloneSSH
result.CloneHTTP = repo.CloneHTTP
result.HTMLURL = repo.HTMLURL
// Update database with git info
_, err = s.db.ExecContext(ctx, `
UPDATE projects SET
git_repo_owner = $1,
git_repo_name = $2,
git_clone_ssh = $3,
git_clone_http = $4,
git_html_url = $5,
updated_at = $6
WHERE id = $7
`, repo.Owner, repo.Name, repo.CloneSSH, repo.CloneHTTP, repo.HTMLURL, time.Now(), projectID)
if err != nil {
s.logger.Error("failed to update project with git info", "error", err, "project", projectID)
// Continue - the git repo was created, we just failed to record it
}
}
} else {
result.NextSteps = append(result.NextSteps, "Git repository service not configured")
}
// 3. Create DNS record
if s.dns != nil {
_, err := s.dns.CreateRecord(ctx, domain.DNSRecord{
Type: "A",
Name: req.Name,
Content: s.clusterIP,
TTL: 1,
Proxied: false,
})
if err != nil {
s.logger.Warn("failed to create DNS record", "error", err)
result.NextSteps = append(result.NextSteps, "Create DNS record manually: "+req.Name+"."+s.defaultDomain+" → "+s.clusterIP)
} else {
// Update database with domain
_, err = s.db.ExecContext(ctx, `
UPDATE projects SET domain = $1, updated_at = $2 WHERE id = $3
`, result.Domain, time.Now(), projectID)
if err != nil {
s.logger.Error("failed to update project with domain", "error", err, "project", projectID)
// Continue - the DNS was created, we just failed to record it
}
}
} else {
result.NextSteps = append(result.NextSteps, "DNS service not configured")
}
// 4. Activate CI (Woodpecker)
if s.ciProvider != nil && result.GitRepoOwner != "" {
ciRepo, err := s.ciProvider.ActivateRepo(ctx, "gitea", result.GitRepoOwner, result.GitRepoName)
if err != nil {
s.logger.Warn("failed to activate CI", "error", err)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Activate Woodpecker manually: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, result.GitRepoOwner, result.GitRepoName),
)
} else {
s.logger.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID)
}
} else if s.ciProvider == nil {
result.NextSteps = append(result.NextSteps, "CI provider not configured")
}
// 5. Seed repository with template
if s.templateProvider != nil && result.GitRepoOwner != "" {
templateName := req.Template
if templateName == "" {
templateName = "default"
}
// Prepare template variables
vars := map[string]string{
"PROJECT_NAME": req.Name,
"DOMAIN": result.Domain,
"GIT_URL": result.CloneHTTP,
}
err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars)
if err != nil {
s.logger.Warn("failed to seed repo with template", "error", err, "template", templateName)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Add template files manually (template: %s)", templateName),
)
} else {
s.logger.Info("repo seeded with template", "template", templateName)
}
} else if s.templateProvider == nil {
result.NextSteps = append(result.NextSteps, "Template provider not configured")
}
s.logger.Info("project created successfully",
"project", req.Name,
"git_repo", result.CloneSSH,
"domain", result.Domain,
)
return result, nil
}
// GetProjectStatus returns the current status of a project.
type ProjectStatus struct { type ProjectStatus struct {
ProjectID string ProjectID string
Name string Name string
Description string Description string
Slug string
// Git // Git
GitRepoOwner string GitRepoOwner string
@ -257,154 +127,59 @@ type ProjectStatus struct {
CloneHTTP string CloneHTTP string
HTMLURL string HTMLURL string
// Domain // Domain (primary for backward compatibility)
Domain string Domain string
CustomDomain string CustomDomain string
URL string URL string
// All domains associated with the project
Domains []*domain.ProjectDomain
// Deployment // Deployment
DeploymentImage string DeploymentImage string
DeploymentStatus string DeploymentStatus string
DeploymentReplicas int DeploymentReplicas int
ReadyReplicas int ReadyReplicas int
// Site health
SiteLive bool // True if the site responds with HTTP 200
SiteError string // Error message if site check failed
} }
// GetStatus returns the current status of a project. // AddDomainRequest contains parameters for adding a domain to a project.
func (s *ProjectInfraService) GetStatus(ctx context.Context, projectID string) (*ProjectStatus, error) { type AddDomainRequest struct {
var status ProjectStatus ProjectID string
Domain string // Full domain (e.g., "my-app.threesix.ai" or "custom.example.com")
Type domain.DomainType // DomainTypePrimaryCustom or DomainTypeAlias
RecordType string // "A" or "CNAME" (default: "A")
Proxied bool // Cloudflare proxy enabled
}
err := s.db.QueryRowContext(ctx, ` // checkSiteHealth performs an HTTP GET request to check if a site is live.
SELECT // Returns (true, "") if the site returns HTTP 200, otherwise (false, errorMessage).
id, name, COALESCE(description, ''), func checkSiteHealth(ctx context.Context, url string) (bool, string) {
COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''), client := &http.Client{
COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''), Timeout: 5 * time.Second,
COALESCE(domain, ''), COALESCE(custom_domain, ''), CheckRedirect: func(req *http.Request, via []*http.Request) error {
COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'), return http.ErrUseLastResponse
COALESCE(deployment_replicas, 1) },
FROM projects WHERE id = $1
`, projectID).Scan(
&status.ProjectID, &status.Name, &status.Description,
&status.GitRepoOwner, &status.GitRepoName,
&status.CloneSSH, &status.CloneHTTP, &status.HTMLURL,
&status.Domain, &status.CustomDomain,
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get project: %w", err) return false, fmt.Sprintf("failed to create request: %v", err)
} }
if status.Domain != "" { resp, err := client.Do(req)
status.URL = "https://" + status.Domain
}
// Get live deployment status if deployer is available
if s.deployer != nil {
deployStatus, err := s.deployer.GetStatus(ctx, projectID)
if err == nil && deployStatus != nil {
status.DeploymentStatus = string(deployStatus.Status)
status.ReadyReplicas = deployStatus.ReadyReplicas
if deployStatus.URL != "" {
status.URL = deployStatus.URL
}
}
}
return &status, nil
}
// ListProjects returns all projects.
func (s *ProjectInfraService) ListProjects(ctx context.Context) ([]*ProjectStatus, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT
id, name, COALESCE(description, ''),
COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''),
COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''),
COALESCE(domain, ''), COALESCE(custom_domain, ''),
COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'),
COALESCE(deployment_replicas, 1)
FROM projects
ORDER BY created_at DESC
`)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list projects: %w", err) return false, fmt.Sprintf("request failed: %v", err)
} }
defer func() { _ = rows.Close() }() defer func() { _ = resp.Body.Close() }()
var projects []*ProjectStatus // Accept 2xx and 3xx status codes as "live"
for rows.Next() { if resp.StatusCode >= 200 && resp.StatusCode < 400 {
var status ProjectStatus return true, ""
err := rows.Scan(
&status.ProjectID, &status.Name, &status.Description,
&status.GitRepoOwner, &status.GitRepoName,
&status.CloneSSH, &status.CloneHTTP, &status.HTMLURL,
&status.Domain, &status.CustomDomain,
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
)
if err != nil {
continue
}
if status.Domain != "" {
status.URL = "https://" + status.Domain
}
projects = append(projects, &status)
} }
return projects, nil return false, fmt.Sprintf("HTTP %d", resp.StatusCode)
}
// DeleteProject removes a project and its associated resources.
func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID string) error {
s.logger.Info("deleting project", "project", projectID)
// Get project info first
status, err := s.GetStatus(ctx, projectID)
if err != nil {
return err
}
// 1. Undeploy if deployed
if s.deployer != nil && status.DeploymentStatus != "none" {
if err := s.deployer.Undeploy(ctx, projectID); err != nil {
s.logger.Warn("failed to undeploy", "error", err)
}
}
// 2. Delete DNS record
if s.dns != nil && status.Domain != "" {
subdomain := status.Name
if err := s.dns.DeleteRecordByName(ctx, "A", subdomain); err != nil {
s.logger.Warn("failed to delete DNS record", "error", err)
}
}
// 3. Delete git repo (optional - might want to keep it)
// Skipping git repo deletion for safety
// 4. Delete from database
_, err = s.db.ExecContext(ctx, `DELETE FROM projects WHERE id = $1`, projectID)
if err != nil {
return fmt.Errorf("failed to delete project from database: %w", err)
}
s.logger.Info("project deleted", "project", projectID)
return nil
}
// ListTemplates returns available project templates.
func (s *ProjectInfraService) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.ListTemplates(ctx)
}
// GetTemplate returns info about a specific template.
func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.GetTemplate(ctx, name)
} }

View File

@ -0,0 +1,490 @@
package service
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// CreateProject creates a new project with git repo and DNS.
// This is the main orchestration method for /project create.
func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProjectRequest) (*CreateProjectResult, error) {
// Validate project name first
if err := ValidateProjectName(req.Name); err != nil {
return nil, fmt.Errorf("%w: %w", domain.ErrInvalidProjectName, err)
}
// Validate custom subdomain if provided
if req.CustomSubdomain != "" {
if err := domain.ValidateSubdomain(req.CustomSubdomain); err != nil {
return nil, fmt.Errorf("invalid custom subdomain: %w", err)
}
}
s.logger.Info("creating project", "name", req.Name)
// 1. Generate unique slug
slug, err := s.generateSlug(ctx, req.Name)
if err != nil {
return nil, err
}
// 2. Create project in database with slug
projectID := req.Name // Use name as ID for simplicity
if err := s.createProjectInDB(ctx, projectID, req, slug); err != nil {
return nil, err
}
// Primary auto domain uses slug
autoDomain := slug + "." + s.defaultDomain
result := &CreateProjectResult{
ProjectID: projectID,
Name: req.Name,
Description: req.Description,
Slug: slug,
Domain: autoDomain,
}
result.URL = "https://" + result.Domain
// 3. Create git repository
s.createGitRepo(ctx, req, result, projectID)
// 4. Create DNS record for primary auto domain (slug-based)
s.createPrimaryDNS(ctx, slug, autoDomain, projectID, result)
// 5. Create custom subdomain if requested
s.createCustomDNS(ctx, req, projectID, result)
// 6. Activate CI (Woodpecker) - Before seeding so the webhook is installed
ciActivated := s.activateCI(ctx, result)
// 7. Seed repository with template
templateSeeded := s.seedTemplate(ctx, req, result)
// 8. Trigger initial CI build if both CI and template are ready
if ciActivated && templateSeeded && s.ciProvider != nil {
pipelineNum, err := s.ciProvider.TriggerBuild(ctx, result.GitRepoOwner, result.GitRepoName, "main")
if err != nil {
s.logger.Warn("failed to trigger initial build", "error", err)
} else {
s.logger.Info("initial build triggered", "pipeline", pipelineNum)
}
}
s.logger.Info("project created successfully",
"project", req.Name,
"git_repo", result.CloneSSH,
"domain", result.Domain,
)
return result, nil
}
func (s *ProjectInfraService) generateSlug(ctx context.Context, name string) (string, error) {
if s.slugGenerator != nil {
slug, err := s.slugGenerator.Generate(ctx)
if err != nil {
return "", fmt.Errorf("failed to generate slug: %w", err)
}
return slug, nil
}
// Fallback: use first 8 chars of name if no slug generator
slug := name
if len(slug) > 8 {
slug = slug[:8]
}
return slug, nil
}
func (s *ProjectInfraService) createProjectInDB(ctx context.Context, projectID string, req CreateProjectRequest, slug string) error {
now := time.Now()
_, err := s.db.ExecContext(ctx, `
INSERT INTO projects (id, name, description, slug, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $5)
ON CONFLICT (id) DO UPDATE SET
description = EXCLUDED.description,
slug = COALESCE(projects.slug, EXCLUDED.slug),
updated_at = EXCLUDED.updated_at
`, projectID, req.Name, req.Description, slug, now)
if err != nil {
return fmt.Errorf("failed to create project in database: %w", err)
}
return nil
}
func (s *ProjectInfraService) createGitRepo(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult, projectID string) {
if s.gitRepo == nil {
result.NextSteps = append(result.NextSteps, "Git repository service not configured")
return
}
repo, err := s.gitRepo.CreateRepo(ctx, req.Name, req.Description, req.Private)
if err != nil {
s.logger.Error("failed to create git repo", "error", err)
result.NextSteps = append(result.NextSteps, "Create git repo manually: failed to auto-create")
return
}
result.GitRepoOwner = repo.Owner
result.GitRepoName = repo.Name
result.CloneSSH = repo.CloneSSH
result.CloneHTTP = repo.CloneHTTP
result.HTMLURL = repo.HTMLURL
// Update database with git info
_, err = s.db.ExecContext(ctx, `
UPDATE projects SET
git_repo_owner = $1,
git_repo_name = $2,
git_clone_ssh = $3,
git_clone_http = $4,
git_html_url = $5,
updated_at = $6
WHERE id = $7
`, repo.Owner, repo.Name, repo.CloneSSH, repo.CloneHTTP, repo.HTMLURL, time.Now(), projectID)
if err != nil {
s.logger.Error("failed to update project with git info", "error", err, "project", projectID)
}
}
func (s *ProjectInfraService) createPrimaryDNS(ctx context.Context, slug, autoDomain, projectID string, result *CreateProjectResult) {
if s.dns == nil {
result.NextSteps = append(result.NextSteps, "DNS service not configured")
return
}
dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{
Type: "A",
Name: slug,
Content: s.clusterIP,
TTL: 1,
Proxied: false,
})
if err != nil {
s.logger.Warn("failed to create DNS record", "error", err)
result.NextSteps = append(result.NextSteps, "Create DNS record manually: "+autoDomain+" → "+s.clusterIP)
return
}
// Store in project_domains table
if s.domainRepo != nil {
pd := &domain.ProjectDomain{
ProjectID: projectID,
Domain: autoDomain,
Type: domain.DomainTypePrimaryAuto,
DNSRecordID: dnsRecord.ID,
DNSRecordType: "A",
Verified: true,
}
if err := s.domainRepo.Create(ctx, pd); err != nil {
s.logger.Error("failed to store primary domain", "error", err)
} else {
result.Domains = append(result.Domains, pd)
}
}
// Also update legacy domain column for backward compatibility
_, err = s.db.ExecContext(ctx, `
UPDATE projects SET domain = $1, updated_at = $2 WHERE id = $3
`, result.Domain, time.Now(), projectID)
if err != nil {
s.logger.Error("failed to update project with domain", "error", err, "project", projectID)
}
}
func (s *ProjectInfraService) createCustomDNS(ctx context.Context, req CreateProjectRequest, projectID string, result *CreateProjectResult) {
if req.CustomSubdomain == "" || s.dns == nil || s.domainRepo == nil {
return
}
customDomain := req.CustomSubdomain + "." + s.defaultDomain
dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{
Type: "A",
Name: req.CustomSubdomain,
Content: s.clusterIP,
TTL: 1,
Proxied: false,
})
if err != nil {
s.logger.Warn("failed to create custom DNS record", "error", err)
result.NextSteps = append(result.NextSteps, "Create custom DNS manually: "+customDomain+" → "+s.clusterIP)
return
}
pd := &domain.ProjectDomain{
ProjectID: projectID,
Domain: customDomain,
Type: domain.DomainTypePrimaryCustom,
DNSRecordID: dnsRecord.ID,
DNSRecordType: "A",
Verified: true,
}
if err := s.domainRepo.Create(ctx, pd); err != nil {
s.logger.Error("failed to store custom domain", "error", err)
} else {
result.Domains = append(result.Domains, pd)
// Custom domain becomes the primary for display
result.Domain = customDomain
result.URL = "https://" + customDomain
}
}
func (s *ProjectInfraService) activateCI(ctx context.Context, result *CreateProjectResult) bool {
if s.ciProvider == nil {
result.NextSteps = append(result.NextSteps, "CI provider not configured")
return false
}
if result.GitRepoOwner == "" {
return false
}
ciRepo, err := s.ciProvider.ActivateRepo(ctx, "gitea", result.GitRepoOwner, result.GitRepoName)
if err != nil {
s.logger.Warn("failed to activate CI", "error", err)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Activate Woodpecker manually: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, result.GitRepoOwner, result.GitRepoName),
)
return false
}
s.logger.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID)
return true
}
func (s *ProjectInfraService) seedTemplate(ctx context.Context, req CreateProjectRequest, result *CreateProjectResult) bool {
if s.templateProvider == nil {
result.NextSteps = append(result.NextSteps, "Template provider not configured")
return false
}
if result.GitRepoOwner == "" {
return false
}
templateName := req.Template
if templateName == "" {
templateName = "default"
}
vars := map[string]string{
"PROJECT_NAME": req.Name,
"DOMAIN": result.Domain,
"GIT_URL": result.CloneHTTP,
}
err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars)
if err != nil {
s.logger.Warn("failed to seed repo with template", "error", err, "template", templateName)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Add template files manually (template: %s)", templateName),
)
return false
}
s.logger.Info("repo seeded with template", "template", templateName)
return true
}
// GetStatus returns the current status of a project.
func (s *ProjectInfraService) GetStatus(ctx context.Context, projectID string) (*ProjectStatus, error) {
var status ProjectStatus
err := s.db.QueryRowContext(ctx, `
SELECT
id, name, COALESCE(description, ''), COALESCE(slug, ''),
COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''),
COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''),
COALESCE(domain, ''), COALESCE(custom_domain, ''),
COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'),
COALESCE(deployment_replicas, 1)
FROM projects WHERE id = $1
`, projectID).Scan(
&status.ProjectID, &status.Name, &status.Description, &status.Slug,
&status.GitRepoOwner, &status.GitRepoName,
&status.CloneSSH, &status.CloneHTTP, &status.HTMLURL,
&status.Domain, &status.CustomDomain,
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
}
if err != nil {
return nil, fmt.Errorf("failed to get project: %w", err)
}
// Load all domains from project_domains table
if s.domainRepo != nil {
domains, err := s.domainRepo.ListByProject(ctx, projectID)
if err != nil {
s.logger.Warn("failed to load project domains", "error", err, "project", projectID)
} else {
status.Domains = domains
// Set primary domain from domains list if not set
if status.Domain == "" && len(domains) > 0 {
// Prefer custom over auto
for _, d := range domains {
if d.Type == domain.DomainTypePrimaryCustom {
status.Domain = d.Domain
break
}
}
if status.Domain == "" {
status.Domain = domains[0].Domain
}
}
}
}
if status.Domain != "" {
status.URL = "https://" + status.Domain
}
// Get live deployment status if deployer is available
if s.deployer != nil {
deployStatus, err := s.deployer.GetStatus(ctx, projectID)
if err == nil && deployStatus != nil {
status.DeploymentStatus = string(deployStatus.Status)
status.ReadyReplicas = deployStatus.ReadyReplicas
if deployStatus.URL != "" {
status.URL = deployStatus.URL
}
}
}
// Check if site is live (HTTP health check)
if status.URL != "" {
live, errMsg := checkSiteHealth(ctx, status.URL)
status.SiteLive = live
status.SiteError = errMsg
}
return &status, nil
}
// ListProjects returns all projects.
func (s *ProjectInfraService) ListProjects(ctx context.Context) ([]*ProjectStatus, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT
id, name, COALESCE(description, ''),
COALESCE(git_repo_owner, ''), COALESCE(git_repo_name, ''),
COALESCE(git_clone_ssh, ''), COALESCE(git_clone_http, ''), COALESCE(git_html_url, ''),
COALESCE(domain, ''), COALESCE(custom_domain, ''),
COALESCE(deployment_image, ''), COALESCE(deployment_status, 'none'),
COALESCE(deployment_replicas, 1)
FROM projects
ORDER BY created_at DESC
`)
if err != nil {
return nil, fmt.Errorf("failed to list projects: %w", err)
}
defer func() { _ = rows.Close() }()
var projects []*ProjectStatus
for rows.Next() {
var status ProjectStatus
err := rows.Scan(
&status.ProjectID, &status.Name, &status.Description,
&status.GitRepoOwner, &status.GitRepoName,
&status.CloneSSH, &status.CloneHTTP, &status.HTMLURL,
&status.Domain, &status.CustomDomain,
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
)
if err != nil {
continue
}
if status.Domain != "" {
status.URL = "https://" + status.Domain
}
projects = append(projects, &status)
}
return projects, nil
}
// DeleteProject removes a project and its associated resources.
func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID string) error {
s.logger.Info("deleting project", "project", projectID)
// Get project info first
status, err := s.GetStatus(ctx, projectID)
if err != nil {
return err
}
// 1. Undeploy if deployed
if s.deployer != nil && status.DeploymentStatus != "none" {
if err := s.deployer.Undeploy(ctx, projectID); err != nil {
s.logger.Warn("failed to undeploy", "error", err)
}
}
// 2. Delete all DNS records for project domains
s.deleteDNSRecords(ctx, status)
// 3. Delete all project_domains entries (CASCADE should handle this, but be explicit)
if s.domainRepo != nil {
if err := s.domainRepo.DeleteByProject(ctx, projectID); err != nil {
s.logger.Warn("failed to delete project domains", "error", err)
}
}
// 4. Delete git repo (optional - might want to keep it)
// Skipping git repo deletion for safety
// 5. Delete from database
_, err = s.db.ExecContext(ctx, `DELETE FROM projects WHERE id = $1`, projectID)
if err != nil {
return fmt.Errorf("failed to delete project from database: %w", err)
}
s.logger.Info("project deleted", "project", projectID)
return nil
}
func (s *ProjectInfraService) deleteDNSRecords(ctx context.Context, status *ProjectStatus) {
if s.dns == nil {
return
}
// Delete DNS records for all domains in project_domains table
if len(status.Domains) > 0 {
for _, pd := range status.Domains {
if pd.DNSRecordID != "" {
if err := s.dns.DeleteRecord(ctx, pd.DNSRecordID); err != nil {
s.logger.Warn("failed to delete DNS record by ID", "error", err, "domain", pd.Domain, "record_id", pd.DNSRecordID)
}
} else {
subdomain := domain.ExtractSubdomain(pd.Domain, s.defaultDomain)
if subdomain != "" {
if err := s.dns.DeleteRecordByName(ctx, pd.DNSRecordType, subdomain); err != nil {
s.logger.Warn("failed to delete DNS record by name", "error", err, "domain", pd.Domain)
}
}
}
}
} else if status.Domain != "" {
// Fallback for legacy projects without project_domains entries
subdomain := status.Name
if err := s.dns.DeleteRecordByName(ctx, "A", subdomain); err != nil {
s.logger.Warn("failed to delete DNS record", "error", err)
}
}
}
// ListTemplates returns available project templates.
func (s *ProjectInfraService) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.ListTemplates(ctx)
}
// GetTemplate returns info about a specific template.
func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.GetTemplate(ctx, name)
}

View File

@ -0,0 +1,153 @@
package service
import (
"context"
"fmt"
"github.com/orchard9/rdev/internal/domain"
)
// AddDomain adds a new domain to a project.
// For threesix.ai subdomains, it creates the DNS record automatically.
// For external domains, it returns instructions for the user to configure their DNS.
func (s *ProjectInfraService) AddDomain(ctx context.Context, req AddDomainRequest) (*domain.ProjectDomain, error) {
if s.domainRepo == nil {
return nil, fmt.Errorf("domain repository not configured")
}
// Validate domain format
if err := domain.ValidateFQDN(req.Domain); err != nil {
return nil, fmt.Errorf("invalid domain: %w", err)
}
// Check if domain already exists
exists, err := s.domainRepo.Exists(ctx, req.Domain)
if err != nil {
return nil, fmt.Errorf("failed to check domain: %w", err)
}
if exists {
return nil, domain.ErrDuplicateDomain
}
// Default record type
recordType := req.RecordType
if recordType == "" {
recordType = "A"
}
// Default domain type
domainType := req.Type
if domainType == "" {
domainType = domain.DomainTypeAlias
}
pd := &domain.ProjectDomain{
ProjectID: req.ProjectID,
Domain: req.Domain,
Type: domainType,
DNSRecordType: recordType,
Verified: false,
}
// Create DNS record for threesix.ai subdomains
if domain.IsSubdomainOf(req.Domain, s.defaultDomain) && s.dns != nil {
subdomain := domain.ExtractSubdomain(req.Domain, s.defaultDomain)
content := s.clusterIP
if recordType == "CNAME" {
// CNAME points to the project's primary auto domain
status, err := s.GetStatus(ctx, req.ProjectID)
if err == nil && status.Slug != "" {
content = status.Slug + "." + s.defaultDomain
}
}
dnsRecord, err := s.dns.CreateRecord(ctx, domain.DNSRecord{
Type: recordType,
Name: subdomain,
Content: content,
TTL: 1,
Proxied: req.Proxied,
})
if err != nil {
return nil, fmt.Errorf("failed to create DNS record: %w", err)
}
pd.DNSRecordID = dnsRecord.ID
pd.Verified = true
}
// Store in database
if err := s.domainRepo.Create(ctx, pd); err != nil {
// Rollback DNS record if database insert fails
if pd.DNSRecordID != "" && s.dns != nil {
_ = s.dns.DeleteRecord(ctx, pd.DNSRecordID)
}
return nil, fmt.Errorf("failed to store domain: %w", err)
}
s.logger.Info("domain added", "project", req.ProjectID, "domain", req.Domain, "type", domainType)
return pd, nil
}
// ListDomains returns all domains for a project.
func (s *ProjectInfraService) ListDomains(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error) {
if s.domainRepo == nil {
return nil, fmt.Errorf("domain repository not configured")
}
return s.domainRepo.ListByProject(ctx, projectID)
}
// RemoveDomain removes a domain from a project.
// Returns error if attempting to remove the primary_auto domain.
func (s *ProjectInfraService) RemoveDomain(ctx context.Context, projectID, fqdn string) error {
if s.domainRepo == nil {
return fmt.Errorf("domain repository not configured")
}
// Get the domain record
pd, err := s.domainRepo.GetByDomain(ctx, fqdn)
if err != nil {
return fmt.Errorf("failed to get domain: %w", err)
}
if pd == nil {
return domain.ErrDomainNotFound
}
// Verify it belongs to this project
if pd.ProjectID != projectID {
return fmt.Errorf("domain does not belong to project")
}
// Prevent deletion of primary_auto domain
if pd.Type == domain.DomainTypePrimaryAuto {
return fmt.Errorf("cannot remove auto-generated primary domain; delete the project instead")
}
// Delete DNS record if we have the record ID
if pd.DNSRecordID != "" && s.dns != nil {
if err := s.dns.DeleteRecord(ctx, pd.DNSRecordID); err != nil {
s.logger.Warn("failed to delete DNS record", "error", err, "domain", fqdn)
}
} else if domain.IsSubdomainOf(fqdn, s.defaultDomain) && s.dns != nil {
// Fallback: try to delete by name
subdomain := domain.ExtractSubdomain(fqdn, s.defaultDomain)
if subdomain != "" {
_ = s.dns.DeleteRecordByName(ctx, pd.DNSRecordType, subdomain)
}
}
// Delete from database
if err := s.domainRepo.Delete(ctx, pd.ID); err != nil {
return fmt.Errorf("failed to delete domain: %w", err)
}
s.logger.Info("domain removed", "project", projectID, "domain", fqdn)
return nil
}
// GetPrimaryDomain returns the primary domain for a project.
func (s *ProjectInfraService) GetPrimaryDomain(ctx context.Context, projectID string) (*domain.ProjectDomain, error) {
if s.domainRepo == nil {
return nil, fmt.Errorf("domain repository not configured")
}
return s.domainRepo.GetPrimary(ctx, projectID)
}

View File

@ -62,30 +62,31 @@ fi
log_info "Loading credentials from $SECRETS_FILE to $RDEV_API_URL" log_info "Loading credentials from $SECRETS_FILE to $RDEV_API_URL"
# Map of secret keys to categories and descriptions # Function to get category for a key (bash 3.2 compatible)
declare -A CATEGORIES=( get_category() {
["GITEA_TOKEN"]="gitea" case "$1" in
["GITEA_API_TOKEN"]="gitea" GITEA_TOKEN|GITEA_API_TOKEN|GITEA_URL) echo "gitea" ;;
["GITEA_URL"]="gitea" CLOUDFLARE_API_TOKEN|CLOUDFLARE_ZONE_ID) echo "cloudflare" ;;
["CLOUDFLARE_API_TOKEN"]="cloudflare" WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;;
["CLOUDFLARE_ZONE_ID"]="cloudflare" REGISTRY_URL) echo "registry" ;;
["WOODPECKER_URL"]="woodpecker" *) echo "other" ;;
["WOODPECKER_API_TOKEN"]="woodpecker" esac
["WOODPECKER_WEBHOOK_SECRET"]="woodpecker" }
["REGISTRY_URL"]="registry"
)
declare -A DESCRIPTIONS=( # Function to get description for a key (bash 3.2 compatible)
["GITEA_TOKEN"]="Gitea API access token" get_description() {
["GITEA_API_TOKEN"]="Gitea API access token" case "$1" in
["GITEA_URL"]="Gitea server URL" GITEA_TOKEN|GITEA_API_TOKEN) echo "Gitea API access token" ;;
["CLOUDFLARE_API_TOKEN"]="Cloudflare API token for DNS management" GITEA_URL) echo "Gitea server URL" ;;
["CLOUDFLARE_ZONE_ID"]="Cloudflare zone ID for threesix.ai" CLOUDFLARE_API_TOKEN) echo "Cloudflare API token for DNS management" ;;
["WOODPECKER_URL"]="Woodpecker CI server URL" CLOUDFLARE_ZONE_ID) echo "Cloudflare zone ID for threesix.ai" ;;
["WOODPECKER_API_TOKEN"]="Woodpecker CI API token for repo activation" WOODPECKER_URL) echo "Woodpecker CI server URL" ;;
["WOODPECKER_WEBHOOK_SECRET"]="HMAC secret for Woodpecker webhook verification" WOODPECKER_API_TOKEN) echo "Woodpecker CI API token for repo activation" ;;
["REGISTRY_URL"]="Container registry URL" WOODPECKER_WEBHOOK_SECRET) echo "HMAC secret for Woodpecker webhook verification" ;;
) REGISTRY_URL) echo "Container registry URL" ;;
*) echo "$1 credential" ;;
esac
}
# Build JSON payload from secrets file # Build JSON payload from secrets file
CREDENTIALS_JSON='{"credentials":[' CREDENTIALS_JSON='{"credentials":['
@ -93,7 +94,8 @@ FIRST=true
while IFS='=' read -r key value || [[ -n "$key" ]]; do while IFS='=' read -r key value || [[ -n "$key" ]]; do
# Skip empty lines and comments # Skip empty lines and comments
[[ -z "$key" || "$key" =~ ^# ]] && continue [[ -z "$key" ]] && continue
case "$key" in \#*) continue ;; esac
# Trim whitespace # Trim whitespace
key=$(echo "$key" | xargs) key=$(echo "$key" | xargs)
@ -108,8 +110,8 @@ while IFS='=' read -r key value || [[ -n "$key" ]]; do
fi fi
# Get category and description # Get category and description
category="${CATEGORIES[$key]:-other}" category=$(get_category "$key")
description="${DESCRIPTIONS[$key]:-$key credential}" description=$(get_description "$key")
# Add comma if not first # Add comma if not first
if [[ "$FIRST" == "true" ]]; then if [[ "$FIRST" == "true" ]]; then

65
scripts/logs.sh Executable file
View File

@ -0,0 +1,65 @@
#!/bin/bash
# rdev logs - Easy log viewing for rdev-api
# Usage: ./scripts/logs.sh [options]
#
# Options:
# -f, --follow Stream logs (like tail -f)
# -n, --lines NUM Number of lines (default: 100)
# -e, --errors Only show errors/warnings
# -a, --all Show all pods (including previous crashes)
# -p, --previous Show logs from previous container instance
# -h, --help Show this help
set -euo pipefail
export KUBECONFIG="${KUBECONFIG:-$HOME/.kube/orchard9-k3sf.yaml}"
FOLLOW=""
LINES="100"
ERRORS_ONLY=""
ALL_PODS=""
PREVIOUS=""
while [[ $# -gt 0 ]]; do
case $1 in
-f|--follow)
FOLLOW="-f"
shift
;;
-n|--lines)
LINES="$2"
shift 2
;;
-e|--errors)
ERRORS_ONLY="1"
shift
;;
-a|--all)
ALL_PODS="1"
shift
;;
-p|--previous)
PREVIOUS="--previous"
shift
;;
-h|--help)
head -14 "$0" | tail -13
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
if [[ -n "$ALL_PODS" ]]; then
# Show logs from all rdev-api pods
kubectl logs -n rdev -l app=rdev-api --tail="$LINES" $FOLLOW $PREVIOUS
elif [[ -n "$ERRORS_ONLY" ]]; then
# Filter for errors/warnings only
kubectl logs -n rdev -l app=rdev-api --tail="$LINES" $PREVIOUS | grep -iE "error|warn|fatal|panic"
else
# Default: latest pod logs
kubectl logs -n rdev -l app=rdev-api --tail="$LINES" $FOLLOW $PREVIOUS
fi

88
scripts/release.sh Executable file
View File

@ -0,0 +1,88 @@
#!/bin/bash
set -euo pipefail
# rdev release script
# Usage: ./scripts/release.sh <version> "<changelog message>"
# Example: ./scripts/release.sh v0.8.1 "Fix worker ID config bug"
VERSION="${1:-}"
MESSAGE="${2:-}"
if [[ -z "$VERSION" || -z "$MESSAGE" ]]; then
echo "Usage: $0 <version> \"<changelog message>\""
echo "Example: $0 v0.8.1 \"Fix worker ID config bug\""
exit 1
fi
# Ensure version starts with 'v'
if [[ ! "$VERSION" =~ ^v ]]; then
VERSION="v$VERSION"
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
echo "=== rdev release $VERSION ==="
echo ""
# 1. Upsert changelog entry
CHANGELOG_DIR="$REPO_ROOT/changelog"
mkdir -p "$CHANGELOG_DIR"
DATE=$(date +%Y-%m-%d)
CHANGELOG_FILE="$CHANGELOG_DIR/$VERSION.md"
echo "📝 Writing changelog: $CHANGELOG_FILE"
cat > "$CHANGELOG_FILE" << EOF
# $VERSION
**Released:** $DATE
## Changes
$MESSAGE
---
**Image:** \`ghcr.io/orchard9/rdev-api:$VERSION\`
EOF
# 2. Update deployment YAML with new version
echo "📦 Updating deployment to $VERSION"
sed -i.bak "s|image: ghcr.io/orchard9/rdev-api:v[0-9.]*|image: ghcr.io/orchard9/rdev-api:$VERSION|g" \
"$REPO_ROOT/deployments/k8s/base/rdev-api.yaml"
rm -f "$REPO_ROOT/deployments/k8s/base/rdev-api.yaml.bak"
# 3. Commit and push
echo "📤 Committing and pushing"
git add changelog/ deployments/k8s/base/rdev-api.yaml
git commit -m "release: $VERSION - $MESSAGE"
git push origin main
# 4. Build for linux/amd64
echo "🔨 Building binary for linux/amd64"
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=$VERSION" -o rdev-api ./cmd/rdev-api
echo "🐳 Building container image"
docker buildx build --platform linux/amd64 -f Dockerfile.api.prebuild -t "ghcr.io/orchard9/rdev-api:$VERSION" --load .
# 5. Push to ghcr.io
echo "🚀 Pushing to ghcr.io"
docker push "ghcr.io/orchard9/rdev-api:$VERSION"
# 6. Tag the commit
echo "🏷️ Tagging commit as $VERSION"
git tag -a "$VERSION" -m "$MESSAGE"
git push origin "$VERSION"
# Cleanup binary
rm -f rdev-api
echo ""
echo "=== Release $VERSION complete ==="
echo ""
echo "To deploy to k3s:"
echo " export KUBECONFIG=~/.kube/orchard9-k3sf.yaml"
echo " kubectl apply -f deployments/k8s/base/rdev-api.yaml"
echo " kubectl rollout restart -n rdev deployment/rdev-api"