rdev/internal/handlers/infrastructure_domains.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

198 lines
5.5 KiB
Go

package handlers
import (
"context"
"errors"
"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., "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")
}
// 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 domains 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(), TimeoutLookup)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.domainService == nil {
api.WriteInternalError(w, r, "domain service not configured")
return
}
domains, err := h.domainService.ListDomains(ctx, projectID)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to list domains: %v", err))
return
}
// 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": response,
"total": len(response),
})
}
// 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")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.domainService == nil {
api.WriteInternalError(w, r, "domain service not configured")
return
}
var req DomainAliasRequest
if err := api.DecodeJSON(r, &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 domain type
domainType := domain.DomainTypeAlias
if req.DomainType == "primary_custom" {
domainType = domain.DomainTypePrimaryCustom
}
pd, err := h.domainService.AddDomain(ctx, DomainAddRequest{
ProjectID: projectID,
Domain: req.Domain,
Type: domainType,
RecordType: recordType,
Proxied: req.Proxied,
})
if err != nil {
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 configured"
if !isSubdomain(req.Domain, h.defaultDomain) && recordType == "A" {
note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP)
}
api.WriteCreated(w, r, map[string]any{
"id": pd.ID,
"project": projectID,
"domain": pd.Domain,
"type": string(pd.Type),
"record_type": pd.DNSRecordType,
"verified": pd.Verified,
"status": "configured",
"note": note,
})
}
// 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")
aliasDomain := chi.URLParam(r, "domain")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
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.domainService == nil {
api.WriteInternalError(w, r, "domain service not configured")
return
}
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
}
api.WriteSuccess(w, r, map[string]string{
"project": projectID,
"domain": aliasDomain,
"status": "removed",
})
}