Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Add UndeployAll() using label selectors to clean up monorepo components on project deletion (replaces name-based Undeploy in DeleteProject and the direct undeploy handler) - Add ResourceGC background worker that periodically finds K8s resources whose project label has no matching DB record, deletes after 1h safety window - Widen deployer client type from *kubernetes.Clientset to kubernetes.Interface for testability - UndeployAll accumulates errors via errors.Join instead of failing fast - Add checkout/checkin sidecar dev flow: temporary git tokens, branch checkout, review on checkin with cleanup workers - Add interactive sessions: pod binding, command execution, SSE streaming, ephemeral preview URLs with session cleanup workers - Add GET /workers/pool endpoint for aggregate capacity and queue depth - Add sessions:read and sessions:execute auth scopes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
308 lines
8.7 KiB
Go
308 lines
8.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"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 {
|
|
Component string `json:"component,omitempty"` // Component path (e.g., "services/auth-api"), empty for single-app or all components
|
|
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"`
|
|
ComponentPath string `json:"component_path,omitempty"` // Component path if deploying a component
|
|
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(), TimeoutHeavyWrite)
|
|
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 := api.DecodeJSON(r, &req); err != nil {
|
|
api.WriteBadRequest(w, r, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Image == "" {
|
|
api.WriteBadRequest(w, r, "image is required")
|
|
return
|
|
}
|
|
|
|
// Build domain - for components, include component name in subdomain
|
|
deployDomain := req.Domain
|
|
if deployDomain == "" {
|
|
if req.Component != "" {
|
|
// For components, build domain like "component-project.domain.com"
|
|
// Extract component name from path (e.g., "services/auth-api" -> "auth-api")
|
|
componentName := extractComponentName(req.Component)
|
|
deployDomain = componentName + "-" + projectID + "." + h.defaultDomain
|
|
} else {
|
|
deployDomain = projectID + "." + h.defaultDomain
|
|
}
|
|
}
|
|
|
|
// Create DNS record if DNS provider is configured
|
|
dnsName := projectID
|
|
if req.Component != "" {
|
|
dnsName = extractComponentName(req.Component) + "-" + projectID
|
|
}
|
|
if h.dns != nil && h.clusterIP != "" {
|
|
_, err := h.dns.CreateRecord(ctx, domain.DNSRecord{
|
|
Type: "A",
|
|
Name: dnsName,
|
|
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,
|
|
ComponentPath: req.Component,
|
|
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,
|
|
ComponentPath: req.Component,
|
|
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(), TimeoutLookup)
|
|
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(), TimeoutStandard)
|
|
defer cancel()
|
|
|
|
if h.deployer == nil {
|
|
api.WriteInternalError(w, r, "deployer not configured")
|
|
return
|
|
}
|
|
|
|
if err := h.deployer.UndeployAll(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(), TimeoutStandard)
|
|
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(), TimeoutStandard)
|
|
defer cancel()
|
|
|
|
if h.deployer == nil {
|
|
api.WriteInternalError(w, r, "deployer not configured")
|
|
return
|
|
}
|
|
|
|
var req ScaleRequest
|
|
if err := api.DecodeJSON(r, &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(), TimeoutStandard)
|
|
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,
|
|
})
|
|
}
|
|
|
|
// extractComponentName extracts the component name from a path like "services/auth-api".
|
|
// Returns the last segment (e.g., "auth-api").
|
|
func extractComponentName(componentPath string) string {
|
|
if componentPath == "" {
|
|
return ""
|
|
}
|
|
// Split by "/" and return last segment
|
|
parts := make([]string, 0)
|
|
current := ""
|
|
for _, c := range componentPath {
|
|
if c == '/' {
|
|
if current != "" {
|
|
parts = append(parts, current)
|
|
current = ""
|
|
}
|
|
} else {
|
|
current += string(c)
|
|
}
|
|
}
|
|
if current != "" {
|
|
parts = append(parts, current)
|
|
}
|
|
if len(parts) == 0 {
|
|
return componentPath
|
|
}
|
|
return parts[len(parts)-1]
|
|
}
|