rdev/internal/service/session_service.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

384 lines
11 KiB
Go

// Package service provides business logic services.
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// SessionService orchestrates interactive remote development sessions.
// A session composes checkout (git token) + pod binding + ephemeral preview URL.
type SessionService struct {
sessionRepo port.SessionRepository
checkoutSvc *CheckoutService
projectRepo port.ProjectRepository
previewMgr port.PreviewManager
previewDomain string
defaultExpiry time.Duration
}
// SessionServiceConfig holds configuration for the session service.
type SessionServiceConfig struct {
// PreviewDomain is the base domain for preview URLs (e.g., "preview.threesix.ai").
PreviewDomain string
// DefaultExpiry is the default session duration (default: 24h).
DefaultExpiry time.Duration
}
// NewSessionService creates a new session service.
func NewSessionService(
sessionRepo port.SessionRepository,
checkoutSvc *CheckoutService,
projectRepo port.ProjectRepository,
previewMgr port.PreviewManager,
cfg SessionServiceConfig,
) *SessionService {
expiry := cfg.DefaultExpiry
if expiry == 0 {
expiry = 24 * time.Hour
}
previewDomain := cfg.PreviewDomain
if previewDomain == "" {
previewDomain = "preview.threesix.ai"
}
return &SessionService{
sessionRepo: sessionRepo,
checkoutSvc: checkoutSvc,
projectRepo: projectRepo,
previewMgr: previewMgr,
previewDomain: previewDomain,
defaultExpiry: expiry,
}
}
// CreateSessionRequest contains parameters for creating a session.
type CreateSessionRequest struct {
ProjectID domain.ProjectID
Branch string
NewBranch string
FromRef string
FeatureSlug string
ExpiresIn time.Duration
PreviewPort int
CreatedBy string
}
// SessionResult contains the result of a session creation.
type SessionResult struct {
Session *domain.Session
AuthenticatedCloneURL string
Branch string
Instructions string
}
// CreateSession creates a new interactive development session.
func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionRequest) (*SessionResult, error) {
log := logging.FromContext(ctx).WithService("SessionService")
// Verify project exists and get pod info.
project, err := s.projectRepo.Get(ctx, req.ProjectID)
if err != nil {
return nil, fmt.Errorf("get project: %w", err)
}
if project.PodName == "" {
return nil, domain.ErrProjectNotRunning
}
// Check no active session exists for this project.
existing, err := s.sessionRepo.GetActiveByProject(ctx, req.ProjectID)
if err == nil && existing != nil {
if existing.IsExpired() {
// Cleanup the expired session inline.
_ = s.teardownSession(ctx, existing)
} else {
return nil, domain.ErrSessionExists
}
}
// Calculate expiry.
expiry := s.defaultExpiry
if req.ExpiresIn > 0 {
expiry = req.ExpiresIn
}
// Create checkout (git token + branch).
checkoutResult, err := s.checkoutSvc.Checkout(ctx, CheckoutRequest{
ProjectID: req.ProjectID,
Branch: req.Branch,
NewBranch: req.NewBranch,
FromRef: req.FromRef,
FeatureSlug: req.FeatureSlug,
ExpiresIn: expiry,
CheckedOutBy: req.CreatedBy,
})
if err != nil {
return nil, fmt.Errorf("create checkout: %w", err)
}
// Generate preview slug (short random hex).
slug, err := generatePreviewSlug()
if err != nil {
return nil, fmt.Errorf("generate preview slug: %w", err)
}
previewHost := slug + "." + s.previewDomain
previewURL := "https://" + previewHost
// Determine preview port.
previewPort := req.PreviewPort
if previewPort == 0 {
previewPort = 8080
}
// Create session record first to get the ID.
session := &domain.Session{
ProjectID: req.ProjectID,
CheckoutID: checkoutResult.Checkout.ID,
PodName: project.PodName,
PreviewURL: previewURL,
PreviewHost: previewHost,
CreatedBy: req.CreatedBy,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(expiry),
Status: domain.SessionStatusActive,
}
if err := s.sessionRepo.Create(ctx, session); err != nil {
// Rollback: revoke checkout token.
_ = s.checkoutSvc.Revoke(ctx, checkoutResult.Checkout.ID)
return nil, fmt.Errorf("create session record: %w", err)
}
// Create K8s preview (Service + Ingress).
if err := s.previewMgr.CreatePreview(ctx, port.PreviewOptions{
SessionID: string(session.ID),
Namespace: "rdev",
PodName: project.PodName,
Host: previewHost,
Port: previewPort,
}); err != nil {
// Rollback: mark session ended and revoke checkout.
_ = s.sessionRepo.SetEnded(ctx, session.ID)
_ = s.checkoutSvc.Revoke(ctx, checkoutResult.Checkout.ID)
return nil, fmt.Errorf("create preview: %w", err)
}
log.Info("session created",
logging.FieldProjectID, req.ProjectID,
"session_id", session.ID,
"preview_url", previewURL,
"pod", project.PodName,
"branch", checkoutResult.Checkout.Branch,
)
branch := checkoutResult.Checkout.Branch
instructions := fmt.Sprintf(`Session started.
Preview URL: %s
Clone URL: %s
Branch: %s
Pod: %s
Run commands via:
POST /projects/%s/claude (Claude Code commands)
POST /projects/%s/shell (shell commands)
GET /projects/%s/stream (SSE output)
End session:
POST /projects/%s/sessions/%s/checkin
Session expires: %s`,
previewURL,
checkoutResult.AuthenticatedCloneURL,
branch,
project.PodName,
req.ProjectID, req.ProjectID, req.ProjectID,
req.ProjectID, session.ID,
session.ExpiresAt.Format(time.RFC3339),
)
return &SessionResult{
Session: session,
AuthenticatedCloneURL: checkoutResult.AuthenticatedCloneURL,
Branch: branch,
Instructions: instructions,
}, nil
}
// EndSessionRequest contains parameters for ending a session.
type EndSessionRequest struct {
SessionID domain.SessionID
SkipReview bool
AutoMerge bool
}
// EndSessionResult contains the result of ending a session.
type EndSessionResult struct {
SessionID domain.SessionID
ReviewTaskID string
Status domain.SessionStatus
}
// EndSession ends an active session, tearing down preview and completing checkout.
func (s *SessionService) EndSession(ctx context.Context, req EndSessionRequest) (*EndSessionResult, error) {
log := logging.FromContext(ctx).WithService("SessionService")
session, err := s.sessionRepo.Get(ctx, req.SessionID)
if err != nil {
return nil, err
}
if session.Status != domain.SessionStatusActive {
return nil, domain.ErrSessionNotActive
}
// Delete preview (Service + Ingress).
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
log.Warn("failed to delete preview",
"session_id", session.ID,
logging.FieldError, err,
)
// Continue — preview cleanup failure shouldn't block checkin.
}
// Complete checkout (revoke token, queue review).
var reviewTaskID string
checkinResult, err := s.checkoutSvc.Checkin(ctx, CheckinRequest{
CheckoutID: session.CheckoutID,
SkipReview: req.SkipReview,
AutoMerge: req.AutoMerge,
})
if err != nil {
log.Warn("failed to checkin checkout",
"session_id", session.ID,
"checkout_id", session.CheckoutID,
logging.FieldError, err,
)
// Continue — we still want to mark the session ended.
} else {
reviewTaskID = checkinResult.ReviewTaskID
}
// Mark session ended.
if err := s.sessionRepo.SetEnded(ctx, session.ID); err != nil {
return nil, fmt.Errorf("set session ended: %w", err)
}
log.Info("session ended",
logging.FieldProjectID, session.ProjectID,
"session_id", session.ID,
"review_queued", reviewTaskID != "",
)
return &EndSessionResult{
SessionID: session.ID,
ReviewTaskID: reviewTaskID,
Status: domain.SessionStatusEnded,
}, nil
}
// Get retrieves a session by ID.
func (s *SessionService) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
return s.sessionRepo.Get(ctx, id)
}
// ListByProject returns all sessions for a project.
func (s *SessionService) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
return s.sessionRepo.ListByProject(ctx, projectID)
}
// ForceEnd forcefully ends a session without checkout checkin (admin use).
func (s *SessionService) ForceEnd(ctx context.Context, id domain.SessionID) error {
log := logging.FromContext(ctx).WithService("SessionService")
session, err := s.sessionRepo.Get(ctx, id)
if err != nil {
return err
}
if session.Status != domain.SessionStatusActive {
return domain.ErrSessionNotActive
}
// Delete preview.
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
log.Warn("failed to delete preview on force-end",
"session_id", session.ID,
logging.FieldError, err,
)
}
// Revoke checkout token.
if err := s.checkoutSvc.Revoke(ctx, session.CheckoutID); err != nil {
log.Warn("failed to revoke checkout on force-end",
"session_id", session.ID,
"checkout_id", session.CheckoutID,
logging.FieldError, err,
)
}
// Mark session ended.
if err := s.sessionRepo.SetEnded(ctx, session.ID); err != nil {
return fmt.Errorf("set session ended: %w", err)
}
log.Info("session force-ended",
logging.FieldProjectID, session.ProjectID,
"session_id", session.ID,
)
return nil
}
// CleanupExpired finds and tears down expired sessions.
// Returns the number of sessions cleaned up.
func (s *SessionService) CleanupExpired(ctx context.Context) (int, error) {
log := logging.FromContext(ctx).WithService("SessionService")
sessions, err := s.sessionRepo.CleanupExpired(ctx)
if err != nil {
return 0, fmt.Errorf("cleanup expired sessions: %w", err)
}
for _, session := range sessions {
// Delete preview resources.
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
log.Warn("failed to delete preview for expired session",
"session_id", session.ID,
logging.FieldError, err,
)
}
// Revoke checkout token (the checkout cleanup may also do this).
_ = s.checkoutSvc.Revoke(ctx, session.CheckoutID)
}
if len(sessions) > 0 {
log.Info("cleaned up expired sessions", "count", len(sessions))
}
return len(sessions), nil
}
// teardownSession handles cleanup of an expired session found inline.
func (s *SessionService) teardownSession(ctx context.Context, session *domain.Session) error {
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
return err
}
return s.sessionRepo.SetEnded(ctx, session.ID)
}
// generatePreviewSlug generates a short random hex string for preview URLs.
func generatePreviewSlug() (string, error) {
b := make([]byte, 6) // 12 hex chars
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate random bytes: %w", err)
}
return hex.EncodeToString(b), nil
}