package handlers import ( "context" "encoding/json" "fmt" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/pkg/api" ) // maxReplicas is the maximum number of deployment replicas allowed. const maxReplicas = 10 // 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 > maxReplicas { api.WriteBadRequest(w, r, fmt.Sprintf("replicas must be between 0 and %d", maxReplicas)) 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, }) }