Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- 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>
416 lines
12 KiB
Go
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
|
|
}
|