- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
234 lines
5.8 KiB
Go
234 lines
5.8 KiB
Go
// Package handlers provides HTTP handlers for the rdev API.
|
|
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/pkg/api"
|
|
)
|
|
|
|
// ExecutorHealthChecker reports whether a background executor is running.
|
|
type ExecutorHealthChecker interface {
|
|
Running() bool
|
|
WorkerID() string
|
|
}
|
|
|
|
// HealthHandler handles health and readiness checks.
|
|
type HealthHandler struct {
|
|
serviceName string
|
|
db port.DatabasePinger
|
|
k8sChecker port.KubernetesChecker
|
|
agentRegistry port.CodeAgentRegistry
|
|
workExecutor ExecutorHealthChecker
|
|
}
|
|
|
|
// NewHealthHandler creates a new health handler with dependencies.
|
|
func NewHealthHandler(serviceName string, db port.DatabasePinger, k8sChecker port.KubernetesChecker) *HealthHandler {
|
|
return &HealthHandler{
|
|
serviceName: serviceName,
|
|
db: db,
|
|
k8sChecker: k8sChecker,
|
|
}
|
|
}
|
|
|
|
// WithAgentRegistry adds a code agent registry for health monitoring.
|
|
func (h *HealthHandler) WithAgentRegistry(registry port.CodeAgentRegistry) *HealthHandler {
|
|
h.agentRegistry = registry
|
|
return h
|
|
}
|
|
|
|
// WithWorkExecutor adds a work executor for health monitoring.
|
|
func (h *HealthHandler) WithWorkExecutor(executor ExecutorHealthChecker) *HealthHandler {
|
|
h.workExecutor = executor
|
|
return h
|
|
}
|
|
|
|
// Health returns a simple liveness check.
|
|
// This should be lightweight and only fail if the process is unhealthy.
|
|
// GET /health
|
|
func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) {
|
|
api.WriteSuccess(w, r, map[string]string{
|
|
"status": "ok",
|
|
"service": h.serviceName,
|
|
})
|
|
}
|
|
|
|
// Ready returns a readiness check with dependency health.
|
|
// This checks all required dependencies (database, k8s) and returns
|
|
// 503 if any are unhealthy.
|
|
// GET /ready
|
|
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
checks := make(map[string]CheckResult)
|
|
allHealthy := true
|
|
|
|
// Database check
|
|
if h.db != nil {
|
|
dbCheck := h.checkDatabase(ctx)
|
|
checks["database"] = dbCheck
|
|
if !dbCheck.Healthy {
|
|
allHealthy = false
|
|
}
|
|
}
|
|
|
|
// Kubernetes check
|
|
if h.k8sChecker != nil {
|
|
k8sCheck := h.checkKubernetes(ctx)
|
|
checks["kubernetes"] = k8sCheck
|
|
if !k8sCheck.Healthy {
|
|
allHealthy = false
|
|
}
|
|
}
|
|
|
|
// Code agent checks (informational - don't affect overall readiness)
|
|
if h.agentRegistry != nil {
|
|
agentChecks := h.checkCodeAgents(ctx)
|
|
for name, check := range agentChecks {
|
|
checks["agent:"+name] = check
|
|
}
|
|
}
|
|
|
|
// Work executor check (informational)
|
|
if h.workExecutor != nil {
|
|
checks["work_executor"] = h.checkWorkExecutor()
|
|
}
|
|
|
|
response := ReadinessResponse{
|
|
Status: "ready",
|
|
Service: h.serviceName,
|
|
Checks: checks,
|
|
}
|
|
|
|
if !allHealthy {
|
|
response.Status = "not_ready"
|
|
api.WriteError(w, r, http.StatusServiceUnavailable, "NOT_READY",
|
|
"Service not ready - one or more checks failed")
|
|
return
|
|
}
|
|
|
|
api.WriteSuccess(w, r, response)
|
|
}
|
|
|
|
// checkDatabase performs a database health check.
|
|
func (h *HealthHandler) checkDatabase(ctx context.Context) CheckResult {
|
|
start := time.Now()
|
|
err := h.db.PingContext(ctx)
|
|
latency := time.Since(start)
|
|
|
|
if err != nil {
|
|
return CheckResult{
|
|
Healthy: false,
|
|
Message: "connection failed: " + err.Error(),
|
|
Latency: latency.String(),
|
|
LastCheck: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
return CheckResult{
|
|
Healthy: true,
|
|
Message: "connected",
|
|
Latency: latency.String(),
|
|
LastCheck: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// checkKubernetes performs a Kubernetes API health check.
|
|
func (h *HealthHandler) checkKubernetes(_ context.Context) CheckResult {
|
|
start := time.Now()
|
|
|
|
// Try to get server version - lightweight API call
|
|
_, err := h.k8sChecker.ServerVersion()
|
|
latency := time.Since(start)
|
|
|
|
if err != nil {
|
|
// Check if it's a timeout or connection error
|
|
msg := err.Error()
|
|
if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") {
|
|
msg = "connection timeout"
|
|
} else if strings.Contains(msg, "refused") {
|
|
msg = "connection refused"
|
|
}
|
|
|
|
return CheckResult{
|
|
Healthy: false,
|
|
Message: msg,
|
|
Latency: latency.String(),
|
|
LastCheck: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
return CheckResult{
|
|
Healthy: true,
|
|
Message: "connected",
|
|
Latency: latency.String(),
|
|
LastCheck: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// checkCodeAgents performs health checks on all registered code agents.
|
|
func (h *HealthHandler) checkCodeAgents(ctx context.Context) map[string]CheckResult {
|
|
results := make(map[string]CheckResult)
|
|
|
|
providers := h.agentRegistry.Available()
|
|
for _, provider := range providers {
|
|
agent := h.agentRegistry.Get(provider)
|
|
if agent == nil {
|
|
continue
|
|
}
|
|
|
|
start := time.Now()
|
|
available := agent.Available(ctx)
|
|
latency := time.Since(start)
|
|
|
|
msg := "available"
|
|
if !available {
|
|
msg = "unavailable"
|
|
}
|
|
|
|
results[string(provider)] = CheckResult{
|
|
Healthy: available,
|
|
Message: fmt.Sprintf("%s (%s)", msg, agent.Name()),
|
|
Latency: latency.String(),
|
|
LastCheck: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
// checkWorkExecutor checks whether the work executor is running.
|
|
func (h *HealthHandler) checkWorkExecutor() CheckResult {
|
|
running := h.workExecutor.Running()
|
|
msg := fmt.Sprintf("worker %s: running", h.workExecutor.WorkerID())
|
|
if !running {
|
|
msg = fmt.Sprintf("worker %s: stopped", h.workExecutor.WorkerID())
|
|
}
|
|
return CheckResult{
|
|
Healthy: running,
|
|
Message: msg,
|
|
LastCheck: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// CheckResult represents the result of a health check.
|
|
type CheckResult struct {
|
|
Healthy bool `json:"healthy"`
|
|
Message string `json:"message"`
|
|
Latency string `json:"latency,omitempty"`
|
|
LastCheck time.Time `json:"last_check"`
|
|
}
|
|
|
|
// ReadinessResponse is the response for the /ready endpoint.
|
|
type ReadinessResponse struct {
|
|
Status string `json:"status"`
|
|
Service string `json:"service"`
|
|
Checks map[string]CheckResult `json:"checks,omitempty"`
|
|
}
|