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:
parent
89b832ce0d
commit
c86516c53a
60
CLAUDE.md
60
CLAUDE.md
@ -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) |
|
||||
| **Understand hexagonal architecture** | [backend/hexagonal.md](.claude/guides/backend/hexagonal.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) |
|
||||
| **Database / migrations** | [ops/database.md](.claude/guides/ops/database.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
|
||||
|
||||
```bash
|
||||
# Set kubeconfig (REQUIRED)
|
||||
# Required env vars (add to ~/.zshrc)
|
||||
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
|
||||
go run ./cmd/rdev-api
|
||||
@ -44,18 +47,33 @@ go run ./cmd/rdev-api
|
||||
# Run tests
|
||||
go test ./...
|
||||
|
||||
# Deploy
|
||||
kubectl apply -k deployments/k8s/base
|
||||
# Release new version
|
||||
./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
|
||||
kubectl get pods -n rdev
|
||||
|
||||
# Check workers
|
||||
kubectl get pods -n rdev -l rdev.orchard9.ai/role=worker
|
||||
# View logs
|
||||
./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
|
||||
./scripts/load-credentials.sh # localhost
|
||||
./scripts/load-credentials.sh https://rdev.example.com # remote
|
||||
# Shell aliases (after source ~/.zshrc)
|
||||
rdev-logs # Last 100 lines
|
||||
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
|
||||
@ -81,7 +99,12 @@ pkg/api/ # HTTP framework (app, responses)
|
||||
deployments/k8s/ # Kustomize manifests
|
||||
└── base/templates/ # Project templates
|
||||
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
|
||||
@ -95,17 +118,16 @@ scripts/ # Operational scripts
|
||||
- **Webhooks**: Event subscriptions with retry delivery
|
||||
- **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:
|
||||
|
||||
| Gap | Status | Description |
|
||||
|-----|--------|-------------|
|
||||
| Woodpecker Auto-Activation | Planned | Auto-enable CI on project creation |
|
||||
| Project Templates | Planned | Seed repos with .woodpecker.yml, .claude/ |
|
||||
| Work Queue | Planned | Task queue for worker pool |
|
||||
| Worker Pool | Planned | Shared claudebox workers (3-5 pods) |
|
||||
| Bot Communication | Planned | Webhook callbacks on task completion |
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Woodpecker Auto-Activation | **Done** | CI enabled on project creation via SDK |
|
||||
| Project Templates | **Done** | Embedded templates (astro-landing, go-api, default) |
|
||||
| Work Queue | **Done** | PostgreSQL with atomic dequeue, retry logic |
|
||||
| Multi-Provider Agents | **Done** | Claude Code + OpenCode via registry |
|
||||
| Webhooks | **Done** | Event dispatcher with retry delivery |
|
||||
| Embedded Worker | **Done** | Goroutine in rdev-api, polls queue |
|
||||
| Build Orchestration | Planned | Structured build specs via API |
|
||||
|
||||
## Constraints
|
||||
|
||||
@ -62,6 +62,9 @@ import (
|
||||
"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() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
@ -295,18 +298,9 @@ func main() {
|
||||
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
||||
workHandler := handlers.NewWorkHandler(workService)
|
||||
|
||||
// Initialize infrastructure handler (for threesix.ai git/deploy/dns/ci)
|
||||
infraHandler := handlers.NewInfrastructureHandler(
|
||||
giteaClient,
|
||||
dnsClient,
|
||||
deployerAdapter,
|
||||
projectRepo,
|
||||
woodpeckerClient,
|
||||
handlers.InfrastructureConfig{
|
||||
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
|
||||
DefaultDomain: infraCfg.DefaultDomain,
|
||||
},
|
||||
)
|
||||
// Initialize domain and slug repositories
|
||||
projectDomainRepo := postgres.NewProjectDomainRepository(database.DB)
|
||||
slugGenerator := postgres.NewSlugRepository(database.DB)
|
||||
|
||||
// Initialize project infrastructure service (orchestrates full project lifecycle)
|
||||
projectInfraService := service.NewProjectInfraService(
|
||||
@ -316,6 +310,8 @@ func main() {
|
||||
deployerAdapter,
|
||||
woodpeckerClient, // CI provider for auto-activating repos
|
||||
templateProvider, // Template provider for seeding repos
|
||||
projectDomainRepo,
|
||||
slugGenerator,
|
||||
service.ProjectInfraConfig{
|
||||
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
|
||||
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
|
||||
projectMgmtHandler := handlers.NewProjectManagementHandler(projectInfraService, logger)
|
||||
|
||||
@ -400,15 +414,13 @@ func main() {
|
||||
})
|
||||
}
|
||||
buildExecutor := worker.NewBuildExecutor(agentRegistry, gitOps, logger)
|
||||
workerCfg := worker.DefaultWorkExecutorConfig()
|
||||
workerCfg.Logger = logger
|
||||
workExecutor := worker.NewWorkExecutor(
|
||||
workerService,
|
||||
workService,
|
||||
buildExecutor,
|
||||
&worker.WorkExecutorConfig{
|
||||
PollPeriod: 5 * time.Second,
|
||||
HeartbeatPeriod: 30 * time.Second,
|
||||
Logger: logger,
|
||||
},
|
||||
workerCfg,
|
||||
)
|
||||
if err := workExecutor.Start(); err != nil {
|
||||
logger.Error("failed to start work executor", "error", err)
|
||||
@ -463,6 +475,7 @@ func main() {
|
||||
})
|
||||
|
||||
logger.Info("rdev-api starting",
|
||||
"version", version,
|
||||
"port", cfg.Port,
|
||||
"db_host", cfg.DBHost,
|
||||
"admin_key_set", cfg.AdminKey != "",
|
||||
|
||||
333
cookbooks/scripts/landing-test.sh
Executable file
333
cookbooks/scripts/landing-test.sh
Executable 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
26
go.mod
@ -14,7 +14,7 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
|
||||
go.opentelemetry.io/otel/sdk 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/apimachinery 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/cenkalti/backoff/v5 v5.0.3 // 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/emicklei/go-restful/v3 v3.12.2 // 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/stdr v1.2.2 // 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/google/gnostic-models v0.7.0 // 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/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/common v0.66.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.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/term v0.39.0 // indirect
|
||||
golang.org/x/text v0.33.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/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
63
go.sum
63
go.sum
@ -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/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.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.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/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
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-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/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU=
|
||||
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
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/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
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/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/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/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
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/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.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/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
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/v2 v2.8.3/go.mod h1:nvdmUnQJMqm8UzJOlJ50MYYq/uv8oyOqhBBr7SdoNPw=
|
||||
go.woodpecker-ci.org/woodpecker/v3 v3.13.0 h1:XSokm3nwTbUJTUgf7uQ1zh/BYj3G6n6cTFprQUbpw8M=
|
||||
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/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
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-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.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
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-20190412213103-97732733099d/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.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
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/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/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/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@ -53,7 +53,7 @@ func (c *Client) CreateRepo(ctx context.Context, name, description string, priva
|
||||
Name: name,
|
||||
Description: description,
|
||||
Private: private,
|
||||
AutoInit: true,
|
||||
AutoInit: false, // Empty repo - template seeding will create all files
|
||||
DefaultBranch: "main",
|
||||
}
|
||||
|
||||
|
||||
315
internal/adapter/postgres/project_domain.go
Normal file
315
internal/adapter/postgres/project_domain.go
Normal 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
|
||||
}
|
||||
@ -114,13 +114,24 @@ func (p *Provider) SeedRepo(ctx context.Context, owner, repo, templateName strin
|
||||
// Create file in repo via Gitea API
|
||||
// Gitea expects base64-encoded content
|
||||
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,
|
||||
FileOptions: gitea.FileOptions{
|
||||
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 {
|
||||
return fmt.Errorf("failed to create file %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ import (
|
||||
"strings"
|
||||
"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/port"
|
||||
@ -117,20 +117,30 @@ func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*
|
||||
|
||||
fullName := owner + "/" + repo
|
||||
|
||||
// First, sync the repo list to ensure we have the latest from forge
|
||||
// This is important for newly created repos
|
||||
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)
|
||||
}
|
||||
|
||||
// Retry loop for newly created repos - Woodpecker sync from Gitea is async
|
||||
// and can take 30+ seconds for newly created repos to appear with valid metadata
|
||||
var targetRepo *woodpecker.Repo
|
||||
var lastErr error
|
||||
maxAttempts := 15
|
||||
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
|
||||
@ -139,12 +149,45 @@ func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*
|
||||
}
|
||||
|
||||
if targetRepo == nil {
|
||||
// Repo not found - try to look it up directly
|
||||
// Repo not found in list - try direct lookup
|
||||
targetRepo, err = c.client.RepoLookup(fullName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("repo not found in Woodpecker: %s (ensure forge is synced)", fullName)
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("%w (tried %d times)", lastErr, maxAttempts)
|
||||
}
|
||||
|
||||
// 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 targetRepo.IsActive {
|
||||
@ -158,7 +201,7 @@ func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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:
|
||||
}
|
||||
|
||||
repos, err := c.client.RepoList()
|
||||
repos, err := c.client.RepoList(woodpecker.RepoListOptions{})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
pipelines, err := c.client.PipelineList(r.ID)
|
||||
pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{})
|
||||
if err != nil {
|
||||
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.
|
||||
func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo {
|
||||
// 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,
|
||||
CloneURL: r.Clone,
|
||||
Active: r.IsActive,
|
||||
AllowPullRequests: r.AllowPullRequests,
|
||||
Visibility: r.Visibility, // Already a string in SDK
|
||||
AllowPullRequests: r.AllowPull, // Renamed in SDK v3
|
||||
Visibility: r.Visibility,
|
||||
}
|
||||
}
|
||||
|
||||
99
internal/db/migrations/013_project_domains.sql
Normal file
99
internal/db/migrations/013_project_domains.sql
Normal 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';
|
||||
@ -62,6 +62,10 @@ var (
|
||||
ErrWebhookNotFound = errors.New("webhook not found")
|
||||
ErrInvalidWebhook = errors.New("invalid webhook configuration")
|
||||
|
||||
// Domain errors
|
||||
ErrDuplicateDomain = errors.New("domain already exists")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
|
||||
// Audit errors
|
||||
ErrAuditNotFound = errors.New("audit log entry not found")
|
||||
|
||||
|
||||
152
internal/domain/project_domain.go
Normal file
152
internal/domain/project_domain.go
Normal 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 ""
|
||||
}
|
||||
212
internal/domain/project_domain_test.go
Normal file
212
internal/domain/project_domain_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
40
internal/handlers/domain_service_adapter.go
Normal file
40
internal/handlers/domain_service_adapter.go
Normal 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)
|
||||
}
|
||||
@ -14,6 +14,23 @@ import (
|
||||
"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.
|
||||
type InfrastructureHandler struct {
|
||||
gitRepo port.GitRepository
|
||||
@ -21,6 +38,7 @@ type InfrastructureHandler struct {
|
||||
deployer port.Deployer
|
||||
projects port.ProjectRepository
|
||||
ciProvider port.CIProvider
|
||||
domainService DomainService
|
||||
|
||||
// Config
|
||||
defaultGitOwner string
|
||||
@ -50,6 +68,7 @@ func NewInfrastructureHandler(
|
||||
deployer port.Deployer,
|
||||
projects port.ProjectRepository,
|
||||
ciProvider port.CIProvider,
|
||||
domainService DomainService,
|
||||
cfg InfrastructureConfig,
|
||||
) *InfrastructureHandler {
|
||||
return &InfrastructureHandler{
|
||||
@ -58,6 +77,7 @@ func NewInfrastructureHandler(
|
||||
deployer: deployer,
|
||||
projects: projects,
|
||||
ciProvider: ciProvider,
|
||||
domainService: domainService,
|
||||
defaultGitOwner: cfg.DefaultGitOwner,
|
||||
defaultDomain: cfg.DefaultDomain,
|
||||
clusterIP: cfg.ClusterIP,
|
||||
|
||||
@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -15,22 +16,23 @@ import (
|
||||
|
||||
// DomainAliasRequest is the request body for POST /projects/{id}/domains.
|
||||
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")
|
||||
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.
|
||||
type DomainAliasResponse struct {
|
||||
// DomainResponse is the response for domain operations.
|
||||
type DomainResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
TTL int `json:"ttl"`
|
||||
Proxied bool `json:"proxied"`
|
||||
Type string `json:"type"` // DomainType: primary_auto, primary_custom, alias
|
||||
RecordType string `json:"record_type"` // DNS record type: A, CNAME
|
||||
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
|
||||
func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
@ -42,66 +44,38 @@ func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
if h.dns == nil {
|
||||
api.WriteInternalError(w, r, "DNS provider not configured")
|
||||
if h.domainService == nil {
|
||||
api.WriteInternalError(w, r, "domain service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// List all A records and find ones matching this project
|
||||
aRecords, err := h.dns.ListRecords(ctx, "A")
|
||||
domains, err := h.domainService.ListDomains(ctx, projectID)
|
||||
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
|
||||
}
|
||||
|
||||
// Also list CNAME records
|
||||
cnameRecords, err := h.dns.ListRecords(ctx, "CNAME")
|
||||
if err != nil {
|
||||
api.WriteInternalError(w, r, "failed to list DNS records")
|
||||
return
|
||||
}
|
||||
|
||||
// Filter records that belong to this project:
|
||||
// - Primary: {projectID}.{defaultDomain}
|
||||
// - Aliases: any record pointing to the cluster IP or the project's primary domain
|
||||
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,
|
||||
// Convert to response format
|
||||
response := make([]DomainResponse, 0, len(domains))
|
||||
for _, d := range domains {
|
||||
response = append(response, DomainResponse{
|
||||
ID: d.ID,
|
||||
Domain: d.Domain,
|
||||
Type: string(d.Type),
|
||||
RecordType: d.DNSRecordType,
|
||||
Verified: d.Verified,
|
||||
CreatedAt: d.CreatedAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
"project_id": projectID,
|
||||
"domains": domains,
|
||||
"total": len(domains),
|
||||
"domains": response,
|
||||
"total": len(response),
|
||||
})
|
||||
}
|
||||
|
||||
// AddDomainAlias adds a DNS alias for a project.
|
||||
// AddDomainAlias adds a domain to a project.
|
||||
// POST /projects/{id}/domains
|
||||
func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
@ -113,8 +87,8 @@ func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
if h.dns == nil {
|
||||
api.WriteInternalError(w, r, "DNS provider not configured")
|
||||
if h.domainService == nil {
|
||||
api.WriteInternalError(w, r, "domain service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
@ -139,57 +113,46 @@ func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Re
|
||||
return
|
||||
}
|
||||
|
||||
// Determine content
|
||||
content := req.Content
|
||||
if content == "" {
|
||||
switch recordType {
|
||||
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 domain type
|
||||
domainType := domain.DomainTypeAlias
|
||||
if req.DomainType == "primary_custom" {
|
||||
domainType = domain.DomainTypePrimaryCustom
|
||||
}
|
||||
|
||||
// Determine the DNS name
|
||||
// If domain is a full FQDN under our zone, extract the subdomain for the API call
|
||||
dnsName := req.Domain
|
||||
if isSubdomain(req.Domain, h.defaultDomain) {
|
||||
dnsName = getSubdomain(req.Domain, h.defaultDomain)
|
||||
}
|
||||
|
||||
record, err := h.dns.CreateRecord(ctx, domain.DNSRecord{
|
||||
Type: recordType,
|
||||
Name: dnsName,
|
||||
Content: content,
|
||||
TTL: 1,
|
||||
pd, err := h.domainService.AddDomain(ctx, DomainAddRequest{
|
||||
ProjectID: projectID,
|
||||
Domain: req.Domain,
|
||||
Type: domainType,
|
||||
RecordType: recordType,
|
||||
Proxied: req.Proxied,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
note := "Domain alias configured"
|
||||
note := "Domain configured"
|
||||
if !isSubdomain(req.Domain, h.defaultDomain) && recordType == "A" {
|
||||
note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP)
|
||||
}
|
||||
|
||||
api.WriteCreated(w, r, map[string]any{
|
||||
"id": pd.ID,
|
||||
"project": projectID,
|
||||
"domain": record.Name,
|
||||
"type": record.Type,
|
||||
"content": record.Content,
|
||||
"domain": pd.Domain,
|
||||
"type": string(pd.Type),
|
||||
"record_type": pd.DNSRecordType,
|
||||
"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}
|
||||
func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
@ -207,39 +170,24 @@ func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
if h.dns == nil {
|
||||
api.WriteInternalError(w, r, "DNS provider not configured")
|
||||
if h.domainService == nil {
|
||||
api.WriteInternalError(w, r, "domain service not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent deleting the project's primary domain through this endpoint
|
||||
primaryDomain := projectID + "." + h.defaultDomain
|
||||
if aliasDomain == primaryDomain {
|
||||
api.WriteBadRequest(w, r, "cannot remove primary project domain through alias endpoint; use DELETE /project/{name} instead")
|
||||
err := h.domainService.RemoveDomain(ctx, projectID, aliasDomain)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrDomainNotFound) {
|
||||
api.WriteNotFound(w, r, fmt.Sprintf("domain not found: %s", aliasDomain))
|
||||
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))
|
||||
// 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
|
||||
}
|
||||
|
||||
// Delete whichever record(s) exist
|
||||
if aRecord != nil {
|
||||
_ = h.dns.DeleteRecordByName(ctx, "A", dnsName)
|
||||
}
|
||||
if cnameRecord != nil {
|
||||
_ = h.dns.DeleteRecordByName(ctx, "CNAME", dnsName)
|
||||
api.WriteInternalError(w, r, fmt.Sprintf("failed to remove domain: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]string{
|
||||
@ -248,13 +196,3 @@ func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http
|
||||
"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
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -11,18 +12,79 @@ import (
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
)
|
||||
|
||||
func TestInfrastructureHandler_ListDomains(t *testing.T) {
|
||||
t.Run("returns matching A records", func(t *testing.T) {
|
||||
_, _, dns, _, router := setupInfraHandler()
|
||||
|
||||
// Add records — one matching the project, one unrelated
|
||||
dns.records["landing.threesix.ai"] = &domain.DNSRecord{
|
||||
ID: "rec-1", Type: "A", Name: "landing.threesix.ai",
|
||||
Content: "208.122.204.172", TTL: 1,
|
||||
// mockDomainService implements DomainService for testing.
|
||||
type mockDomainService struct {
|
||||
domains map[string][]*domain.ProjectDomain // projectID -> domains
|
||||
err error
|
||||
}
|
||||
dns.records["other.threesix.ai"] = &domain.DNSRecord{
|
||||
ID: "rec-2", Type: "A", Name: "other.threesix.ai",
|
||||
Content: "208.122.204.172", TTL: 1,
|
||||
|
||||
func newMockDomainService() *mockDomainService {
|
||||
return &mockDomainService{
|
||||
domains: make(map[string][]*domain.ProjectDomain),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return domain.ErrDomainNotFound
|
||||
}
|
||||
|
||||
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)
|
||||
@ -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())
|
||||
}
|
||||
|
||||
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
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
@ -71,12 +102,12 @@ func TestInfrastructureHandler_ListDomains(t *testing.T) {
|
||||
data := resp["data"].(map[string]any)
|
||||
total := int(data["total"].(float64))
|
||||
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) {
|
||||
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, InfrastructureConfig{
|
||||
t.Run("domain service not configured", func(t *testing.T) {
|
||||
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{
|
||||
DefaultGitOwner: "threesix",
|
||||
DefaultDomain: "threesix.ai",
|
||||
ClusterIP: "208.122.204.172",
|
||||
@ -95,8 +126,8 @@ func TestInfrastructureHandler_ListDomains(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
|
||||
t.Run("add A record alias", func(t *testing.T) {
|
||||
_, _, dns, _, router := setupInfraHandler()
|
||||
t.Run("add domain alias", func(t *testing.T) {
|
||||
_, domainSvc, router := setupInfraDomainHandler()
|
||||
|
||||
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai"})
|
||||
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 {
|
||||
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
if len(dns.records) != 1 {
|
||||
t.Errorf("DNS records = %d, want 1", len(dns.records))
|
||||
if len(domainSvc.domains["landing"]) != 1 {
|
||||
t.Errorf("domains = %d, want 1", len(domainSvc.domains["landing"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add CNAME alias", func(t *testing.T) {
|
||||
_, _, dns, _, router := setupInfraHandler()
|
||||
t.Run("add primary custom domain", func(t *testing.T) {
|
||||
_, domainSvc, router := setupInfraDomainHandler()
|
||||
|
||||
body, _ := json.Marshal(DomainAliasRequest{
|
||||
Domain: "www.threesix.ai",
|
||||
Type: "CNAME",
|
||||
Domain: "mysite.threesix.ai",
|
||||
DomainType: "primary_custom",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/projects/landing/domains", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
@ -125,19 +156,16 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
// CNAME should target landing.threesix.ai
|
||||
for _, r := range dns.records {
|
||||
if r.Type != "CNAME" {
|
||||
t.Errorf("type = %s, want CNAME", r.Type)
|
||||
}
|
||||
if r.Content != "landing.threesix.ai" {
|
||||
t.Errorf("content = %s, want landing.threesix.ai", r.Content)
|
||||
if len(domainSvc.domains["landing"]) != 1 {
|
||||
t.Errorf("domains = %d, want 1", len(domainSvc.domains["landing"]))
|
||||
}
|
||||
if domainSvc.domains["landing"][0].Type != domain.DomainTypePrimaryCustom {
|
||||
t.Errorf("type = %s, want primary_custom", domainSvc.domains["landing"][0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid type", func(t *testing.T) {
|
||||
_, _, _, _, router := setupInfraHandler()
|
||||
_, _, router := setupInfraDomainHandler()
|
||||
|
||||
body, _ := json.Marshal(DomainAliasRequest{Domain: "www.threesix.ai", Type: "MX"})
|
||||
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) {
|
||||
_, _, _, _, router := setupInfraHandler()
|
||||
_, _, router := setupInfraDomainHandler()
|
||||
|
||||
body, _ := json.Marshal(DomainAliasRequest{})
|
||||
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) {
|
||||
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, InfrastructureConfig{
|
||||
t.Run("domain service not configured", func(t *testing.T) {
|
||||
h := NewInfrastructureHandler(nil, nil, nil, nil, nil, nil, InfrastructureConfig{
|
||||
DefaultGitOwner: "threesix",
|
||||
DefaultDomain: "threesix.ai",
|
||||
ClusterIP: "208.122.204.172",
|
||||
@ -184,10 +212,9 @@ func TestInfrastructureHandler_AddDomainAlias(t *testing.T) {
|
||||
|
||||
func TestInfrastructureHandler_RemoveDomainAlias(t *testing.T) {
|
||||
t.Run("removes alias", func(t *testing.T) {
|
||||
_, _, dns, _, router := setupInfraHandler()
|
||||
dns.records["www"] = &domain.DNSRecord{
|
||||
ID: "rec-www", Type: "A", Name: "www",
|
||||
Content: "208.122.204.172",
|
||||
_, domainSvc, router := setupInfraDomainHandler()
|
||||
domainSvc.domains["landing"] = []*domain.ProjectDomain{
|
||||
{ID: 1, ProjectID: "landing", Domain: "www.threesix.ai", Type: domain.DomainTypeAlias, DNSRecordType: "A"},
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("status = %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
if len(domainSvc.domains["landing"]) != 0 {
|
||||
t.Errorf("domains = %d, want 0", len(domainSvc.domains["landing"]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
_, _, dns, _, router := setupInfraHandler()
|
||||
dns.err = nil // No records stored
|
||||
_, _, router := setupInfraDomainHandler()
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/projects/landing/domains/nonexistent.threesix.ai", nil)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,8 +67,15 @@ func (m *mockCIProvider) GetPipeline(_ context.Context, owner, repo string, numb
|
||||
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 {
|
||||
h := NewInfrastructureHandler(nil, nil, nil, nil, ci, InfrastructureConfig{
|
||||
h := NewInfrastructureHandler(nil, nil, nil, nil, ci, nil, InfrastructureConfig{
|
||||
DefaultGitOwner: "threesix",
|
||||
DefaultDomain: "threesix.ai",
|
||||
})
|
||||
|
||||
@ -245,7 +245,7 @@ func setupInfraHandler() (*InfrastructureHandler, *mockGitRepository, *mockDNSPr
|
||||
git := newMockGitRepository()
|
||||
dns := newMockDNSProvider()
|
||||
deployer := newMockDeployer()
|
||||
h := NewInfrastructureHandler(git, dns, deployer, nil, nil, InfrastructureConfig{
|
||||
h := NewInfrastructureHandler(git, dns, deployer, nil, nil, nil, InfrastructureConfig{
|
||||
DefaultGitOwner: "threesix",
|
||||
DefaultDomain: "threesix.ai",
|
||||
ClusterIP: "208.122.204.172",
|
||||
@ -298,7 +298,7 @@ func TestInfrastructureHandler_CreateRepo(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()
|
||||
h.Mount(r)
|
||||
|
||||
@ -392,7 +392,7 @@ func TestInfrastructureHandler_Deploy(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()
|
||||
h.Mount(r)
|
||||
|
||||
|
||||
@ -57,7 +57,9 @@ type CreateRequest struct {
|
||||
// Create creates a new project with git repo and DNS.
|
||||
// POST /project
|
||||
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()
|
||||
|
||||
if h.infraService == nil {
|
||||
|
||||
@ -35,4 +35,8 @@ type CIProvider interface {
|
||||
|
||||
// GetPipeline returns a specific pipeline execution by number.
|
||||
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)
|
||||
}
|
||||
|
||||
57
internal/port/project_domain_repository.go
Normal file
57
internal/port/project_domain_repository.go
Normal 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)
|
||||
}
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
@ -28,6 +29,8 @@ type ProjectInfraService struct {
|
||||
deployer port.Deployer
|
||||
ciProvider port.CIProvider
|
||||
templateProvider port.TemplateProvider
|
||||
domainRepo port.ProjectDomainRepository
|
||||
slugGenerator port.SlugGenerator
|
||||
logger *slog.Logger
|
||||
|
||||
// Config
|
||||
@ -52,6 +55,8 @@ func NewProjectInfraService(
|
||||
deployer port.Deployer,
|
||||
ciProvider port.CIProvider,
|
||||
templateProvider port.TemplateProvider,
|
||||
domainRepo port.ProjectDomainRepository,
|
||||
slugGenerator port.SlugGenerator,
|
||||
cfg ProjectInfraConfig,
|
||||
) *ProjectInfraService {
|
||||
logger := cfg.Logger
|
||||
@ -65,6 +70,8 @@ func NewProjectInfraService(
|
||||
deployer: deployer,
|
||||
ciProvider: ciProvider,
|
||||
templateProvider: templateProvider,
|
||||
domainRepo: domainRepo,
|
||||
slugGenerator: slugGenerator,
|
||||
logger: logger,
|
||||
defaultGitOwner: cfg.DefaultGitOwner,
|
||||
defaultDomain: cfg.DefaultDomain,
|
||||
@ -78,6 +85,7 @@ type CreateProjectRequest struct {
|
||||
Description string
|
||||
Private bool
|
||||
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.
|
||||
@ -85,6 +93,7 @@ type CreateProjectResult struct {
|
||||
ProjectID string
|
||||
Name string
|
||||
Description string
|
||||
Slug string // Auto-generated unique identifier
|
||||
|
||||
// Git info
|
||||
GitRepoOwner string
|
||||
@ -93,162 +102,23 @@ type CreateProjectResult struct {
|
||||
CloneHTTP string
|
||||
HTMLURL string
|
||||
|
||||
// Domain info
|
||||
// Domain info (primary domain for backward compatibility)
|
||||
Domain string
|
||||
URL string
|
||||
|
||||
// All domains associated with the project
|
||||
Domains []*domain.ProjectDomain
|
||||
|
||||
// Next steps
|
||||
NextSteps []string
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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.
|
||||
// ProjectStatus represents the current status of a project.
|
||||
type ProjectStatus struct {
|
||||
ProjectID string
|
||||
Name string
|
||||
Description string
|
||||
Slug string
|
||||
|
||||
// Git
|
||||
GitRepoOwner string
|
||||
@ -257,154 +127,59 @@ type ProjectStatus struct {
|
||||
CloneHTTP string
|
||||
HTMLURL string
|
||||
|
||||
// Domain
|
||||
// Domain (primary for backward compatibility)
|
||||
Domain string
|
||||
CustomDomain string
|
||||
URL string
|
||||
|
||||
// All domains associated with the project
|
||||
Domains []*domain.ProjectDomain
|
||||
|
||||
// Deployment
|
||||
DeploymentImage string
|
||||
DeploymentStatus string
|
||||
DeploymentReplicas 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.
|
||||
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(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.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)
|
||||
// AddDomainRequest contains parameters for adding a domain to a project.
|
||||
type AddDomainRequest struct {
|
||||
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
|
||||
}
|
||||
|
||||
// checkSiteHealth performs an HTTP GET request to check if a site is live.
|
||||
// Returns (true, "") if the site returns HTTP 200, otherwise (false, errorMessage).
|
||||
func checkSiteHealth(ctx context.Context, url string) (bool, string) {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, 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 != "" {
|
||||
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
|
||||
`)
|
||||
resp, err := client.Do(req)
|
||||
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
|
||||
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)
|
||||
// Accept 2xx and 3xx status codes as "live"
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
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 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)
|
||||
return false, fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
490
internal/service/project_infra_crud.go
Normal file
490
internal/service/project_infra_crud.go
Normal 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)
|
||||
}
|
||||
153
internal/service/project_infra_domains.go
Normal file
153
internal/service/project_infra_domains.go
Normal 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)
|
||||
}
|
||||
@ -62,30 +62,31 @@ fi
|
||||
|
||||
log_info "Loading credentials from $SECRETS_FILE to $RDEV_API_URL"
|
||||
|
||||
# Map of secret keys to categories and descriptions
|
||||
declare -A CATEGORIES=(
|
||||
["GITEA_TOKEN"]="gitea"
|
||||
["GITEA_API_TOKEN"]="gitea"
|
||||
["GITEA_URL"]="gitea"
|
||||
["CLOUDFLARE_API_TOKEN"]="cloudflare"
|
||||
["CLOUDFLARE_ZONE_ID"]="cloudflare"
|
||||
["WOODPECKER_URL"]="woodpecker"
|
||||
["WOODPECKER_API_TOKEN"]="woodpecker"
|
||||
["WOODPECKER_WEBHOOK_SECRET"]="woodpecker"
|
||||
["REGISTRY_URL"]="registry"
|
||||
)
|
||||
# Function to get category for a key (bash 3.2 compatible)
|
||||
get_category() {
|
||||
case "$1" in
|
||||
GITEA_TOKEN|GITEA_API_TOKEN|GITEA_URL) echo "gitea" ;;
|
||||
CLOUDFLARE_API_TOKEN|CLOUDFLARE_ZONE_ID) echo "cloudflare" ;;
|
||||
WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;;
|
||||
REGISTRY_URL) echo "registry" ;;
|
||||
*) echo "other" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
declare -A DESCRIPTIONS=(
|
||||
["GITEA_TOKEN"]="Gitea API access token"
|
||||
["GITEA_API_TOKEN"]="Gitea API access token"
|
||||
["GITEA_URL"]="Gitea server URL"
|
||||
["CLOUDFLARE_API_TOKEN"]="Cloudflare API token for DNS management"
|
||||
["CLOUDFLARE_ZONE_ID"]="Cloudflare zone ID for threesix.ai"
|
||||
["WOODPECKER_URL"]="Woodpecker CI server URL"
|
||||
["WOODPECKER_API_TOKEN"]="Woodpecker CI API token for repo activation"
|
||||
["WOODPECKER_WEBHOOK_SECRET"]="HMAC secret for Woodpecker webhook verification"
|
||||
["REGISTRY_URL"]="Container registry URL"
|
||||
)
|
||||
# Function to get description for a key (bash 3.2 compatible)
|
||||
get_description() {
|
||||
case "$1" in
|
||||
GITEA_TOKEN|GITEA_API_TOKEN) echo "Gitea API access token" ;;
|
||||
GITEA_URL) echo "Gitea server URL" ;;
|
||||
CLOUDFLARE_API_TOKEN) echo "Cloudflare API token for DNS management" ;;
|
||||
CLOUDFLARE_ZONE_ID) echo "Cloudflare zone ID for threesix.ai" ;;
|
||||
WOODPECKER_URL) echo "Woodpecker CI server URL" ;;
|
||||
WOODPECKER_API_TOKEN) echo "Woodpecker CI API token for repo activation" ;;
|
||||
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
|
||||
CREDENTIALS_JSON='{"credentials":['
|
||||
@ -93,7 +94,8 @@ FIRST=true
|
||||
|
||||
while IFS='=' read -r key value || [[ -n "$key" ]]; do
|
||||
# Skip empty lines and comments
|
||||
[[ -z "$key" || "$key" =~ ^# ]] && continue
|
||||
[[ -z "$key" ]] && continue
|
||||
case "$key" in \#*) continue ;; esac
|
||||
|
||||
# Trim whitespace
|
||||
key=$(echo "$key" | xargs)
|
||||
@ -108,8 +110,8 @@ while IFS='=' read -r key value || [[ -n "$key" ]]; do
|
||||
fi
|
||||
|
||||
# Get category and description
|
||||
category="${CATEGORIES[$key]:-other}"
|
||||
description="${DESCRIPTIONS[$key]:-$key credential}"
|
||||
category=$(get_category "$key")
|
||||
description=$(get_description "$key")
|
||||
|
||||
# Add comma if not first
|
||||
if [[ "$FIRST" == "true" ]]; then
|
||||
|
||||
65
scripts/logs.sh
Executable file
65
scripts/logs.sh
Executable 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
88
scripts/release.sh
Executable 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"
|
||||
Loading…
Reference in New Issue
Block a user