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) }