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>
277 lines
8.2 KiB
Go
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
|
|
}
|