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