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>
480 lines
13 KiB
Go
480 lines
13 KiB
Go
// Package service provides business logic services.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// CheckoutService orchestrates checkout/checkin operations.
|
|
// It manages temporary git access tokens for local development.
|
|
type CheckoutService struct {
|
|
checkoutRepo port.CheckoutRepository
|
|
gitRepo port.GitRepository
|
|
workQueue port.WorkQueue
|
|
projectRepo port.ProjectRepository
|
|
|
|
// giteaURL is the base Gitea URL for building clone URLs.
|
|
giteaURL string
|
|
|
|
// defaultOwner is the default git org/user for repositories.
|
|
defaultOwner string
|
|
|
|
// defaultExpiry is the default checkout token expiry duration.
|
|
defaultExpiry time.Duration
|
|
}
|
|
|
|
// CheckoutServiceConfig holds configuration for the checkout service.
|
|
type CheckoutServiceConfig struct {
|
|
GiteaURL string
|
|
DefaultOwner string
|
|
DefaultExpiry time.Duration
|
|
}
|
|
|
|
// NewCheckoutService creates a new checkout service.
|
|
func NewCheckoutService(
|
|
checkoutRepo port.CheckoutRepository,
|
|
gitRepo port.GitRepository,
|
|
projectRepo port.ProjectRepository,
|
|
cfg CheckoutServiceConfig,
|
|
) *CheckoutService {
|
|
expiry := cfg.DefaultExpiry
|
|
if expiry == 0 {
|
|
expiry = 24 * time.Hour
|
|
}
|
|
return &CheckoutService{
|
|
checkoutRepo: checkoutRepo,
|
|
gitRepo: gitRepo,
|
|
projectRepo: projectRepo,
|
|
giteaURL: cfg.GiteaURL,
|
|
defaultOwner: cfg.DefaultOwner,
|
|
defaultExpiry: expiry,
|
|
}
|
|
}
|
|
|
|
// WithWorkQueue adds a work queue for review task enqueueing.
|
|
func (s *CheckoutService) WithWorkQueue(queue port.WorkQueue) *CheckoutService {
|
|
s.workQueue = queue
|
|
return s
|
|
}
|
|
|
|
// CheckoutRequest contains parameters for checking out a project.
|
|
type CheckoutRequest struct {
|
|
// ProjectID is the project to checkout.
|
|
ProjectID domain.ProjectID
|
|
|
|
// Branch is the branch to checkout. If empty and NewBranch is set, creates NewBranch from main.
|
|
Branch string
|
|
|
|
// NewBranch is an optional new branch name to create.
|
|
NewBranch string
|
|
|
|
// FromRef is the reference to create the new branch from (default: "main").
|
|
FromRef string
|
|
|
|
// FeatureSlug is an optional SDLC feature to link.
|
|
FeatureSlug string
|
|
|
|
// ExpiresIn is the optional token expiry duration (default: 24h).
|
|
ExpiresIn time.Duration
|
|
|
|
// CheckedOutBy is the user/key creating the checkout.
|
|
CheckedOutBy string
|
|
}
|
|
|
|
// CheckoutResult contains the result of a checkout operation.
|
|
type CheckoutResult struct {
|
|
// Checkout is the created checkout record.
|
|
Checkout *domain.Checkout
|
|
|
|
// AuthenticatedCloneURL is the clone URL with embedded token.
|
|
// This is only available at creation time and should not be stored.
|
|
AuthenticatedCloneURL string
|
|
|
|
// Instructions are human-readable instructions for using the checkout.
|
|
Instructions string
|
|
}
|
|
|
|
// Checkout creates a new checkout session with a temporary git token.
|
|
func (s *CheckoutService) Checkout(ctx context.Context, req CheckoutRequest) (*CheckoutResult, error) {
|
|
log := logging.FromContext(ctx).WithService("CheckoutService")
|
|
|
|
// Validate project exists
|
|
project, err := s.projectRepo.Get(ctx, req.ProjectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get project: %w", err)
|
|
}
|
|
|
|
// Determine branch
|
|
branch := req.Branch
|
|
if req.NewBranch != "" {
|
|
branch = req.NewBranch
|
|
}
|
|
if branch == "" {
|
|
return nil, fmt.Errorf("branch or new_branch is required")
|
|
}
|
|
|
|
// Get repo info (owner/name from project)
|
|
repoOwner := s.defaultOwner
|
|
repoName := string(project.ID)
|
|
|
|
// Check if branch exists or needs to be created
|
|
if req.NewBranch != "" {
|
|
fromRef := req.FromRef
|
|
if fromRef == "" {
|
|
fromRef = "main"
|
|
}
|
|
|
|
log.Info("creating new branch",
|
|
logging.FieldProjectID, req.ProjectID,
|
|
"branch", req.NewBranch,
|
|
"from_ref", fromRef,
|
|
)
|
|
|
|
_, err := s.gitRepo.CreateBranch(ctx, repoOwner, repoName, req.NewBranch, fromRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create branch: %w", err)
|
|
}
|
|
} else {
|
|
// Verify branch exists
|
|
branches, err := s.gitRepo.ListBranches(ctx, repoOwner, repoName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list branches: %w", err)
|
|
}
|
|
|
|
found := false
|
|
for _, b := range branches {
|
|
if b.Name == branch {
|
|
found = true
|
|
if b.Protected {
|
|
return nil, domain.ErrBranchProtected
|
|
}
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, domain.ErrBranchNotFound
|
|
}
|
|
}
|
|
|
|
// Check for existing active checkout on this branch
|
|
existing, err := s.checkoutRepo.GetByProjectBranch(ctx, req.ProjectID, branch)
|
|
if err == nil && existing != nil {
|
|
// Check if expired (cleanup job may not have run)
|
|
if existing.IsExpired() {
|
|
// Mark as expired and revoke token
|
|
_ = s.checkoutRepo.UpdateStatus(ctx, existing.ID, domain.CheckoutStatusExpired)
|
|
_ = s.gitRepo.DeleteAccessToken(ctx, existing.GiteaTokenID)
|
|
} else {
|
|
return nil, domain.ErrCheckoutAlreadyExists
|
|
}
|
|
}
|
|
|
|
// Calculate expiry
|
|
expiry := s.defaultExpiry
|
|
if req.ExpiresIn > 0 {
|
|
expiry = req.ExpiresIn
|
|
}
|
|
expiresAt := time.Now().Add(expiry)
|
|
|
|
// Create Gitea access token
|
|
tokenName := fmt.Sprintf("checkout-%s-%s-%d", repoName, branch, time.Now().Unix())
|
|
token, err := s.gitRepo.CreateAccessToken(ctx, tokenName, []string{"write:repository"}, &expiresAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create access token: %w", err)
|
|
}
|
|
|
|
// Build clone URLs (authenticated for return, base for storage)
|
|
authCloneURL, baseCloneURL, err := s.buildCloneURLs(token.Token, repoOwner, repoName)
|
|
if err != nil {
|
|
// Clean up token on error
|
|
_ = s.gitRepo.DeleteAccessToken(ctx, token.ID)
|
|
return nil, fmt.Errorf("build clone url: %w", err)
|
|
}
|
|
|
|
// Create checkout record (stores base URL without token)
|
|
checkout := &domain.Checkout{
|
|
ProjectID: req.ProjectID,
|
|
Branch: branch,
|
|
FeatureSlug: req.FeatureSlug,
|
|
GiteaTokenID: token.ID,
|
|
GiteaTokenName: token.Name,
|
|
CloneURL: baseCloneURL, // Base URL only - no token
|
|
CheckedOutBy: req.CheckedOutBy,
|
|
CheckedOutAt: time.Now(),
|
|
ExpiresAt: expiresAt,
|
|
Status: domain.CheckoutStatusActive,
|
|
}
|
|
|
|
if err := s.checkoutRepo.Create(ctx, checkout); err != nil {
|
|
// Clean up token on error
|
|
_ = s.gitRepo.DeleteAccessToken(ctx, token.ID)
|
|
return nil, fmt.Errorf("create checkout record: %w", err)
|
|
}
|
|
|
|
log.Info("checkout created",
|
|
logging.FieldProjectID, req.ProjectID,
|
|
"checkout_id", checkout.ID,
|
|
"branch", branch,
|
|
"expires_at", expiresAt,
|
|
)
|
|
|
|
instructions := fmt.Sprintf(`Clone with:
|
|
git clone %s
|
|
cd %s
|
|
git checkout %s
|
|
|
|
Work locally, then push and call checkin when ready.
|
|
Token expires: %s`, authCloneURL, repoName, branch, expiresAt.Format(time.RFC3339))
|
|
|
|
return &CheckoutResult{
|
|
Checkout: checkout,
|
|
AuthenticatedCloneURL: authCloneURL, // Only returned once at creation
|
|
Instructions: instructions,
|
|
}, nil
|
|
}
|
|
|
|
// CheckinRequest contains parameters for checking in.
|
|
type CheckinRequest struct {
|
|
// CheckoutID is the checkout to complete.
|
|
CheckoutID domain.CheckoutID
|
|
|
|
// SkipReview skips the automatic review (merge directly if desired).
|
|
SkipReview bool
|
|
|
|
// AutoMerge attempts to merge to main if review passes.
|
|
AutoMerge bool
|
|
}
|
|
|
|
// CheckinResult contains the result of a checkin operation.
|
|
type CheckinResult struct {
|
|
// CheckoutID is the completed checkout ID.
|
|
CheckoutID domain.CheckoutID
|
|
|
|
// ReviewTaskID is the ID of the queued review task (if review requested).
|
|
ReviewTaskID string
|
|
|
|
// Status is the current checkout status.
|
|
Status domain.CheckoutStatus
|
|
}
|
|
|
|
// Checkin completes a checkout and optionally queues a review.
|
|
func (s *CheckoutService) Checkin(ctx context.Context, req CheckinRequest) (*CheckinResult, error) {
|
|
log := logging.FromContext(ctx).WithService("CheckoutService")
|
|
|
|
// Get checkout
|
|
checkout, err := s.checkoutRepo.Get(ctx, req.CheckoutID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Verify active
|
|
if checkout.Status != domain.CheckoutStatusActive {
|
|
return nil, domain.ErrCheckoutNotActive
|
|
}
|
|
|
|
// Revoke token immediately (security: token should not be usable after checkin)
|
|
if err := s.gitRepo.DeleteAccessToken(ctx, checkout.GiteaTokenID); err != nil {
|
|
log.Warn("failed to revoke checkout token",
|
|
"checkout_id", checkout.ID,
|
|
"token_id", checkout.GiteaTokenID,
|
|
logging.FieldError, err,
|
|
)
|
|
// Continue anyway - token revocation failure shouldn't block checkin
|
|
}
|
|
|
|
var reviewTaskID string
|
|
|
|
// Queue review task if requested
|
|
if !req.SkipReview && s.workQueue != nil {
|
|
task := &domain.WorkTask{
|
|
ProjectID: string(checkout.ProjectID),
|
|
Type: domain.WorkTaskType("review_checkout"),
|
|
Spec: map[string]any{
|
|
"checkout_id": string(checkout.ID),
|
|
"branch": checkout.Branch,
|
|
"feature_slug": checkout.FeatureSlug,
|
|
"auto_merge": req.AutoMerge,
|
|
},
|
|
Priority: 5, // Normal priority
|
|
MaxRetries: 1, // Reviews shouldn't retry automatically
|
|
}
|
|
|
|
taskID, err := s.workQueue.Enqueue(ctx, task)
|
|
if err != nil {
|
|
log.Warn("failed to enqueue review task",
|
|
"checkout_id", checkout.ID,
|
|
logging.FieldError, err,
|
|
)
|
|
} else {
|
|
reviewTaskID = taskID
|
|
log.Info("review task queued",
|
|
"checkout_id", checkout.ID,
|
|
"task_id", taskID,
|
|
)
|
|
}
|
|
}
|
|
|
|
// Mark checked in
|
|
if err := s.checkoutRepo.SetCheckedIn(ctx, checkout.ID, reviewTaskID); err != nil {
|
|
return nil, fmt.Errorf("set checked in: %w", err)
|
|
}
|
|
|
|
log.Info("checkout completed",
|
|
logging.FieldProjectID, checkout.ProjectID,
|
|
"checkout_id", checkout.ID,
|
|
"branch", checkout.Branch,
|
|
"review_queued", reviewTaskID != "",
|
|
)
|
|
|
|
return &CheckinResult{
|
|
CheckoutID: checkout.ID,
|
|
ReviewTaskID: reviewTaskID,
|
|
Status: domain.CheckoutStatusCheckedIn,
|
|
}, nil
|
|
}
|
|
|
|
// ListBranches returns all branches for a project's repository.
|
|
func (s *CheckoutService) ListBranches(ctx context.Context, projectID domain.ProjectID) ([]*domain.GitBranch, error) {
|
|
// Get project to verify it exists
|
|
project, err := s.projectRepo.Get(ctx, projectID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get project: %w", err)
|
|
}
|
|
|
|
repoOwner := s.defaultOwner
|
|
repoName := string(project.ID)
|
|
|
|
return s.gitRepo.ListBranches(ctx, repoOwner, repoName)
|
|
}
|
|
|
|
// Get retrieves a checkout by ID.
|
|
func (s *CheckoutService) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
|
|
return s.checkoutRepo.Get(ctx, id)
|
|
}
|
|
|
|
// List returns checkouts for a project.
|
|
func (s *CheckoutService) List(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) {
|
|
return s.checkoutRepo.ListByProject(ctx, projectID)
|
|
}
|
|
|
|
// Revoke manually revokes an active checkout.
|
|
func (s *CheckoutService) Revoke(ctx context.Context, id domain.CheckoutID) error {
|
|
log := logging.FromContext(ctx).WithService("CheckoutService")
|
|
|
|
checkout, err := s.checkoutRepo.Get(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if checkout.Status != domain.CheckoutStatusActive {
|
|
return domain.ErrCheckoutNotActive
|
|
}
|
|
|
|
// Revoke token
|
|
if err := s.gitRepo.DeleteAccessToken(ctx, checkout.GiteaTokenID); err != nil {
|
|
log.Warn("failed to revoke checkout token",
|
|
"checkout_id", checkout.ID,
|
|
"token_id", checkout.GiteaTokenID,
|
|
logging.FieldError, err,
|
|
)
|
|
}
|
|
|
|
// Update status
|
|
if err := s.checkoutRepo.UpdateStatus(ctx, id, domain.CheckoutStatusRevoked); err != nil {
|
|
return fmt.Errorf("update status: %w", err)
|
|
}
|
|
|
|
log.Info("checkout revoked",
|
|
"checkout_id", id,
|
|
logging.FieldProjectID, checkout.ProjectID,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// CleanupExpired marks expired checkouts and revokes their tokens.
|
|
// Returns the number of checkouts cleaned up.
|
|
func (s *CheckoutService) CleanupExpired(ctx context.Context) (int, error) {
|
|
log := logging.FromContext(ctx).WithService("CheckoutService")
|
|
|
|
tokenIDs, err := s.checkoutRepo.CleanupExpired(ctx)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("cleanup expired: %w", err)
|
|
}
|
|
|
|
// Revoke tokens
|
|
for _, tokenID := range tokenIDs {
|
|
if err := s.gitRepo.DeleteAccessToken(ctx, tokenID); err != nil {
|
|
log.Warn("failed to revoke expired token",
|
|
"token_id", tokenID,
|
|
logging.FieldError, err,
|
|
)
|
|
}
|
|
}
|
|
|
|
if len(tokenIDs) > 0 {
|
|
log.Info("cleaned up expired checkouts", "count", len(tokenIDs))
|
|
}
|
|
|
|
return len(tokenIDs), nil
|
|
}
|
|
|
|
// buildCloneURLs constructs both authenticated and base HTTPS clone URLs.
|
|
// Returns (authenticatedURL, baseURL, error).
|
|
// The authenticated URL contains the token and should only be shown once.
|
|
// The base URL is safe to store and display.
|
|
func (s *CheckoutService) buildCloneURLs(token, owner, repo string) (authURL, baseURL string, err error) {
|
|
// Parse base URL
|
|
u, err := url.Parse(s.giteaURL)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("parse gitea url: %w", err)
|
|
}
|
|
|
|
// Build base URL (no token): https://git.threesix.ai/owner/repo.git
|
|
u.Path = fmt.Sprintf("/%s/%s.git", owner, repo)
|
|
u.RawQuery = ""
|
|
u.Fragment = ""
|
|
baseURL = u.String()
|
|
|
|
// Build authenticated URL: https://<token>@git.threesix.ai/owner/repo.git
|
|
u.User = url.User(token)
|
|
authURL = u.String()
|
|
|
|
return authURL, baseURL, nil
|
|
}
|
|
|
|
// SanitizeCloneURL removes the token from a clone URL for safe logging/display.
|
|
func SanitizeCloneURL(cloneURL string) string {
|
|
u, err := url.Parse(cloneURL)
|
|
if err != nil {
|
|
return cloneURL
|
|
}
|
|
u.User = nil
|
|
return u.String()
|
|
}
|
|
|
|
// ExtractRepoFromURL extracts owner/repo from a git URL.
|
|
func ExtractRepoFromURL(gitURL string) (owner, repo string, err error) {
|
|
u, err := url.Parse(gitURL)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
|
|
// Remove .git suffix and leading slash
|
|
path := strings.TrimSuffix(strings.TrimPrefix(u.Path, "/"), ".git")
|
|
parts := strings.SplitN(path, "/", 2)
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("invalid git url path: %s", path)
|
|
}
|
|
|
|
return parts[0], parts[1], nil
|
|
}
|