- 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>
261 lines
7.1 KiB
Go
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
|
|
}
|