rdev/internal/handlers/operations.go
jordan c280a92012 feat: add operations audit system and template improvements
Operations Audit (new feature):
- Add Operation domain model with status tracking (pending, running, completed, failed, cancelled)
- Add OperationRepository with PostgreSQL implementation
- Add OperationService for CRUD and lifecycle management
- Add operations handlers (list, get, cancel endpoints)
- Add migration 015_operations.sql for operations table
- Add operation cleanup worker for stale operation handling
- Add ErrOperationNotFound to domain errors

Template Improvements:
- Add CLAUDE.md configuration files to astro-landing, default, and go-api templates
- Fix PORT template variable usage in nginx configs for app templates
- Add replace directives for local pkg module in Go templates
- Simplify Go service/worker Dockerfiles for workspace builds
- Fix TypeScript error in logger template

Other:
- Refactor landing-test.sh cookbook script
- Update CLAUDE.md version reference

Note: Some files exceed 500-line limit (pre-existing debt + new feature)
- component.go: 550 lines (unchanged, pre-existing)
- main.go: 522 lines (added operations wiring)
- operation_repo.go: 569 lines (new, needs splitting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:08:57 -07:00

277 lines
8.2 KiB
Go

// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
)
// OperationsHandler handles operation query endpoints.
type OperationsHandler struct {
repo port.OperationRepository
}
// NewOperationsHandler creates a new operations handler.
func NewOperationsHandler(repo port.OperationRepository) *OperationsHandler {
return &OperationsHandler{repo: repo}
}
// Mount registers the operation routes.
func (h *OperationsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/operations", func(r chi.Router) {
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).Get("/{operation_id}", h.Get)
})
}
// OperationSummaryResponse is the response for listing operations.
type OperationSummaryResponse struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
StartedAt string `json:"started_at"`
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
StepsSummary string `json:"steps_summary,omitempty"`
CommitSHA *string `json:"commit_sha,omitempty"`
ExternalRef *string `json:"external_ref,omitempty"`
}
// OperationStepResponse is the response for a single operation step.
type OperationStepResponse struct {
Name string `json:"name"`
Status string `json:"status"`
StartedAt string `json:"started_at"`
DurationMs int64 `json:"duration_ms,omitempty"`
Output map[string]any `json:"output,omitempty"`
Error string `json:"error,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
}
// OperationDetailResponse is the full response for a single operation.
type OperationDetailResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Type string `json:"type"`
Status string `json:"status"`
RequestID string `json:"request_id,omitempty"`
TriggeredBy string `json:"triggered_by,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
ExternalRef string `json:"external_ref,omitempty"`
StartedAt string `json:"started_at"`
CompletedAt *string `json:"completed_at,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
Input map[string]any `json:"input,omitempty"`
Output map[string]any `json:"output,omitempty"`
Error string `json:"error,omitempty"`
ErrorDetail string `json:"error_detail,omitempty"`
Steps []OperationStepResponse `json:"steps,omitempty"`
}
// List returns operations for a project with optional filters.
// GET /projects/{id}/operations
// Query parameters:
// - status: filter by status (pending, running, completed, failed)
// - type: filter by type (project.create, component.add, build, resource.provision)
// - commit: filter by commit SHA
// - since: filter by start time (RFC3339 or duration like "1h", "24h")
// - limit: maximum number of entries (default 50, max 200)
func (h *OperationsHandler) List(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
filters := domain.DefaultOperationFilters()
filters.ProjectID = projectID
// Parse status filter
if status := r.URL.Query().Get("status"); status != "" {
s := domain.OperationStatus(status)
if !s.IsValid() {
api.WriteBadRequest(w, r, "invalid status: must be pending, running, completed, or failed")
return
}
filters.Status = s
}
// Parse type filter
if opType := r.URL.Query().Get("type"); opType != "" {
t := domain.OperationType(opType)
if !t.IsValid() {
api.WriteBadRequest(w, r, "invalid type: must be project.create, component.add, build, or resource.provision")
return
}
filters.Type = t
}
// Parse commit filter
if commit := r.URL.Query().Get("commit"); commit != "" {
filters.CommitSHA = commit
}
// Parse since filter (RFC3339 or duration)
if sinceStr := r.URL.Query().Get("since"); sinceStr != "" {
since, err := parseSince(sinceStr)
if err != nil {
api.WriteBadRequest(w, r, "invalid since: must be RFC3339 or duration (e.g., 1h, 24h)")
return
}
filters.Since = since
}
// Parse limit
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 {
api.WriteBadRequest(w, r, "invalid limit: must be a positive integer")
return
}
filters.Limit = limit
}
filters.Normalize()
ops, err := h.repo.List(r.Context(), filters)
if err != nil {
api.WriteInternalError(w, r, "failed to list operations")
return
}
resp := make([]OperationSummaryResponse, len(ops))
for i, op := range ops {
resp[i] = toOperationSummary(op)
}
api.WriteSuccess(w, r, map[string]any{
"data": resp,
"project_id": projectID,
"total": len(resp),
})
}
// Get returns details for a single operation.
// GET /projects/{id}/operations/{operation_id}
func (h *OperationsHandler) Get(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
operationID := chi.URLParam(r, "operation_id")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
if operationID == "" {
api.WriteBadRequest(w, r, "operation ID is required")
return
}
op, err := h.repo.Get(r.Context(), operationID)
if err != nil {
if errors.Is(err, domain.ErrOperationNotFound) {
api.WriteNotFound(w, r, "operation not found")
return
}
api.WriteInternalError(w, r, "failed to get operation")
return
}
// Verify the operation belongs to the requested project
if op.ProjectID != projectID {
api.WriteNotFound(w, r, "operation not found")
return
}
api.WriteSuccess(w, r, map[string]any{
"data": toOperationDetail(op),
})
}
// toOperationSummary converts an Operation to a summary response.
func toOperationSummary(op *domain.Operation) OperationSummaryResponse {
resp := OperationSummaryResponse{
ID: op.ID,
Type: string(op.Type),
Status: string(op.Status),
StartedAt: op.StartedAt.Format(time.RFC3339),
DurationMs: op.DurationMs,
Error: op.Error,
StepsSummary: op.StepsSummary(),
}
if op.CommitSHA != "" {
resp.CommitSHA = &op.CommitSHA
}
if op.ExternalRef != "" {
resp.ExternalRef = &op.ExternalRef
}
return resp
}
// toOperationDetail converts an Operation to a full detail response.
func toOperationDetail(op *domain.Operation) OperationDetailResponse {
resp := OperationDetailResponse{
ID: op.ID,
ProjectID: op.ProjectID,
Type: string(op.Type),
Status: string(op.Status),
RequestID: op.RequestID,
TriggeredBy: op.TriggeredBy,
CommitSHA: op.CommitSHA,
ExternalRef: op.ExternalRef,
StartedAt: op.StartedAt.Format(time.RFC3339),
DurationMs: op.DurationMs,
Input: op.Input,
Output: op.Output,
Error: op.Error,
ErrorDetail: op.ErrorDetail,
}
if op.CompletedAt != nil {
s := op.CompletedAt.Format(time.RFC3339)
resp.CompletedAt = &s
}
if len(op.Steps) > 0 {
resp.Steps = make([]OperationStepResponse, len(op.Steps))
for i, step := range op.Steps {
resp.Steps[i] = OperationStepResponse{
Name: step.Name,
Status: string(step.Status),
StartedAt: step.StartedAt.Format(time.RFC3339),
DurationMs: step.DurationMs,
Output: step.Output,
Error: step.Error,
ErrorDetail: step.ErrorDetail,
}
}
}
return resp
}
// parseSince parses a "since" parameter as either RFC3339 time or duration.
func parseSince(s string) (time.Time, error) {
// Try RFC3339 first
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// Try duration (e.g., "1h", "24h", "7d")
d, err := time.ParseDuration(s)
if err != nil {
return time.Time{}, err
}
return time.Now().Add(-d), nil
}