rdev/internal/handlers/infrastructure.go
jordan 0fd4e32073 feat: Add infrastructure adapters for threesix.ai
Add Gitea, Cloudflare DNS, and Kubernetes deployer adapters following
hexagonal architecture. These enable automated project provisioning:
- Git repository creation/management via Gitea
- DNS record management via Cloudflare
- Container deployment to Kubernetes

Includes domain models, ports, handlers, and Woodpecker CI webhook
integration for automated deployments on push.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:49:58 -07:00

593 lines
17 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"regexp"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
)
// InfrastructureHandler handles git, deployment, and DNS endpoints.
type InfrastructureHandler struct {
gitRepo port.GitRepository
dns port.DNSProvider
deployer port.Deployer
projects port.ProjectRepository
// Config
defaultGitOwner string
defaultDomain string
clusterIP string
}
// projectIDRegex validates project IDs (alphanumeric, dash, underscore only).
var projectIDRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
// validateProjectID validates that a project ID is safe for use as repo/deployment name.
func validateProjectID(id string) error {
if id == "" {
return errors.New("project ID cannot be empty")
}
if len(id) > 63 { // K8s name limit
return errors.New("project ID too long (max 63 characters)")
}
if !projectIDRegex.MatchString(id) {
return errors.New("project ID must start with a letter and contain only alphanumeric characters, dashes, or underscores")
}
return nil
}
// InfrastructureConfig configures the infrastructure handler.
type InfrastructureConfig struct {
// DefaultGitOwner is the default org/user for new repos (e.g., "threesix")
DefaultGitOwner string
// DefaultDomain is the base domain for auto-generated URLs (e.g., "threesix.ai")
DefaultDomain string
// ClusterIP is the external IP address for DNS records (e.g., "208.122.204.172")
ClusterIP string
}
// NewInfrastructureHandler creates a new infrastructure handler.
func NewInfrastructureHandler(
gitRepo port.GitRepository,
dns port.DNSProvider,
deployer port.Deployer,
projects port.ProjectRepository,
cfg InfrastructureConfig,
) *InfrastructureHandler {
return &InfrastructureHandler{
gitRepo: gitRepo,
dns: dns,
deployer: deployer,
projects: projects,
defaultGitOwner: cfg.DefaultGitOwner,
defaultDomain: cfg.DefaultDomain,
clusterIP: cfg.ClusterIP,
}
}
// Mount registers the infrastructure routes.
func (h *InfrastructureHandler) Mount(r api.Router) {
r.Route("/projects", func(r chi.Router) {
// Git repository endpoints
r.Post("/{id}/repo", h.CreateRepo)
r.Get("/{id}/repo", h.GetRepo)
r.Delete("/{id}/repo", h.DeleteRepo)
// Deployment endpoints
r.Post("/{id}/deploy", h.Deploy)
r.Get("/{id}/deploy/status", h.GetDeployStatus)
r.Delete("/{id}/deploy", h.Undeploy)
r.Post("/{id}/deploy/restart", h.RestartDeploy)
r.Post("/{id}/deploy/scale", h.ScaleDeploy)
r.Get("/{id}/deploy/logs", h.GetDeployLogs)
// Domain endpoints
r.Post("/{id}/domain", h.AddDomain)
r.Delete("/{id}/domain", h.RemoveDomain)
})
}
// CreateRepoRequest is the request body for POST /projects/{id}/repo.
type CreateRepoRequest struct {
Description string `json:"description,omitempty"`
Private bool `json:"private,omitempty"`
}
// CreateRepoResponse is the response for POST /projects/{id}/repo.
type CreateRepoResponse struct {
ID int64 `json:"id"`
Owner string `json:"owner"`
Name string `json:"name"`
FullName string `json:"full_name"`
Description string `json:"description,omitempty"`
Private bool `json:"private"`
CloneSSH string `json:"clone_ssh"`
CloneHTTP string `json:"clone_http"`
HTMLURL string `json:"html_url"`
}
// CreateRepo creates a git repository for a project.
// POST /projects/{id}/repo
func (h *InfrastructureHandler) CreateRepo(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.gitRepo == nil {
api.WriteInternalError(w, r, "git repository not configured")
return
}
var req CreateRepoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err.Error() != "EOF" {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Create the repo
repo, err := h.gitRepo.CreateRepo(ctx, projectID, req.Description, req.Private)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create repo: %v", err))
return
}
// Note: Could update project with repo info here if we had a project repository
// For now, just return the repo info directly
_ = h.projects // Silence unused warning if present
api.WriteCreated(w, r, CreateRepoResponse{
ID: repo.ID,
Owner: repo.Owner,
Name: repo.Name,
FullName: repo.FullName,
Description: repo.Description,
Private: repo.Private,
CloneSSH: repo.CloneSSH,
CloneHTTP: repo.CloneHTTP,
HTMLURL: repo.HTMLURL,
})
}
// GetRepo returns the git repository for a project.
// GET /projects/{id}/repo
func (h *InfrastructureHandler) GetRepo(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.gitRepo == nil {
api.WriteInternalError(w, r, "git repository not configured")
return
}
repo, err := h.gitRepo.GetRepo(ctx, h.defaultGitOwner, projectID)
if err != nil {
api.WriteNotFound(w, r, fmt.Sprintf("repo not found: %s/%s", h.defaultGitOwner, projectID))
return
}
api.WriteSuccess(w, r, CreateRepoResponse{
ID: repo.ID,
Owner: repo.Owner,
Name: repo.Name,
FullName: repo.FullName,
Description: repo.Description,
Private: repo.Private,
CloneSSH: repo.CloneSSH,
CloneHTTP: repo.CloneHTTP,
HTMLURL: repo.HTMLURL,
})
}
// DeleteRepo deletes the git repository for a project.
// DELETE /projects/{id}/repo
func (h *InfrastructureHandler) DeleteRepo(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.gitRepo == nil {
api.WriteInternalError(w, r, "git repository not configured")
return
}
err := h.gitRepo.DeleteRepo(ctx, h.defaultGitOwner, projectID)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to delete repo: %v", err))
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "deleted",
"project": projectID,
})
}
// DeployRequest is the request body for POST /projects/{id}/deploy.
type DeployRequest struct {
Image string `json:"image"` // Container image
Domain string `json:"domain,omitempty"` // Custom domain (optional)
Port int `json:"port,omitempty"` // Container port (default 8080)
Replicas int `json:"replicas,omitempty"` // Number of replicas (default 1)
EnvVars map[string]string `json:"env_vars,omitempty"` // Plain environment variables
Secrets map[string]string `json:"secrets,omitempty"` // Secret environment variables
}
// DeployResponse is the response for POST /projects/{id}/deploy.
type DeployResponse struct {
ProjectName string `json:"project_name"`
Image string `json:"image"`
Domain string `json:"domain"`
URL string `json:"url"`
Status string `json:"status"`
}
// Deploy deploys a project.
// POST /projects/{id}/deploy
func (h *InfrastructureHandler) Deploy(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.deployer == nil {
api.WriteInternalError(w, r, "deployer not configured")
return
}
var req DeployRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Image == "" {
api.WriteBadRequest(w, r, "image is required")
return
}
// Build domain
deployDomain := req.Domain
if deployDomain == "" {
deployDomain = projectID + "." + h.defaultDomain
}
// Create DNS record if DNS provider is configured
if h.dns != nil && h.clusterIP != "" {
_, err := h.dns.CreateRecord(ctx, domain.DNSRecord{
Type: "A",
Name: projectID,
Content: h.clusterIP,
TTL: 1,
Proxied: false,
})
if err != nil {
// Check if this is a "record already exists" error (not a real failure)
// Cloudflare returns specific error codes we could check, but for now
// we log and continue - the record might already exist from a previous deploy
// TODO: Add proper duplicate detection once we have structured errors from adapter
_ = err // acknowledge error - may be duplicate record which is acceptable
}
}
// Deploy
spec := domain.DeploySpec{
ProjectName: projectID,
Image: req.Image,
Domain: deployDomain,
Port: req.Port,
Replicas: req.Replicas,
EnvVars: req.EnvVars,
Secrets: req.Secrets,
}
if err := h.deployer.Deploy(ctx, spec); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to deploy: %v", err))
return
}
api.WriteCreated(w, r, DeployResponse{
ProjectName: projectID,
Image: req.Image,
Domain: deployDomain,
URL: "https://" + deployDomain,
Status: "deploying",
})
}
// GetDeployStatus returns the deployment status for a project.
// GET /projects/{id}/deploy/status
func (h *InfrastructureHandler) GetDeployStatus(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if h.deployer == nil {
api.WriteInternalError(w, r, "deployer not configured")
return
}
status, err := h.deployer.GetStatus(ctx, projectID)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to get status: %v", err))
return
}
if status == nil {
api.WriteNotFound(w, r, fmt.Sprintf("no deployment found for project: %s", projectID))
return
}
api.WriteSuccess(w, r, map[string]any{
"project_name": status.ProjectName,
"image": status.Image,
"replicas": status.Replicas,
"ready_replicas": status.ReadyReplicas,
"url": status.URL,
"status": status.Status,
"created_at": status.CreatedAt,
"updated_at": status.UpdatedAt,
})
}
// Undeploy removes the deployment for a project.
// DELETE /projects/{id}/deploy
func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if h.deployer == nil {
api.WriteInternalError(w, r, "deployer not configured")
return
}
if err := h.deployer.Undeploy(ctx, projectID); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to undeploy: %v", err))
return
}
// Remove DNS record if DNS provider is configured
if h.dns != nil {
_ = h.dns.DeleteRecordByName(ctx, "A", projectID)
}
api.WriteSuccess(w, r, map[string]string{
"status": "undeployed",
"project": projectID,
})
}
// RestartDeploy restarts the deployment for a project.
// POST /projects/{id}/deploy/restart
func (h *InfrastructureHandler) RestartDeploy(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if h.deployer == nil {
api.WriteInternalError(w, r, "deployer not configured")
return
}
if err := h.deployer.Restart(ctx, projectID); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to restart: %v", err))
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "restarting",
"project": projectID,
})
}
// ScaleRequest is the request body for POST /projects/{id}/deploy/scale.
type ScaleRequest struct {
Replicas int `json:"replicas"`
}
// ScaleDeploy scales the deployment for a project.
// POST /projects/{id}/deploy/scale
func (h *InfrastructureHandler) ScaleDeploy(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if h.deployer == nil {
api.WriteInternalError(w, r, "deployer not configured")
return
}
var req ScaleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Replicas < 0 || req.Replicas > 10 {
api.WriteBadRequest(w, r, "replicas must be between 0 and 10")
return
}
if err := h.deployer.Scale(ctx, projectID, req.Replicas); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to scale: %v", err))
return
}
api.WriteSuccess(w, r, map[string]any{
"status": "scaled",
"project": projectID,
"replicas": req.Replicas,
})
}
// GetDeployLogs returns recent logs from the deployment.
// GET /projects/{id}/deploy/logs
func (h *InfrastructureHandler) GetDeployLogs(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if h.deployer == nil {
api.WriteInternalError(w, r, "deployer not configured")
return
}
// Default to 100 lines
tailLines := 100
logs, err := h.deployer.GetLogs(ctx, projectID, tailLines)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to get logs: %v", err))
return
}
api.WriteSuccess(w, r, map[string]string{
"project": projectID,
"logs": logs,
})
}
// AddDomainRequest is the request body for POST /projects/{id}/domain.
type AddDomainRequest struct {
Domain string `json:"domain"` // Custom domain (e.g., "myapp.example.com")
}
// AddDomain adds a custom domain to a project.
// POST /projects/{id}/domain
func (h *InfrastructureHandler) AddDomain(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
var req AddDomainRequest
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
}
// Create DNS record if it's a threesix.ai subdomain
if h.dns != nil && h.clusterIP != "" && isSubdomain(req.Domain, h.defaultDomain) {
subdomain := getSubdomain(req.Domain, h.defaultDomain)
_, err := h.dns.CreateRecord(ctx, domain.DNSRecord{
Type: "A",
Name: subdomain,
Content: h.clusterIP,
TTL: 1,
Proxied: false,
})
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create DNS record: %v", err))
return
}
}
// TODO: Update ingress with new domain
// This would require getting the current deployment and updating it
note := "Domain configured"
if !isSubdomain(req.Domain, h.defaultDomain) && h.clusterIP != "" {
note = fmt.Sprintf("External domain configured. Point your DNS to %s", h.clusterIP)
}
api.WriteCreated(w, r, map[string]string{
"project": projectID,
"domain": req.Domain,
"status": "configured",
"note": note,
})
}
// RemoveDomain removes a custom domain from a project.
// DELETE /projects/{id}/domain
func (h *InfrastructureHandler) RemoveDomain(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Validate project ID
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
domainName := r.URL.Query().Get("domain")
if domainName == "" {
api.WriteBadRequest(w, r, "domain query parameter is required")
return
}
// Delete DNS record if it's a threesix.ai subdomain
if h.dns != nil && isSubdomain(domainName, h.defaultDomain) {
subdomain := getSubdomain(domainName, h.defaultDomain)
_ = h.dns.DeleteRecordByName(ctx, "A", subdomain)
}
// TODO: Update ingress to remove the domain
api.WriteSuccess(w, r, map[string]string{
"project": projectID,
"domain": domainName,
"status": "removed",
})
}
// Helper functions
func isSubdomain(domain, baseDomain string) bool {
suffix := "." + baseDomain
return len(domain) > len(suffix) && domain[len(domain)-len(suffix):] == suffix
}
func getSubdomain(domain, baseDomain string) string {
suffix := "." + baseDomain
if len(domain) > len(suffix) && domain[len(domain)-len(suffix):] == suffix {
return domain[:len(domain)-len(suffix)]
}
return domain
}