dev-pod-api-build/internal/api/router.go
2026-04-16 04:16:36 +00:00

204 lines
8.2 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
chiMiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/k8s"
"github.com/iliaivanov/spec-kit-remote/cmd/dev-pod-api/internal/model"
)
// PodManager defines Kubernetes pod operations needed by the API handlers.
type PodManager interface {
CreatePod(ctx context.Context, opts k8s.CreatePodOpts) (*model.Pod, error)
DeletePod(ctx context.Context, user, pod string) error
DeleteAllPods(ctx context.Context, user string) error
ListPods(ctx context.Context, user string) ([]model.Pod, error)
GetPod(ctx context.Context, user, pod string) (*model.Pod, error)
}
// UserStore defines user operations needed by the API handlers.
type UserStore interface {
GetUser(ctx context.Context, id string) (*model.User, error)
ListUsers(ctx context.Context) ([]model.User, error)
CreateUser(ctx context.Context, id string, quota model.Quota) (*model.User, error)
DeleteUser(ctx context.Context, id string) error
UpdateQuotas(ctx context.Context, id string, req model.UpdateQuotasRequest) (*model.User, error)
CreateAPIKey(ctx context.Context, userID, role, keyHash string) error
SaveForgejoToken(ctx context.Context, userID, token string) error
GetForgejoToken(ctx context.Context, userID string) (string, error)
SaveTailscaleKey(ctx context.Context, userID, key string) error
GetTailscaleKey(ctx context.Context, userID string) (string, error)
}
// UsageStore defines usage tracking operations needed by the API handlers.
type UsageStore interface {
RecordPodStart(ctx context.Context, userID, podName string) error
RecordPodStop(ctx context.Context, userID, podName string) error
RecordResourceSample(ctx context.Context, userID, podName string, cpuMillicores, memBytes float64) error
RecordAIRequest(ctx context.Context, userID string) error
GetUsage(ctx context.Context, userID string, year int, month time.Month, monthlyBudget int) (*model.UsageSummary, error)
GetDailyUsage(ctx context.Context, userID string, year int, month time.Month) ([]model.DailyUsage, error)
}
// ClusterInfoProvider defines operations for cluster status and cache stats.
type ClusterInfoProvider interface {
GetClusterStatus(ctx context.Context) (*model.ClusterStatus, error)
GetCacheStats(ctx context.Context) ([]model.CacheStat, error)
}
// ForgejoManager handles user lifecycle in the central Forgejo instance.
// Optional — if nil, Forgejo integration is skipped.
type ForgejoManager interface {
CreateForgejoUser(ctx context.Context, username string) (token string, err error)
DeleteForgejoUser(ctx context.Context, username string) error
GetRepoFileContent(ctx context.Context, owner, repo, filepath, ref string) ([]byte, error)
CreateIssueComment(ctx context.Context, owner, repo string, issueNumber int, body string) error
}
// RunnerStore defines runner database operations needed by the API handlers.
type RunnerStore interface {
CreateRunner(ctx context.Context, r *model.Runner) error
GetRunner(ctx context.Context, id string) (*model.Runner, error)
ListRunners(ctx context.Context, userFilter string, statusFilter string) ([]model.Runner, error)
UpdateRunnerStatus(ctx context.Context, id string, newStatus model.RunnerStatus, forgejoRunnerID string) error
DeleteRunner(ctx context.Context, id string) error
IsDeliveryProcessed(ctx context.Context, deliveryID string) (bool, error)
GetStaleRunners(ctx context.Context, ttl time.Duration) ([]model.Runner, error)
}
// RunnerPodManager defines k8s operations for runner pod lifecycle.
type RunnerPodManager interface {
CreateRunnerPod(ctx context.Context, opts k8s.CreateRunnerPodOpts) (string, error)
DeleteRunnerPod(ctx context.Context, user, podName string) error
}
// KeyGenerator generates a new API key returning (plaintext, hash, error).
type KeyGenerator func() (string, string, error)
// Server holds dependencies for HTTP handlers.
type Server struct {
Store KeyValidator
K8s PodManager
Cluster ClusterInfoProvider
Users UserStore
Usage UsageStore
Forgejo ForgejoManager
Logger *slog.Logger
GenerateKey KeyGenerator
Runners RunnerStore
RunnerPods RunnerPodManager
// JWTSigner, when non-nil, enables the web-tui auth-exchange endpoint
// (/api/v1/auth/exchange) and the public JWKS at /.well-known/jwks.json.
// Both are optional — leave nil to keep the endpoints returning 503.
JWTSigner JWTSigner
// SessionHost, when non-nil, enables the web-tui session-host endpoints
// (POST /api/v1/pods/session-host, GET /api/v1/pods/session-host/{user}/status).
SessionHost SessionHostManager
// WebhookSecret is the HMAC-SHA256 secret for validating Forgejo webhook signatures.
WebhookSecret string
// ForgejoRunnerToken is a pre-generated Forgejo Actions runner registration token.
// Generated via `forgejo forgejo-cli actions generate-runner-token` and passed
// to all runner pods. The Forgejo REST API does not expose this endpoint.
ForgejoRunnerToken string
}
// NewRouter creates a chi router with all middleware and routes.
func NewRouter(srv *Server) *chi.Mux {
r := chi.NewRouter()
// Global middleware
r.Use(chiMiddleware.RequestID)
r.Use(chiMiddleware.RealIP)
r.Use(chiMiddleware.Recoverer)
r.Use(RequestLogger(srv.Logger))
// Health check — no auth required
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
})
// JWKS — public, no auth. web-tui-gateway fetches this to verify
// the JWTs it receives on WS attachments. Responds 503 when no
// JWTSigner is configured so operators notice a misconfiguration.
r.Get("/.well-known/jwks.json", srv.handleJWKS)
// Internal endpoints — cluster-internal only, no auth required.
// Runner pods call back to report status transitions.
r.Post("/internal/runners/{id}/callback", srv.handleRunnerCallback)
// Forgejo webhook — HMAC-SHA256 auth, not bearer token.
// Registered outside /api/v1 auth group because Forgejo uses HMAC signatures.
r.Post("/api/v1/webhooks/forgejo", srv.handleForgejoWebhook)
// Rate limiter: 60 req/min = 1 req/s, burst of 60
limiter := NewRateLimiter(1.0, 60)
// API routes — auth required
r.Route("/api/v1", func(r chi.Router) {
r.Use(MaxBodySize(1 << 20)) // 1 MB
r.Use(AuthMiddleware(srv.Store))
r.Use(limiter.Middleware)
// Pod management
r.Post("/pods", srv.handleCreatePod)
r.Get("/pods", srv.handleListAllPods)
r.Get("/pods/{user}", srv.handleListUserPods)
r.Get("/pods/{user}/{pod}", srv.handleGetPod)
r.Delete("/pods/{user}", srv.handleDeleteAllPods)
r.Delete("/pods/{user}/{pod}", srv.handleDeletePod)
r.Patch("/pods/{user}/{pod}", srv.handleUpdatePod)
// User management
r.Post("/users", srv.handleCreateUser)
r.Get("/users", srv.handleListUsers)
r.Get("/users/{user}", srv.handleGetUser)
r.Delete("/users/{user}", srv.handleDeleteUser)
r.Patch("/users/{user}/quotas", srv.handleUpdateQuotas)
// Billing & usage
r.Get("/users/{user}/usage", srv.handleGetUserUsage)
r.Get("/billing/summary", srv.handleBillingSummary)
r.Get("/billing/{user}/history", srv.handleBillingHistory)
// Cluster info
r.Get("/cluster/status", srv.handleClusterStatus)
r.Get("/cache/stats", srv.handleCacheStats)
// Runner management
r.Post("/runners", srv.handleCreateRunner)
r.Get("/runners", srv.handleListRunners)
r.Delete("/runners/{id}", srv.handleDeleteRunner)
r.Post("/runners/{id}/status", srv.handleUpdateRunnerStatus)
// web-tui — JWT exchange (auth) + session-host pod lifecycle.
// Enabled when the Server has JWTSigner / SessionHost configured.
r.Post("/auth/exchange", srv.handleAuthExchange)
r.Post("/pods/session-host", srv.handleCreateSessionHost)
r.Get("/pods/session-host/{user}/status", srv.handleSessionHostStatus)
})
return r
}
// writeError writes a JSON error response.
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
// writeJSON writes a JSON response with the given status code.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}