rdev/internal/handlers/infrastructure_domains.go
jordan bc47e426b0 feat: Add CI pipeline proxy, DNS alias management, and worker executor system
- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:05:28 -07:00

261 lines
7.1 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/pkg/api"
)
// 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)
}
// 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"`
}
// ListDomains returns all DNS records associated with a project.
// GET /projects/{id}/domains
func (h *InfrastructureHandler) ListDomains(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.dns == nil {
api.WriteInternalError(w, r, "DNS provider not configured")
return
}
// List all A records and find ones matching this project
aRecords, err := h.dns.ListRecords(ctx, "A")
if err != nil {
api.WriteInternalError(w, r, "failed to list DNS records")
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,
})
}
}
api.WriteSuccess(w, r, map[string]any{
"project_id": projectID,
"domains": domains,
"total": len(domains),
})
}
// AddDomainAlias adds a DNS alias for a project.
// POST /projects/{id}/domains
func (h *InfrastructureHandler) AddDomainAlias(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.dns == nil {
api.WriteInternalError(w, r, "DNS provider not configured")
return
}
var req DomainAliasRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Domain == "" {
api.WriteBadRequest(w, r, "domain is required")
return
}
// Default record type is A
recordType := strings.ToUpper(req.Type)
if recordType == "" {
recordType = "A"
}
if recordType != "A" && recordType != "CNAME" {
api.WriteBadRequest(w, r, "type must be A or CNAME")
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 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,
})
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create DNS record: %v", err))
return
}
note := "Domain alias 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,
})
}
// RemoveDomainAlias removes a DNS alias from a project.
// DELETE /projects/{id}/domains/{domain}
func (h *InfrastructureHandler) RemoveDomainAlias(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
aliasDomain := chi.URLParam(r, "domain")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if aliasDomain == "" {
api.WriteBadRequest(w, r, "domain path parameter is required")
return
}
if h.dns == nil {
api.WriteInternalError(w, r, "DNS provider 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")
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
}