diff --git a/CLAUDE.md b/CLAUDE.md index 3d03459..bcdb0c7 100644 --- a/CLAUDE.md +++ b/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="" # 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 +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 diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 7607b6c..d8dcc07 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -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 != "", diff --git a/cookbooks/scripts/landing-test.sh b/cookbooks/scripts/landing-test.sh new file mode 100755 index 0000000..7ea8253 --- /dev/null +++ b/cookbooks/scripts/landing-test.sh @@ -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 diff --git a/go.mod b/go.mod index ec83ef2..786d36b 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index c4b90a2..039e99a 100644 --- a/go.sum +++ b/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= diff --git a/internal/adapter/gitea/client.go b/internal/adapter/gitea/client.go index 3ea7a6c..1e3801c 100644 --- a/internal/adapter/gitea/client.go +++ b/internal/adapter/gitea/client.go @@ -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", } diff --git a/internal/adapter/postgres/project_domain.go b/internal/adapter/postgres/project_domain.go new file mode 100644 index 0000000..99680f1 --- /dev/null +++ b/internal/adapter/postgres/project_domain.go @@ -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 +} diff --git a/internal/adapter/templates/provider.go b/internal/adapter/templates/provider.go index 815f0d9..cacfbc2 100644 --- a/internal/adapter/templates/provider.go +++ b/internal/adapter/templates/provider.go @@ -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", + Message: "Add " + relPath + " from template", }, - }) + } + 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) } diff --git a/internal/adapter/woodpecker/client.go b/internal/adapter/woodpecker/client.go index eefda8b..e501ebf 100644 --- a/internal/adapter/woodpecker/client.go +++ b/internal/adapter/woodpecker/client.go @@ -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,33 +117,76 @@ 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 - for _, r := range repos { - if strings.EqualFold(r.FullName, fullName) { - targetRepo = r + 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 + break + } + } + + if targetRepo == nil { + // Repo not found in list - try direct lookup + targetRepo, err = c.client.RepoLookup(fullName) + if err != nil { + // SDK bug: RepoLookup returns non-nil empty struct on error + targetRepo = nil + lastErr = fmt.Errorf("repo not found in Woodpecker: %s", fullName) + if attempt < maxAttempts { + c.logger.Debug("repo not found, retrying", "repo", fullName, "attempt", attempt, "max", maxAttempts) + time.Sleep(retryDelay) + continue + } + } + } + + // Check if repo was found AND has valid ForgeRemoteID (metadata sync complete) + if targetRepo != nil && targetRepo.ForgeRemoteID != "" { break } + + // 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 { - // Repo not found - try to look it up directly - targetRepo, err = c.client.RepoLookup(fullName) - if err != nil { - return nil, fmt.Errorf("repo not found in Woodpecker: %s (ensure forge is synced)", fullName) - } + 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 @@ -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, } } diff --git a/internal/db/migrations/013_project_domains.sql b/internal/db/migrations/013_project_domains.sql new file mode 100644 index 0000000..d6cd68b --- /dev/null +++ b/internal/db/migrations/013_project_domains.sql @@ -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'; diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 3ad2d94..800ac23 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -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") diff --git a/internal/domain/project_domain.go b/internal/domain/project_domain.go new file mode 100644 index 0000000..074ddda --- /dev/null +++ b/internal/domain/project_domain.go @@ -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 "" +} diff --git a/internal/domain/project_domain_test.go b/internal/domain/project_domain_test.go new file mode 100644 index 0000000..4a84a2a --- /dev/null +++ b/internal/domain/project_domain_test.go @@ -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) + } + }) + } +} diff --git a/internal/handlers/domain_service_adapter.go b/internal/handlers/domain_service_adapter.go new file mode 100644 index 0000000..2074992 --- /dev/null +++ b/internal/handlers/domain_service_adapter.go @@ -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) +} diff --git a/internal/handlers/infrastructure.go b/internal/handlers/infrastructure.go index d80f2d2..ef276fd 100644 --- a/internal/handlers/infrastructure.go +++ b/internal/handlers/infrastructure.go @@ -14,13 +14,31 @@ 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 - dns port.DNSProvider - deployer port.Deployer - projects port.ProjectRepository - ciProvider port.CIProvider + gitRepo port.GitRepository + dns port.DNSProvider + 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, diff --git a/internal/handlers/infrastructure_domains.go b/internal/handlers/infrastructure_domains.go index 9dafab5..95c39ea 100644 --- a/internal/handlers/infrastructure_domains.go +++ b/internal/handlers/infrastructure_domains.go @@ -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") - 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) + 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) + DomainType string `json:"domain_type,omitempty"` // "primary_custom" or "alias" (default: "alias") } -// DomainAliasResponse is the response for domain alias operations. -type DomainAliasResponse struct { - Domain string `json:"domain"` - Type string `json:"type"` - Content string `json:"content"` - TTL int `json:"ttl"` - Proxied bool `json:"proxied"` +// DomainResponse is the response for domain operations. +type DomainResponse struct { + ID int64 `json:"id"` + Domain string `json:"domain"` + 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, - }) - } - } - - 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, - }) - } + // 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), + }) } 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, - Proxied: req.Proxied, + 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{ - "project": projectID, - "domain": record.Name, - "type": record.Type, - "content": record.Content, - "status": "configured", - "note": note, + "id": pd.ID, + "project": projectID, + "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,54 +170,29 @@ 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 for primary domain protection + if strings.Contains(err.Error(), "auto-generated primary domain") { + api.WriteBadRequest(w, r, "cannot remove auto-generated primary domain; delete the project instead") + return + } + api.WriteInternalError(w, r, fmt.Sprintf("failed to remove domain: %v", err)) return } - // Check if the record exists before attempting deletion - dnsName := aliasDomain - if isSubdomain(aliasDomain, h.defaultDomain) { - dnsName = getSubdomain(aliasDomain, h.defaultDomain) - } - - // Check both A and CNAME records - aRecord, _ := h.dns.FindRecord(ctx, "A", dnsName) - cnameRecord, _ := h.dns.FindRecord(ctx, "CNAME", dnsName) - - if aRecord == nil && cnameRecord == nil { - api.WriteNotFound(w, r, fmt.Sprintf("no DNS record found for %s", aliasDomain)) - return - } - - // Delete whichever record(s) exist - if aRecord != nil { - _ = h.dns.DeleteRecordByName(ctx, "A", dnsName) - } - if cnameRecord != nil { - _ = h.dns.DeleteRecordByName(ctx, "CNAME", dnsName) - } - api.WriteSuccess(w, r, map[string]string{ "project": projectID, "domain": aliasDomain, "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 -} diff --git a/internal/handlers/infrastructure_domains_test.go b/internal/handlers/infrastructure_domains_test.go index cf856a8..365b466 100644 --- a/internal/handlers/infrastructure_domains_test.go +++ b/internal/handlers/infrastructure_domains_test.go @@ -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() +// mockDomainService implements DomainService for testing. +type mockDomainService struct { + domains map[string][]*domain.ProjectDomain // projectID -> domains + err error +} - // 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, +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 } - dns.records["other.threesix.ai"] = &domain.DNSRecord{ - ID: "rec-2", Type: "A", Name: "other.threesix.ai", - Content: "208.122.204.172", TTL: 1, + } + 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) - } - }) - } -} diff --git a/internal/handlers/infrastructure_pipelines_test.go b/internal/handlers/infrastructure_pipelines_test.go index cc2e5cb..145031f 100644 --- a/internal/handlers/infrastructure_pipelines_test.go +++ b/internal/handlers/infrastructure_pipelines_test.go @@ -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", }) diff --git a/internal/handlers/infrastructure_test.go b/internal/handlers/infrastructure_test.go index 3bd7b19..9d79835 100644 --- a/internal/handlers/infrastructure_test.go +++ b/internal/handlers/infrastructure_test.go @@ -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) diff --git a/internal/handlers/project_management.go b/internal/handlers/project_management.go index 4276529..25a71a5 100644 --- a/internal/handlers/project_management.go +++ b/internal/handlers/project_management.go @@ -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 { diff --git a/internal/port/ci_provider.go b/internal/port/ci_provider.go index 35f9e7b..ac8abd4 100644 --- a/internal/port/ci_provider.go +++ b/internal/port/ci_provider.go @@ -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) } diff --git a/internal/port/project_domain_repository.go b/internal/port/project_domain_repository.go new file mode 100644 index 0000000..cd7ccea --- /dev/null +++ b/internal/port/project_domain_repository.go @@ -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) +} diff --git a/internal/service/project_infra.go b/internal/service/project_infra.go index 37984fa..63f3bd0 100644 --- a/internal/service/project_infra.go +++ b/internal/service/project_infra.go @@ -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, @@ -74,10 +81,11 @@ func NewProjectInfraService( // CreateProjectRequest contains parameters for creating a new project. type CreateProjectRequest struct { - Name string - Description string - Private bool - Template string // Template to seed the repo with (default: "default") + Name string + 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 +// 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 +} - 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) +// 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) } diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go new file mode 100644 index 0000000..14890c5 --- /dev/null +++ b/internal/service/project_infra_crud.go @@ -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) +} diff --git a/internal/service/project_infra_domains.go b/internal/service/project_infra_domains.go new file mode 100644 index 0000000..f1dda1b --- /dev/null +++ b/internal/service/project_infra_domains.go @@ -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) +} diff --git a/scripts/load-credentials.sh b/scripts/load-credentials.sh index ede3bd8..bb4d529 100755 --- a/scripts/load-credentials.sh +++ b/scripts/load-credentials.sh @@ -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 diff --git a/scripts/logs.sh b/scripts/logs.sh new file mode 100755 index 0000000..76ed96c --- /dev/null +++ b/scripts/logs.sh @@ -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 diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..3232b4e --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,88 @@ +#!/bin/bash +set -euo pipefail + +# rdev release script +# Usage: ./scripts/release.sh "" +# 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 \"\"" + 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"