// 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 }