rdev/internal/handlers/infrastructure_pipelines.go
jordan f20fc6c51c
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat(saga): implement enterprise-grade resilience architecture
Fixes issues from code review of resilience implementation:

- Wire saga system in main.go (SagaRepository, SagaExecutor, SagaHandler)
- Fix CompletedSteps() to include skipped steps for dependency resolution
- Fix reverse loop bug in saga compensation (use standard swap pattern)
- Add circuit breaker state change callbacks for Prometheus metrics

Phase 1 (Build Resilience):
- Add failure:retry to all component Kaniko build steps
- Add preflight registry health check before builds
- Add services-deployed sync point to decouple docs from critical path

Phase 2 (API Resilience):
- Add pipeline retry endpoint (POST /projects/{id}/pipelines/{number}/retry)
- Wire circuit breakers with metrics callbacks
- Add /health/circuits endpoint for circuit breaker status

Phase 3 (Saga Engine):
- Full domain model (Saga, SagaStep, RetryPolicy, BackoffType)
- PostgreSQL saga repository with CRUD and step management
- Saga executor with retry, compensation, skip step support
- Saga API handlers with CRUD and control operations

Phase 4 (Observability):
- Add saga metrics (total, step_duration, retry, circuit_breaker_state)
- Add logging fields (saga_id, saga_name, step_name)

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

262 lines
7.1 KiB
Go

package handlers
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/pkg/api"
)
// PipelineErrorResponse is the JSON representation of a pipeline error.
type PipelineErrorResponse struct {
Type string `json:"type"`
Message string `json:"message"`
IsWarning bool `json:"is_warning"`
}
// PipelineResponse is the JSON representation of a CI pipeline.
type PipelineResponse struct {
ID int64 `json:"id"`
Number int64 `json:"number"`
Status string `json:"status"`
Event string `json:"event"`
Branch string `json:"branch"`
Commit string `json:"commit"`
Message string `json:"message"`
Author string `json:"author"`
Started string `json:"started,omitempty"`
Finished string `json:"finished,omitempty"`
Errors []PipelineErrorResponse `json:"errors,omitempty"`
}
// ListPipelines returns recent CI pipeline executions for a project.
// GET /projects/{id}/pipelines
func (h *InfrastructureHandler) ListPipelines(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.ciProvider == nil {
api.WriteInternalError(w, r, "CI provider not configured")
return
}
pipelines, err := h.ciProvider.ListPipelines(ctx, h.defaultGitOwner, projectID)
if err != nil {
api.WriteNotFound(w, r, fmt.Sprintf("pipelines not found: %v", err))
return
}
resp := make([]PipelineResponse, len(pipelines))
for i, p := range pipelines {
resp[i] = PipelineResponse{
ID: p.ID,
Number: p.Number,
Status: p.Status,
Event: p.Event,
Branch: p.Branch,
Commit: p.Commit,
Message: p.Message,
Author: p.Author,
Started: formatTime(p.Started),
Finished: formatTime(p.Finished),
Errors: mapPipelineErrors(p.Errors),
}
}
api.WriteSuccess(w, r, resp)
}
// GetPipeline returns a specific CI pipeline execution for a project.
// GET /projects/{id}/pipelines/{number}
func (h *InfrastructureHandler) GetPipeline(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
numberStr := chi.URLParam(r, "number")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
number, err := strconv.ParseInt(numberStr, 10, 64)
if err != nil {
api.WriteBadRequest(w, r, "invalid pipeline number")
return
}
if h.ciProvider == nil {
api.WriteInternalError(w, r, "CI provider not configured")
return
}
p, err := h.ciProvider.GetPipeline(ctx, h.defaultGitOwner, projectID, number)
if err != nil {
api.WriteNotFound(w, r, fmt.Sprintf("pipeline not found: %v", err))
return
}
api.WriteSuccess(w, r, PipelineResponse{
ID: p.ID,
Number: p.Number,
Status: p.Status,
Event: p.Event,
Branch: p.Branch,
Commit: p.Commit,
Message: p.Message,
Author: p.Author,
Started: formatTime(p.Started),
Finished: formatTime(p.Finished),
Errors: mapPipelineErrors(p.Errors),
})
}
// formatTime formats a time.Time as RFC3339, returning empty string for zero time.
func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// mapPipelineErrors converts domain pipeline errors to response format.
func mapPipelineErrors(errors []domain.CIPipelineError) []PipelineErrorResponse {
if len(errors) == 0 {
return nil
}
resp := make([]PipelineErrorResponse, len(errors))
for i, e := range errors {
resp[i] = PipelineErrorResponse{
Type: e.Type,
Message: e.Message,
IsWarning: e.IsWarning,
}
}
return resp
}
// PipelineStepResponse is the JSON representation of a pipeline step.
type PipelineStepResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
ExitCode *int `json:"exit_code,omitempty"`
Duration int `json:"duration_seconds"`
Error string `json:"error,omitempty"`
Log string `json:"log,omitempty"`
}
// PipelineStepsResponse is the JSON representation of pipeline steps.
type PipelineStepsResponse struct {
PipelineNumber int64 `json:"pipeline_number"`
URL string `json:"url"`
Steps []PipelineStepResponse `json:"steps"`
}
// GetPipelineSteps returns detailed step information for a pipeline.
// GET /projects/{id}/pipelines/{number}/steps
func (h *InfrastructureHandler) GetPipelineSteps(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
numberStr := chi.URLParam(r, "number")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
number, err := strconv.ParseInt(numberStr, 10, 64)
if err != nil {
api.WriteBadRequest(w, r, "invalid pipeline number")
return
}
if h.ciProvider == nil {
api.WriteInternalError(w, r, "CI provider not configured")
return
}
steps, err := h.ciProvider.GetPipelineSteps(ctx, h.defaultGitOwner, projectID, number)
if err != nil {
api.WriteNotFound(w, r, fmt.Sprintf("pipeline steps not found: %v", err))
return
}
// Map to response
respSteps := make([]PipelineStepResponse, len(steps.Steps))
for i, s := range steps.Steps {
respSteps[i] = PipelineStepResponse{
ID: s.ID,
Name: s.Name,
Status: s.Status,
ExitCode: s.ExitCode,
Duration: s.Duration,
Error: s.Error,
Log: s.Log,
}
}
api.WriteSuccess(w, r, PipelineStepsResponse{
PipelineNumber: steps.PipelineNumber,
URL: steps.URL,
Steps: respSteps,
})
}
// RetryPipeline restarts a failed or stopped pipeline.
// POST /projects/{id}/pipelines/{number}/retry
func (h *InfrastructureHandler) RetryPipeline(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
numberStr := chi.URLParam(r, "number")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
if err := validateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
number, err := strconv.ParseInt(numberStr, 10, 64)
if err != nil {
api.WriteBadRequest(w, r, "invalid pipeline number")
return
}
if h.ciProvider == nil {
api.WriteInternalError(w, r, "CI provider not configured")
return
}
p, err := h.ciProvider.RetryPipeline(ctx, h.defaultGitOwner, projectID, number)
if err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to retry pipeline: %v", err))
return
}
api.WriteSuccess(w, r, PipelineResponse{
ID: p.ID,
Number: p.Number,
Status: p.Status,
Event: p.Event,
Branch: p.Branch,
Commit: p.Commit,
Message: p.Message,
Author: p.Author,
Started: formatTime(p.Started),
Finished: formatTime(p.Finished),
Errors: mapPipelineErrors(p.Errors),
})
}