rdev/internal/handlers/infrastructure_deploy.go
jordan 812b8341be refactor: Split large files to comply with 500-line limit
- cmd/rdev-api/main.go: Extract OpenAPI spec to openapi.go (1073→386 lines)
- internal/adapter/deployer/deployer.go: Extract K8s resources to resources.go (502→264 lines)
- internal/handlers/infrastructure.go: Extract deploy handlers to infrastructure_deploy.go (592→342 lines)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 23:02:31 -07:00

264 lines
7.2 KiB
Go

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"
)
// 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,
})
}