rdev/internal/handlers/sessions.go
jordan 9226454b85
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: label-based undeploy, GC reconciliation, checkout/sessions, pool status
- Add UndeployAll() using label selectors to clean up monorepo components
  on project deletion (replaces name-based Undeploy in DeleteProject and
  the direct undeploy handler)
- Add ResourceGC background worker that periodically finds K8s resources
  whose project label has no matching DB record, deletes after 1h safety
  window
- Widen deployer client type from *kubernetes.Clientset to
  kubernetes.Interface for testability
- UndeployAll accumulates errors via errors.Join instead of failing fast
- Add checkout/checkin sidecar dev flow: temporary git tokens, branch
  checkout, review on checkin with cleanup workers
- Add interactive sessions: pod binding, command execution, SSE streaming,
  ephemeral preview URLs with session cleanup workers
- Add GET /workers/pool endpoint for aggregate capacity and queue depth
- Add sessions:read and sessions:execute auth scopes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:11:28 -07:00

416 lines
12 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// SessionsHandler handles interactive remote development session endpoints.
type SessionsHandler struct {
sessionService *service.SessionService
}
// NewSessionsHandler creates a new sessions handler.
func NewSessionsHandler(sessionService *service.SessionService) *SessionsHandler {
return &SessionsHandler{sessionService: sessionService}
}
// Mount registers the session routes.
func (h *SessionsHandler) Mount(r api.Router) {
r.Route("/projects/{id}/sessions", func(r chi.Router) {
// List sessions (read access)
r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/", h.List)
// Create session (execute access)
r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/", h.Create)
// Get session (read access)
r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/{sid}", h.Get)
// End session via checkin (execute access)
r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/{sid}/checkin", h.Checkin)
// Force-terminate session (admin only)
r.With(auth.RequireScope(auth.ScopeAdmin)).
Delete("/{sid}", h.Delete)
})
}
// CreateSessionRequest is the JSON body for creating a session.
type CreateSessionRequest struct {
Branch string `json:"branch,omitempty"`
NewBranch string `json:"new_branch,omitempty"`
FromRef string `json:"from_ref,omitempty"`
FeatureSlug string `json:"feature_slug,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d")
PreviewPort int `json:"preview_port,omitempty"` // Default: 8080
}
// SessionResponse is the JSON response for a session.
type SessionResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
CheckoutID string `json:"checkout_id"`
PodName string `json:"pod_name"`
PreviewURL string `json:"preview_url"`
Status string `json:"status"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
EndedAt *string `json:"ended_at,omitempty"`
AuthCloneURL string `json:"auth_clone_url,omitempty"` // Only at creation
Branch string `json:"branch,omitempty"` // Only at creation
Instructions string `json:"instructions,omitempty"` // Only at creation
}
// SessionCheckinRequest is the JSON body for ending a session.
type SessionCheckinRequest struct {
SkipReview bool `json:"skip_review,omitempty"`
AutoMerge bool `json:"auto_merge,omitempty"`
}
// SessionCheckinResponse is the JSON response for ending a session.
type SessionCheckinResponse struct {
SessionID string `json:"session_id"`
Status string `json:"status"`
ReviewTaskID string `json:"review_task_id,omitempty"`
Message string `json:"message"`
}
// List returns all sessions for a project.
// GET /projects/{id}/sessions
func (h *SessionsHandler) List(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
sessions, err := h.sessionService.ListByProject(ctx, domain.ProjectID(projectID))
if err != nil {
api.WriteInternalError(w, r, "Failed to list sessions")
return
}
resp := make([]SessionResponse, len(sessions))
for i, s := range sessions {
resp[i] = sessionToResponse(s)
}
api.WriteSuccess(w, r, map[string]any{
"sessions": resp,
})
}
// Create creates a new interactive development session.
// POST /projects/{id}/sessions
func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
var req CreateSessionRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Validate: need either branch or new_branch.
if req.Branch == "" && req.NewBranch == "" {
api.WriteBadRequest(w, r, "branch or new_branch is required")
return
}
if req.Branch != "" && req.NewBranch != "" {
api.WriteBadRequest(w, r, "specify either branch or new_branch, not both")
return
}
// Validate branch name if provided.
if req.NewBranch != "" {
if err := validate.Name(req.NewBranch, "new_branch"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
}
// Parse expiry duration.
var expiresIn time.Duration
if req.ExpiresIn != "" {
var err error
expiresIn, err = time.ParseDuration(req.ExpiresIn)
if err != nil {
// Try parsing as days (e.g., "7d").
if len(req.ExpiresIn) > 1 && req.ExpiresIn[len(req.ExpiresIn)-1] == 'd' {
days, parseErr := strconv.Atoi(req.ExpiresIn[:len(req.ExpiresIn)-1])
if parseErr == nil && days > 0 && days <= 30 {
expiresIn = time.Duration(days) * 24 * time.Hour
} else {
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
return
}
} else {
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
return
}
}
if expiresIn > 30*24*time.Hour {
api.WriteBadRequest(w, r, "expires_in cannot exceed 30 days")
return
}
}
// Get user from API key.
createdBy := "unknown"
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil {
createdBy = string(apiKey.ID)
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutOrchestration)
defer cancel()
result, err := h.sessionService.CreateSession(ctx, service.CreateSessionRequest{
ProjectID: domain.ProjectID(projectID),
Branch: req.Branch,
NewBranch: req.NewBranch,
FromRef: req.FromRef,
FeatureSlug: req.FeatureSlug,
ExpiresIn: expiresIn,
PreviewPort: req.PreviewPort,
CreatedBy: createdBy,
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
if errors.Is(err, domain.ErrProjectNotRunning) {
api.WriteBadRequest(w, r, "project pod is not running")
return
}
if errors.Is(err, domain.ErrSessionExists) {
api.WriteError(w, r, http.StatusConflict, "SESSION_EXISTS",
"active session already exists for this project")
return
}
if errors.Is(err, domain.ErrBranchNotFound) {
api.WriteNotFound(w, r, "branch not found")
return
}
if errors.Is(err, domain.ErrBranchProtected) {
api.WriteBadRequest(w, r, "cannot checkout protected branch")
return
}
if errors.Is(err, domain.ErrCheckoutAlreadyExists) {
api.WriteError(w, r, http.StatusConflict, "CHECKOUT_EXISTS",
"active checkout already exists for this branch")
return
}
api.WriteInternalError(w, r, "Failed to create session")
return
}
resp := sessionToResponse(result.Session)
resp.AuthCloneURL = result.AuthenticatedCloneURL
resp.Branch = result.Branch
resp.Instructions = result.Instructions
api.WriteCreated(w, r, resp)
}
// Get retrieves a session by ID.
// GET /projects/{id}/sessions/{sid}
func (h *SessionsHandler) Get(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
sid := chi.URLParam(r, "sid")
if sid == "" {
api.WriteBadRequest(w, r, "session id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
if err != nil {
if errors.Is(err, domain.ErrSessionNotFound) {
api.WriteNotFound(w, r, "session not found")
return
}
api.WriteInternalError(w, r, "Failed to get session")
return
}
// Verify session belongs to project.
if string(session.ProjectID) != projectID {
api.WriteNotFound(w, r, "session not found")
return
}
api.WriteSuccess(w, r, sessionToResponse(session))
}
// Checkin ends a session and optionally queues a review.
// POST /projects/{id}/sessions/{sid}/checkin
func (h *SessionsHandler) Checkin(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
sid := chi.URLParam(r, "sid")
if sid == "" {
api.WriteBadRequest(w, r, "session id is required")
return
}
var req SessionCheckinRequest
if err := api.DecodeJSON(r, &req); err != nil {
req = SessionCheckinRequest{}
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Verify session exists and belongs to project.
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
if err != nil {
if errors.Is(err, domain.ErrSessionNotFound) {
api.WriteNotFound(w, r, "session not found")
return
}
api.WriteInternalError(w, r, "Failed to get session")
return
}
if string(session.ProjectID) != projectID {
api.WriteNotFound(w, r, "session not found")
return
}
result, err := h.sessionService.EndSession(ctx, service.EndSessionRequest{
SessionID: domain.SessionID(sid),
SkipReview: req.SkipReview,
AutoMerge: req.AutoMerge,
})
if err != nil {
if errors.Is(err, domain.ErrSessionNotFound) {
api.WriteNotFound(w, r, "session not found")
return
}
if errors.Is(err, domain.ErrSessionNotActive) {
api.WriteBadRequest(w, r, "session is not active")
return
}
api.WriteInternalError(w, r, "Failed to end session")
return
}
message := "Session ended. Preview removed, token revoked."
if result.ReviewTaskID != "" {
message = "Session ended. Review task queued."
}
api.WriteSuccess(w, r, SessionCheckinResponse{
SessionID: string(result.SessionID),
Status: string(result.Status),
ReviewTaskID: result.ReviewTaskID,
Message: message,
})
}
// Delete force-terminates a session (admin only).
// DELETE /projects/{id}/sessions/{sid}
func (h *SessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, "invalid project id")
return
}
sid := chi.URLParam(r, "sid")
if sid == "" {
api.WriteBadRequest(w, r, "session id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// Verify session exists and belongs to project.
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
if err != nil {
if errors.Is(err, domain.ErrSessionNotFound) {
api.WriteNotFound(w, r, "session not found")
return
}
api.WriteInternalError(w, r, "Failed to get session")
return
}
if string(session.ProjectID) != projectID {
api.WriteNotFound(w, r, "session not found")
return
}
if err := h.sessionService.ForceEnd(ctx, domain.SessionID(sid)); err != nil {
if errors.Is(err, domain.ErrSessionNotFound) {
api.WriteNotFound(w, r, "session not found")
return
}
if errors.Is(err, domain.ErrSessionNotActive) {
api.WriteBadRequest(w, r, "session is not active")
return
}
api.WriteInternalError(w, r, "Failed to terminate session")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "terminated",
"id": sid,
"message": "Session force-terminated. Preview removed, token revoked.",
})
}
// sessionToResponse converts a domain session to a response.
func sessionToResponse(s *domain.Session) SessionResponse {
resp := SessionResponse{
ID: string(s.ID),
ProjectID: string(s.ProjectID),
CheckoutID: string(s.CheckoutID),
PodName: s.PodName,
PreviewURL: s.PreviewURL,
Status: string(s.Status),
CreatedBy: s.CreatedBy,
CreatedAt: s.CreatedAt.Format(time.RFC3339),
ExpiresAt: s.ExpiresAt.Format(time.RFC3339),
}
if s.EndedAt != nil {
t := s.EndedAt.Format(time.RFC3339)
resp.EndedAt = &t
}
return resp
}