rdev/internal/handlers/project_management.go
jordan 572b221e20 feat: add automatic cleanup for cookbook test projects
- Add AUTO_TEARDOWN env var and --auto-teardown flag to cookbook scripts
- Scripts automatically delete created projects on exit (including Ctrl+C)
- Add DELETE /projects/cleanup API endpoint for bulk cleanup
- Supports shell-style glob patterns (e.g., "tree-test-*")
- Includes dry_run mode and older_than_hours filter for safety
- Requires admin scope for actual deletion
- Update cookbook scripts: landing-test, composable-test, template-validation,
  feature-test, tree-runner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:54:15 -07:00

440 lines
13 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api"
)
// ProjectManagementHandler handles project lifecycle operations.
type ProjectManagementHandler struct {
infraService *service.ProjectInfraService
operationService *service.OperationService
logger *slog.Logger
}
// NewProjectManagementHandler creates a new project management handler.
func NewProjectManagementHandler(infraService *service.ProjectInfraService, logger *slog.Logger) *ProjectManagementHandler {
if logger == nil {
logger = slog.Default()
}
return &ProjectManagementHandler{
infraService: infraService,
logger: logger,
}
}
// SetOperationService sets the operation tracking service.
func (h *ProjectManagementHandler) SetOperationService(svc *service.OperationService) *ProjectManagementHandler {
if svc != nil {
h.operationService = svc
}
return h
}
// Mount registers the project management routes.
func (h *ProjectManagementHandler) Mount(r api.Router) {
r.Route("/project", func(r chi.Router) {
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/", h.Create)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Delete("/{name}", h.Delete)
// Read operations
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/{name}", h.Status)
})
// Bulk operations on projects (admin only)
r.Route("/projects", func(r chi.Router) {
// Cleanup test projects - admin only for safety
r.With(auth.RequireScope(auth.ScopeAdmin)).
Delete("/cleanup", h.CleanupTestProjects)
})
// Template endpoints (read-only)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/templates", h.ListTemplates)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/templates/components", h.ListComponentTemplates)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/templates/{name}", h.GetTemplate)
}
// CreateRequest is the request body for POST /project.
type CreateRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Private bool `json:"private,omitempty"`
Template string `json:"template,omitempty"` // Template to seed repo (default: "default")
}
// Create creates a new project with git repo and DNS.
// POST /project
func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), TimeoutOrchestration)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
var req CreateRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Name == "" {
api.WriteBadRequest(w, r, "name is required")
return
}
// Start operation tracking
var operationID string
if h.operationService != nil {
operationID, _ = h.operationService.StartOperation(ctx, req.Name,
domain.OperationTypeProjectCreate,
map[string]any{"name": req.Name, "description": req.Description, "template": req.Template},
r.Header.Get("X-Request-ID"))
}
result, err := h.infraService.CreateProject(ctx, service.CreateProjectRequest{
Name: req.Name,
Description: req.Description,
Private: req.Private,
Template: req.Template,
})
if err != nil {
if h.operationService != nil && operationID != "" {
if opErr := h.operationService.FailOperation(ctx, operationID, err.Error(), ""); opErr != nil {
h.logger.Error("failed to record operation failure", "error", opErr, "operation_id", operationID)
}
}
// Check for validation errors (user input) vs internal errors
if errors.Is(err, domain.ErrInvalidProjectName) {
api.WriteBadRequest(w, r, err.Error())
return
}
// Log internal errors but return generic message to client
h.logger.Error("project creation failed", "error", err, "name", req.Name)
api.WriteInternalError(w, r, "failed to create project")
return
}
if h.operationService != nil && operationID != "" {
if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{
"project_id": result.ProjectID,
"git_url": result.CloneHTTP,
}); opErr != nil {
h.logger.Error("failed to record operation completion", "error", opErr, "operation_id", operationID)
}
}
resp := map[string]any{
"project_id": result.ProjectID,
"name": result.Name,
"description": result.Description,
"git": map[string]string{
"owner": result.GitRepoOwner,
"name": result.GitRepoName,
"clone_ssh": result.CloneSSH,
"clone_http": result.CloneHTTP,
"html_url": result.HTMLURL,
},
"domain": result.Domain,
"url": result.URL,
"next_steps": result.NextSteps,
}
if operationID != "" {
resp["operation_id"] = operationID
}
api.WriteCreated(w, r, resp)
}
// List returns all projects.
// GET /project
func (h *ProjectManagementHandler) List(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
projects, err := h.infraService.ListProjects(ctx)
if err != nil {
h.logger.Error("failed to list projects", "error", err)
api.WriteInternalError(w, r, "failed to list projects")
return
}
// Convert to response format
response := make([]map[string]any, len(projects))
for i, p := range projects {
response[i] = map[string]any{
"project_id": p.ProjectID,
"name": p.Name,
"description": p.Description,
"git": map[string]string{
"clone_ssh": p.CloneSSH,
"clone_http": p.CloneHTTP,
"html_url": p.HTMLURL,
},
"domain": p.Domain,
"url": p.URL,
"deployment": map[string]any{
"status": p.DeploymentStatus,
"image": p.DeploymentImage,
"replicas": p.DeploymentReplicas,
"ready_replicas": p.ReadyReplicas,
},
}
}
api.WriteSuccess(w, r, response)
}
// Status returns the status of a specific project.
// GET /project/{name}
func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
status, err := h.infraService.GetStatus(ctx, name)
if err != nil {
// Check if it's a "not found" error
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
h.logger.Error("failed to get project status", "error", err, "name", name)
api.WriteInternalError(w, r, "failed to get project status")
return
}
api.WriteSuccess(w, r, map[string]any{
"project_id": status.ProjectID,
"name": status.Name,
"description": status.Description,
"git": map[string]string{
"owner": status.GitRepoOwner,
"name": status.GitRepoName,
"clone_ssh": status.CloneSSH,
"clone_http": status.CloneHTTP,
"html_url": status.HTMLURL,
},
"domain": status.Domain,
"custom_domain": status.CustomDomain,
"url": status.URL,
"deployment": map[string]any{
"status": status.DeploymentStatus,
"image": status.DeploymentImage,
"replicas": status.DeploymentReplicas,
"ready_replicas": status.ReadyReplicas,
},
})
}
// Delete removes a project and its associated resources.
// DELETE /project/{name}
func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutHeavyWrite)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
err := h.infraService.DeleteProject(ctx, name)
if err != nil {
// Check if it's a "not found" error
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
h.logger.Error("failed to delete project", "error", err, "name", name)
api.WriteInternalError(w, r, "failed to delete project")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "deleted",
"project": name,
})
}
// ListTemplates returns available project templates.
// GET /templates
func (h *ProjectManagementHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
templates, err := h.infraService.ListTemplates(ctx)
if err != nil {
h.logger.Error("failed to list templates", "error", err)
api.WriteInternalError(w, r, "failed to list templates")
return
}
// Convert to response format
response := make([]map[string]any, len(templates))
for i, t := range templates {
response[i] = map[string]any{
"name": t.Name,
"description": t.Description,
"stack": t.Stack,
"files": t.Files,
}
}
api.WriteSuccess(w, r, response)
}
// GetTemplate returns details about a specific template.
// GET /templates/{name}
func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
template, err := h.infraService.GetTemplate(ctx, name)
if err != nil {
if errors.Is(err, domain.ErrTemplateNotFound) {
api.WriteNotFound(w, r, "template not found")
return
}
h.logger.Error("failed to get template", "error", err, "name", name)
api.WriteInternalError(w, r, "failed to get template")
return
}
api.WriteSuccess(w, r, map[string]any{
"name": template.Name,
"description": template.Description,
"stack": template.Stack,
"files": template.Files,
})
}
// CleanupTestProjectsRequest is the request body for DELETE /projects/cleanup.
type CleanupTestProjectsRequest struct {
Patterns []string `json:"patterns"` // Name patterns to match (e.g., "tree-test-*", "landing-test-*")
OlderThanHrs int `json:"older_than_hours"` // Only delete projects older than this many hours
DryRun bool `json:"dry_run"` // If true, don't actually delete, just return what would be deleted
}
// CleanupTestProjects deletes test projects matching patterns that are older than a specified age.
// DELETE /projects/cleanup
// Admin scope required for safety.
func (h *ProjectManagementHandler) CleanupTestProjects(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLongRunning)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
var req CleanupTestProjectsRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if len(req.Patterns) == 0 {
api.WriteBadRequest(w, r, "at least one pattern is required")
return
}
if req.OlderThanHrs < 0 {
api.WriteBadRequest(w, r, "older_than_hours must be non-negative")
return
}
result, err := h.infraService.CleanupTestProjects(ctx, service.CleanupTestProjectsRequest{
Patterns: req.Patterns,
OlderThanHrs: req.OlderThanHrs,
DryRun: req.DryRun,
})
if err != nil {
h.logger.Error("failed to cleanup test projects", "error", err)
api.WriteInternalError(w, r, "failed to cleanup test projects")
return
}
api.WriteSuccess(w, r, map[string]any{
"deleted": result.Deleted,
"count": result.Count,
"dry_run": result.DryRun,
})
}
// ListComponentTemplates returns available component templates.
// GET /templates/components
func (h *ProjectManagementHandler) ListComponentTemplates(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
componentType := r.URL.Query().Get("type")
components, err := h.infraService.ListComponentTemplates(ctx, componentType)
if err != nil {
h.logger.Error("failed to list component templates", "error", err)
api.WriteInternalError(w, r, "failed to list component templates")
return
}
response := make([]map[string]any, len(components))
for i, c := range components {
response[i] = map[string]any{
"type": c.Type,
"description": c.Description,
"stack": c.Stack,
"default_port": c.DefaultPort,
"dest_dir": c.DestDir,
"files": c.Files,
}
}
api.WriteSuccess(w, r, map[string]any{
"components": response,
})
}