feat: label-based undeploy, GC reconciliation, checkout/sessions, pool status
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>
This commit is contained in:
jordan 2026-02-09 19:11:28 -07:00
parent 1714b5921a
commit 9226454b85
41 changed files with 4939 additions and 27 deletions

View File

@ -36,7 +36,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **E2E testing strategy** | [services/e2e-testing-strategy.md](.claude/guides/services/e2e-testing-strategy.md) | | **E2E testing strategy** | [services/e2e-testing-strategy.md](.claude/guides/services/e2e-testing-strategy.md) |
| **Cookbook tree system (commands)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) | | **Cookbook tree system (commands)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) |
| **Slackpath reference architectures** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md#slackpath-trees-reference-architectures) | | **Slackpath reference architectures** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md#slackpath-trees-reference-architectures) |
| **Write E2E cookbook scripts** | [cookbook-scripts/SKILL.md](.claude/skills/cookbook-scripts/SKILL.md) | | **Write cookbook trees** | [cookbook-trees/SKILL.md](.claude/skills/cookbook-trees/SKILL.md) |
| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) | | **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) | | **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) |
| **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) | | **Resource provisioning plan** | [services/resource-provisioning-plan.md](.claude/guides/services/resource-provisioning-plan.md) |
@ -49,6 +49,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **Debug external system health** | [ops/external-health-diagnostics.md](.claude/guides/ops/external-health-diagnostics.md) | | **Debug external system health** | [ops/external-health-diagnostics.md](.claude/guides/ops/external-health-diagnostics.md) |
| **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) | | **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.md) |
| **Visual verification (Playwright)** | [services/visual-verification.md](.claude/guides/services/visual-verification.md) | | **Visual verification (Playwright)** | [services/visual-verification.md](.claude/guides/services/visual-verification.md) |
| **Interactive remote development** | [services/interactive-remote-dev.md](.claude/guides/services/interactive-remote-dev.md) |
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction | | **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
## Critical Rules ## Critical Rules
@ -76,7 +77,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
- **Validation:** Use `validate.New()` accumulator for 2+ field checks in handlers: `v := validate.New(); v.Required(req.Name, "name"); v.Required(req.Type, "type"); if err := v.Error() { ... }`. Single-field checks can stay inline. NEVER duplicate validation logic that exists in `internal/validate`. - **Validation:** Use `validate.New()` accumulator for 2+ field checks in handlers: `v := validate.New(); v.Required(req.Name, "name"); v.Required(req.Type, "type"); if err := v.Error() { ... }`. Single-field checks can stay inline. NEVER duplicate validation logic that exists in `internal/validate`.
- **Error wrapping:** ALWAYS use `%w` (not `%v`) when wrapping errors in `fmt.Errorf`. Using `%v` stringifies the error and breaks `errors.Is`/`errors.As` chains. For non-error types (structs, slices), create a typed error implementing `error` instead of stringifying with `%v`. - **Error wrapping:** ALWAYS use `%w` (not `%v`) when wrapping errors in `fmt.Errorf`. Using `%v` stringifies the error and breaks `errors.Is`/`errors.As` chains. For non-error types (structs, slices), create a typed error implementing `error` instead of stringifying with `%v`.
- **Context propagation:** NEVER use `context.Background()` in handlers, services, or adapters that receive a context parameter. Always derive from parent context. Use `context.WithoutCancel(ctx)` for fire-and-forget goroutines that need tracing but independent cancellation. - **Context propagation:** NEVER use `context.Background()` in handlers, services, or adapters that receive a context parameter. Always derive from parent context. Use `context.WithoutCancel(ctx)` for fire-and-forget goroutines that need tracing but independent cancellation.
- **Cookbooks:** Load `.claude/skills/cookbook-scripts/SKILL.md` before writing/modifying any cookbook script or tree. - **Cookbooks:** Load `.claude/skills/cookbook-trees/SKILL.md` before writing/modifying any cookbook tree.
- **Version alignment:** Skeleton templates MUST use consistent versions across all files: Go 1.25 (go.work, go.mod, Dockerfiles, CI images), Node 20, Alpine 3.19. When updating a version, grep the entire templates/ tree and update ALL occurrences to prevent drift. - **Version alignment:** Skeleton templates MUST use consistent versions across all files: Go 1.25 (go.work, go.mod, Dockerfiles, CI images), Node 20, Alpine 3.19. When updating a version, grep the entire templates/ tree and update ALL occurrences to prevent drift.
## Quick Reference ## Quick Reference
@ -198,6 +199,8 @@ cookbooks/ # End-to-end workflow guides
| SDLC Orchestration | **Done** | Deterministic feature lifecycle with classifier engine, API, orchestrator, and 15 skeleton commands | | SDLC Orchestration | **Done** | Deterministic feature lifecycle with classifier engine, API, orchestrator, and 15 skeleton commands |
| Composable Monorepo Templates | **Done** | Monorepo skeleton + component templates (service, worker, app-astro, app-react, cli) | | Composable Monorepo Templates | **Done** | Monorepo skeleton + component templates (service, worker, app-astro, app-react, cli) |
| Visual Verification | Planned | Playwright screenshots/video + AI evaluation for feature completeness | | Visual Verification | Planned | Playwright screenshots/video + AI evaluation for feature completeness |
| Checkout/Checkin | **Done** | Sidecar dev flow: temporary git tokens, branch checkout, review on checkin |
| Interactive Remote Dev | **Done** | Sessions with pod binding, command execution, SSE streaming, ephemeral preview URLs |
**Current Version:** v0.10.25 **Current Version:** v0.10.25

View File

@ -303,6 +303,46 @@ func main() {
// Create verify service (orchestrates verify task submission and tracking) // Create verify service (orchestrates verify task submission and tracking)
verifyService := service.NewVerifyService(workQueueRepo) verifyService := service.NewVerifyService(workQueueRepo)
// Create checkout repository and service (for sidecar development flow)
checkoutRepo := postgres.NewCheckoutRepository(database.DB)
var checkoutService *service.CheckoutService
if giteaClient != nil {
checkoutService = service.NewCheckoutService(
checkoutRepo,
giteaClient,
projectRepo,
service.CheckoutServiceConfig{
GiteaURL: infraCfg.GiteaURL,
DefaultOwner: infraCfg.GiteaDefaultOrg,
DefaultExpiry: 24 * time.Hour,
},
).WithWorkQueue(workQueueRepo)
}
// Create session service (for interactive remote development)
sessionRepo := postgres.NewSessionRepository(database.DB)
var previewManager *kubernetes.PreviewManager
if k8sClient != nil {
previewManager = kubernetes.NewPreviewManager(k8sClient, kubernetes.PreviewConfig{
Namespace: namespace,
IngressClass: "traefik",
TLSIssuer: infraCfg.DeployTLSIssuer,
})
}
var sessionService *service.SessionService
if checkoutService != nil && previewManager != nil {
sessionService = service.NewSessionService(
sessionRepo,
checkoutService,
projectRepo,
previewManager,
service.SessionServiceConfig{
PreviewDomain: "preview.threesix.ai",
DefaultExpiry: 24 * time.Hour,
},
)
}
// SDLC lifecycle management (kubectl exec into project pods) // SDLC lifecycle management (kubectl exec into project pods)
sdlcPodExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger}) sdlcPodExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger})
@ -485,13 +525,25 @@ func main() {
agentsHandler := handlers.NewAgentsHandler(agentRegistry) agentsHandler := handlers.NewAgentsHandler(agentRegistry)
// Initialize worker pool handlers // Initialize worker pool handlers
workersHandler := handlers.NewWorkersHandler(workerService).WithWorkService(workService) workersHandler := handlers.NewWorkersHandler(workerService).WithWorkService(workService).WithWorkQueue(workQueueRepo)
buildsHandler := handlers.NewBuildsHandler(buildService) buildsHandler := handlers.NewBuildsHandler(buildService)
createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService) createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService)
sdlcHandler := handlers.NewSDLCHandler(sdlcService) sdlcHandler := handlers.NewSDLCHandler(sdlcService)
sdlcOrchestratorHandler := handlers.NewSDLCOrchestratorHandler(sdlcOrchestrator) sdlcOrchestratorHandler := handlers.NewSDLCOrchestratorHandler(sdlcOrchestrator)
// Initialize checkout handler (for sidecar development flow)
var checkoutHandler *handlers.CheckoutHandler
if checkoutService != nil {
checkoutHandler = handlers.NewCheckoutHandler(checkoutService)
}
// Initialize sessions handler (for interactive remote development)
var sessionsHandler *handlers.SessionsHandler
if sessionService != nil {
sessionsHandler = handlers.NewSessionsHandler(sessionService)
}
// Initialize saga system (resilient workflow orchestration) // Initialize saga system (resilient workflow orchestration)
sagaRepo := postgres.NewSagaRepository(database.DB) sagaRepo := postgres.NewSagaRepository(database.DB)
sagaExecutor := service.NewSagaExecutor(sagaRepo, logger) sagaExecutor := service.NewSagaExecutor(sagaRepo, logger)
@ -588,6 +640,12 @@ func main() {
sdlcOrchestratorHandler.Mount(app.Router()) sdlcOrchestratorHandler.Mount(app.Router())
sdlcGenerateHandler.Mount(app.Router()) sdlcGenerateHandler.Mount(app.Router())
sdlcCallbackHandler.Mount(app.Router()) sdlcCallbackHandler.Mount(app.Router())
if checkoutHandler != nil {
checkoutHandler.Mount(app.Router())
}
if sessionsHandler != nil {
sessionsHandler.Mount(app.Router())
}
verifyHandler.Mount(app.Router()) verifyHandler.Mount(app.Router())
sagaHandler.Mount(app.Router()) sagaHandler.Mount(app.Router())
@ -658,6 +716,27 @@ func main() {
}) })
operationCleanup.Start() operationCleanup.Start()
// Start checkout cleanup worker (revokes expired checkout tokens)
var checkoutCleanup *worker.CheckoutCleanup
if checkoutService != nil {
checkoutCleanup = worker.NewCheckoutCleanup(checkoutService, nil)
checkoutCleanup.Start()
}
// Start session cleanup worker (tears down expired session previews)
var sessionCleanup *worker.SessionCleanup
if sessionService != nil {
sessionCleanup = worker.NewSessionCleanup(sessionService, nil)
sessionCleanup.Start()
}
// Start resource GC worker (cleans up orphaned K8s resources)
var resourceGC *worker.ResourceGC
if deployerAdapter != nil {
resourceGC = worker.NewResourceGC(deployerAdapter, database.DB, nil)
resourceGC.Start()
}
// Enable API documentation // Enable API documentation
app.EnableDocs(buildOpenAPISpec()) app.EnableDocs(buildOpenAPISpec())
@ -668,6 +747,15 @@ func main() {
workExecutor.Stop() workExecutor.Stop()
queueMaintenance.Stop() queueMaintenance.Stop()
operationCleanup.Stop() operationCleanup.Stop()
if checkoutCleanup != nil {
checkoutCleanup.Stop()
}
if sessionCleanup != nil {
sessionCleanup.Stop()
}
if resourceGC != nil {
resourceGC.Stop()
}
queueProcessor.Stop() queueProcessor.Stop()
webhookDispatcher.Stop() webhookDispatcher.Stop()
projectRepo.StopWatching() projectRepo.StopWatching()

View File

@ -4,11 +4,12 @@ package deployer
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors" k8serr "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@ -35,13 +36,13 @@ type Config struct {
// Deployer manages Kubernetes deployments for projects. // Deployer manages Kubernetes deployments for projects.
type Deployer struct { type Deployer struct {
client *kubernetes.Clientset client kubernetes.Interface
ingressClient IngressClient ingressClient IngressClient
config Config config Config
} }
// NewDeployer creates a new Deployer. // NewDeployer creates a new Deployer.
func NewDeployer(client *kubernetes.Clientset, cfg Config) *Deployer { func NewDeployer(client kubernetes.Interface, cfg Config) *Deployer {
if cfg.DefaultReplicas == 0 { if cfg.DefaultReplicas == 0 {
cfg.DefaultReplicas = 1 cfg.DefaultReplicas = 1
} }
@ -59,7 +60,7 @@ func NewDeployer(client *kubernetes.Clientset, cfg Config) *Deployer {
} }
// NewDeployerWithIngressClient creates a Deployer with a custom IngressClient for testing. // NewDeployerWithIngressClient creates a Deployer with a custom IngressClient for testing.
func NewDeployerWithIngressClient(client *kubernetes.Clientset, ingressClient IngressClient, cfg Config) *Deployer { func NewDeployerWithIngressClient(client kubernetes.Interface, ingressClient IngressClient, cfg Config) *Deployer {
if cfg.DefaultReplicas == 0 { if cfg.DefaultReplicas == 0 {
cfg.DefaultReplicas = 1 cfg.DefaultReplicas = 1
} }
@ -137,38 +138,153 @@ func (d *Deployer) Undeploy(ctx context.Context, projectName string) error {
// Delete Ingress // Delete Ingress
err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, projectName, metav1.DeleteOptions{}) err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, projectName, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) { if err != nil && !k8serr.IsNotFound(err) {
return fmt.Errorf("failed to delete ingress: %w", err) return fmt.Errorf("failed to delete ingress: %w", err)
} }
// Delete Service // Delete Service
err = d.client.CoreV1().Services(ns).Delete(ctx, projectName, metav1.DeleteOptions{}) err = d.client.CoreV1().Services(ns).Delete(ctx, projectName, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) { if err != nil && !k8serr.IsNotFound(err) {
return fmt.Errorf("failed to delete service: %w", err) return fmt.Errorf("failed to delete service: %w", err)
} }
// Delete Deployment // Delete Deployment
err = d.client.AppsV1().Deployments(ns).Delete(ctx, projectName, metav1.DeleteOptions{}) err = d.client.AppsV1().Deployments(ns).Delete(ctx, projectName, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) { if err != nil && !k8serr.IsNotFound(err) {
return fmt.Errorf("failed to delete deployment: %w", err) return fmt.Errorf("failed to delete deployment: %w", err)
} }
// Delete Secret // Delete Secret
err = d.client.CoreV1().Secrets(ns).Delete(ctx, projectName+"-env", metav1.DeleteOptions{}) err = d.client.CoreV1().Secrets(ns).Delete(ctx, projectName+"-env", metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) { if err != nil && !k8serr.IsNotFound(err) {
return fmt.Errorf("failed to delete secret: %w", err) return fmt.Errorf("failed to delete secret: %w", err)
} }
return nil return nil
} }
// UndeployAll removes all deployment resources matching the project label.
// Unlike Undeploy which deletes by exact name, this uses label selectors to find
// and delete all resources (including monorepo components like {project}-{component}).
// Errors are accumulated so that a single resource failure doesn't prevent cleanup of others.
func (d *Deployer) UndeployAll(ctx context.Context, projectName string) error {
ns := d.config.Namespace
selector := fmt.Sprintf("project=%s", projectName)
propagation := metav1.DeletePropagationForeground
deleteOpts := metav1.DeleteOptions{PropagationPolicy: &propagation}
listOpts := metav1.ListOptions{LabelSelector: selector}
var errs []error
// Delete Ingresses
ingresses, err := d.client.NetworkingV1().Ingresses(ns).List(ctx, listOpts)
if err != nil {
errs = append(errs, fmt.Errorf("failed to list ingresses: %w", err))
} else {
for _, ing := range ingresses.Items {
if err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, ing.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to delete ingress %s: %w", ing.Name, err))
}
}
}
// Delete Services
services, err := d.client.CoreV1().Services(ns).List(ctx, listOpts)
if err != nil {
errs = append(errs, fmt.Errorf("failed to list services: %w", err))
} else {
for _, svc := range services.Items {
if err := d.client.CoreV1().Services(ns).Delete(ctx, svc.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to delete service %s: %w", svc.Name, err))
}
}
}
// Delete Deployments
deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, listOpts)
if err != nil {
errs = append(errs, fmt.Errorf("failed to list deployments: %w", err))
} else {
for _, dep := range deployments.Items {
if err := d.client.AppsV1().Deployments(ns).Delete(ctx, dep.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to delete deployment %s: %w", dep.Name, err))
}
}
}
// Delete Secrets
secrets, err := d.client.CoreV1().Secrets(ns).List(ctx, listOpts)
if err != nil {
errs = append(errs, fmt.Errorf("failed to list secrets: %w", err))
} else {
for _, sec := range secrets.Items {
if err := d.client.CoreV1().Secrets(ns).Delete(ctx, sec.Name, deleteOpts); err != nil && !k8serr.IsNotFound(err) {
errs = append(errs, fmt.Errorf("failed to delete secret %s: %w", sec.Name, err))
}
}
}
return errors.Join(errs...)
}
// ListProjectLabels returns unique project label values from all deployments in the namespace.
// This is used by the GC reconciliation worker to discover orphaned resources.
func (d *Deployer) ListProjectLabels(ctx context.Context) ([]string, error) {
ns := d.config.Namespace
deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{
LabelSelector: "project",
})
if err != nil {
return nil, fmt.Errorf("failed to list deployments: %w", err)
}
seen := make(map[string]struct{})
var labels []string
for _, dep := range deployments.Items {
project := dep.Labels["project"]
if project == "" {
continue
}
if _, ok := seen[project]; !ok {
seen[project] = struct{}{}
labels = append(labels, project)
}
}
return labels, nil
}
// GetOldestResourceTime returns the creation time of the oldest deployment
// matching the given project label. Returns false if no resources exist.
func (d *Deployer) GetOldestResourceTime(ctx context.Context, projectName string) (time.Time, bool, error) {
ns := d.config.Namespace
deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{
LabelSelector: fmt.Sprintf("project=%s", projectName),
})
if err != nil {
return time.Time{}, false, fmt.Errorf("failed to list deployments: %w", err)
}
if len(deployments.Items) == 0 {
return time.Time{}, false, nil
}
oldest := deployments.Items[0].CreationTimestamp.Time
for _, dep := range deployments.Items[1:] {
if dep.CreationTimestamp.Time.Before(oldest) {
oldest = dep.CreationTimestamp.Time
}
}
return oldest, true, nil
}
// GetStatus returns the current deployment status for a project. // GetStatus returns the current deployment status for a project.
func (d *Deployer) GetStatus(ctx context.Context, projectName string) (*domain.DeployStatus, error) { func (d *Deployer) GetStatus(ctx context.Context, projectName string) (*domain.DeployStatus, error) {
ns := d.config.Namespace ns := d.config.Namespace
deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, projectName, metav1.GetOptions{}) deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, projectName, metav1.GetOptions{})
if err != nil { if err != nil {
if errors.IsNotFound(err) { if k8serr.IsNotFound(err) {
return nil, nil return nil, nil
} }
return nil, fmt.Errorf("failed to get deployment: %w", err) return nil, fmt.Errorf("failed to get deployment: %w", err)

View File

@ -0,0 +1,241 @@
package deployer
import (
"context"
"testing"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
func newTestDeployer(client *fake.Clientset) *Deployer {
return NewDeployer(client, Config{Namespace: "projects"})
}
func createFakeDeployment(t *testing.T, client *fake.Clientset, name, project string) {
t.Helper()
replicas := int32(1)
_, err := client.AppsV1().Deployments("projects").Create(context.Background(), &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "projects",
Labels: map[string]string{"app": name, "project": project},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: name, Image: "test:latest"}}},
},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("failed to create deployment %s: %v", name, err)
}
}
func createFakeService(t *testing.T, client *fake.Clientset, name, project string) {
t.Helper()
_, err := client.CoreV1().Services("projects").Create(context.Background(), &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "projects",
Labels: map[string]string{"app": name, "project": project},
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": name},
Ports: []corev1.ServicePort{{Port: 8080}},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("failed to create service %s: %v", name, err)
}
}
func createFakeIngress(t *testing.T, client *fake.Clientset, name, project string) {
t.Helper()
_, err := client.NetworkingV1().Ingresses("projects").Create(context.Background(), &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "projects",
Labels: map[string]string{"app": name, "project": project},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("failed to create ingress %s: %v", name, err)
}
}
func createFakeSecret(t *testing.T, client *fake.Clientset, name, project string) {
t.Helper()
_, err := client.CoreV1().Secrets("projects").Create(context.Background(), &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "projects",
Labels: map[string]string{"project": project},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("failed to create secret %s: %v", name, err)
}
}
func TestUndeployAll_SingleApp(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployer(client)
ctx := context.Background()
createFakeDeployment(t, client, "myapp", "myapp")
createFakeService(t, client, "myapp", "myapp")
createFakeIngress(t, client, "myapp", "myapp")
createFakeSecret(t, client, "myapp-env", "myapp")
if err := d.UndeployAll(ctx, "myapp"); err != nil {
t.Fatalf("UndeployAll failed: %v", err)
}
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 0 {
t.Errorf("expected 0 deployments, got %d", len(deps.Items))
}
svcs, _ := client.CoreV1().Services("projects").List(ctx, metav1.ListOptions{})
if len(svcs.Items) != 0 {
t.Errorf("expected 0 services, got %d", len(svcs.Items))
}
ings, _ := client.NetworkingV1().Ingresses("projects").List(ctx, metav1.ListOptions{})
if len(ings.Items) != 0 {
t.Errorf("expected 0 ingresses, got %d", len(ings.Items))
}
secs, _ := client.CoreV1().Secrets("projects").List(ctx, metav1.ListOptions{})
if len(secs.Items) != 0 {
t.Errorf("expected 0 secrets, got %d", len(secs.Items))
}
}
func TestUndeployAll_MonorepoComponents(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployer(client)
ctx := context.Background()
// Main project + two components all sharing "project=myapp" label
createFakeDeployment(t, client, "myapp", "myapp")
createFakeDeployment(t, client, "myapp-studio-ui", "myapp")
createFakeDeployment(t, client, "myapp-studio-api", "myapp")
createFakeService(t, client, "myapp", "myapp")
createFakeService(t, client, "myapp-studio-ui", "myapp")
createFakeService(t, client, "myapp-studio-api", "myapp")
createFakeIngress(t, client, "myapp", "myapp")
// Also create a resource for a DIFFERENT project - should not be deleted
createFakeDeployment(t, client, "other-project", "other-project")
createFakeService(t, client, "other-project", "other-project")
if err := d.UndeployAll(ctx, "myapp"); err != nil {
t.Fatalf("UndeployAll failed: %v", err)
}
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 1 {
t.Errorf("expected 1 deployment (other-project), got %d", len(deps.Items))
}
if len(deps.Items) == 1 && deps.Items[0].Name != "other-project" {
t.Errorf("remaining deployment = %q, want %q", deps.Items[0].Name, "other-project")
}
svcs, _ := client.CoreV1().Services("projects").List(ctx, metav1.ListOptions{})
if len(svcs.Items) != 1 {
t.Errorf("expected 1 service (other-project), got %d", len(svcs.Items))
}
ings, _ := client.NetworkingV1().Ingresses("projects").List(ctx, metav1.ListOptions{})
if len(ings.Items) != 0 {
t.Errorf("expected 0 ingresses, got %d", len(ings.Items))
}
}
func TestUndeployAll_IdempotentNoOp(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployer(client)
ctx := context.Background()
// No resources exist for this project
if err := d.UndeployAll(ctx, "nonexistent"); err != nil {
t.Fatalf("UndeployAll should be idempotent, got: %v", err)
}
}
func TestListProjectLabels(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployer(client)
ctx := context.Background()
createFakeDeployment(t, client, "proj-a", "proj-a")
createFakeDeployment(t, client, "proj-b", "proj-b")
createFakeDeployment(t, client, "proj-b-ui", "proj-b")
createFakeDeployment(t, client, "proj-b-api", "proj-b")
labels, err := d.ListProjectLabels(ctx)
if err != nil {
t.Fatalf("ListProjectLabels failed: %v", err)
}
// Should have exactly 2 unique labels
if len(labels) != 2 {
t.Fatalf("expected 2 unique labels, got %d: %v", len(labels), labels)
}
found := make(map[string]bool)
for _, l := range labels {
found[l] = true
}
if !found["proj-a"] || !found["proj-b"] {
t.Errorf("expected proj-a and proj-b, got %v", labels)
}
}
func TestListProjectLabels_Empty(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployer(client)
labels, err := d.ListProjectLabels(context.Background())
if err != nil {
t.Fatalf("ListProjectLabels failed: %v", err)
}
if len(labels) != 0 {
t.Errorf("expected 0 labels, got %d", len(labels))
}
}
func TestGetOldestResourceTime(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployer(client)
ctx := context.Background()
createFakeDeployment(t, client, "myapp", "myapp")
createFakeDeployment(t, client, "myapp-ui", "myapp")
_, found, err := d.GetOldestResourceTime(ctx, "myapp")
if err != nil {
t.Fatalf("GetOldestResourceTime failed: %v", err)
}
if !found {
t.Fatal("expected found=true")
}
}
func TestGetOldestResourceTime_NoResources(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployer(client)
_, found, err := d.GetOldestResourceTime(context.Background(), "nonexistent")
if err != nil {
t.Fatalf("GetOldestResourceTime failed: %v", err)
}
if found {
t.Error("expected found=false for nonexistent project")
}
}

View File

@ -16,9 +16,9 @@ type IngressClient interface {
DeleteIngress(ctx context.Context, namespace, name string) error DeleteIngress(ctx context.Context, namespace, name string) error
} }
// k8sIngressClient wraps kubernetes.Clientset to implement IngressClient. // k8sIngressClient wraps kubernetes.Interface to implement IngressClient.
type k8sIngressClient struct { type k8sIngressClient struct {
clientset *kubernetes.Clientset clientset kubernetes.Interface
} }
// GetIngress retrieves an Ingress by namespace and name. // GetIngress retrieves an Ingress by namespace and name.

View File

@ -251,6 +251,114 @@ func (c *Client) Check(ctx context.Context) domain.ExternalSystemStatus {
return status return status
} }
// ListBranches returns all branches for a repository.
func (c *Client) ListBranches(ctx context.Context, owner, repo string) ([]*domain.GitBranch, error) {
// Gitea SDK doesn't support context propagation, but check for cancellation.
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
branches, _, err := c.client.ListRepoBranches(owner, repo, gitea.ListRepoBranchesOptions{
ListOptions: gitea.ListOptions{PageSize: 100},
})
if err != nil {
return nil, fmt.Errorf("failed to list branches for %s/%s: %w", owner, repo, err)
}
result := make([]*domain.GitBranch, len(branches))
for i, b := range branches {
result[i] = &domain.GitBranch{
Name: b.Name,
CommitSHA: b.Commit.ID,
Protected: b.Protected,
}
}
return result, nil
}
// CreateBranch creates a new branch from a reference (branch name or commit SHA).
func (c *Client) CreateBranch(ctx context.Context, owner, repo, branchName, fromRef string) (*domain.GitBranch, error) {
// Gitea SDK doesn't support context propagation, but check for cancellation.
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
branch, _, err := c.client.CreateBranch(owner, repo, gitea.CreateBranchOption{
BranchName: branchName,
OldBranchName: fromRef,
})
if err != nil {
return nil, fmt.Errorf("failed to create branch %s from %s in %s/%s: %w", branchName, fromRef, owner, repo, err)
}
return &domain.GitBranch{
Name: branch.Name,
CommitSHA: branch.Commit.ID,
Protected: branch.Protected,
}, nil
}
// CreateAccessToken creates a new personal access token for git operations.
func (c *Client) CreateAccessToken(ctx context.Context, name string, scopes []string, expiresAt *time.Time) (*domain.GitAccessToken, error) {
// Gitea SDK doesn't support context propagation, but check for cancellation.
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Convert string scopes to Gitea AccessTokenScope
tokenScopes := make([]gitea.AccessTokenScope, len(scopes))
for i, s := range scopes {
tokenScopes[i] = gitea.AccessTokenScope(s)
}
token, _, err := c.client.CreateAccessToken(gitea.CreateAccessTokenOption{
Name: name,
Scopes: tokenScopes,
})
if err != nil {
return nil, fmt.Errorf("failed to create access token: %w", err)
}
return &domain.GitAccessToken{
ID: token.ID,
Name: token.Name,
Token: token.Token,
Scopes: scopes,
}, nil
}
// DeleteAccessToken revokes and deletes an access token.
func (c *Client) DeleteAccessToken(ctx context.Context, tokenID int64) error {
// Gitea SDK doesn't support context propagation, but check for cancellation.
select {
case <-ctx.Done():
return ctx.Err()
default:
}
_, err := c.client.DeleteAccessToken(tokenID)
if err != nil {
return fmt.Errorf("failed to delete access token %d: %w", tokenID, err)
}
return nil
}
// DefaultOwner returns the default organization for repo operations.
func (c *Client) DefaultOwner() string {
return c.defaultOwner
}
// URL returns the Gitea server URL.
func (c *Client) URL() string {
return c.url
}
// repoFromGitea converts a gitea.Repository to domain.Repo. // repoFromGitea converts a gitea.Repository to domain.Repo.
func repoFromGitea(r *gitea.Repository) *domain.Repo { func repoFromGitea(r *gitea.Repository) *domain.Repo {
return &domain.Repo{ return &domain.Repo{

View File

@ -0,0 +1,183 @@
package kubernetes
import (
"context"
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes"
"github.com/orchard9/rdev/internal/port"
)
// Ensure PreviewManager implements port.PreviewManager at compile time.
var _ port.PreviewManager = (*PreviewManager)(nil)
// PreviewConfig holds configuration for the preview manager.
type PreviewConfig struct {
// Namespace is the K8s namespace for preview resources.
Namespace string
// IngressClass is the ingress controller class (e.g., "traefik").
IngressClass string
// TLSIssuer is the cert-manager cluster issuer name.
TLSIssuer string
}
// PreviewManager manages ephemeral preview URLs via K8s Service + Ingress.
type PreviewManager struct {
client *kubernetes.Clientset
config PreviewConfig
}
// NewPreviewManager creates a new K8s preview manager.
func NewPreviewManager(client *kubernetes.Clientset, cfg PreviewConfig) *PreviewManager {
if cfg.IngressClass == "" {
cfg.IngressClass = "traefik"
}
return &PreviewManager{
client: client,
config: cfg,
}
}
// resourceName returns the K8s resource name for a session.
func resourceName(sessionID string) string {
return "session-" + sessionID
}
// CreatePreview creates a K8s Service + Ingress for the session preview.
func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOptions) error {
if opts.Port == 0 {
opts.Port = 8080
}
name := resourceName(opts.SessionID)
ns := opts.Namespace
if ns == "" {
ns = m.config.Namespace
}
// Create Service targeting the pod by its rdev project label.
// Project pods are labeled with rdev.orchard9.ai/name=<project-id>.
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
Labels: map[string]string{
"rdev.orchard9.ai/session": opts.SessionID,
"rdev.orchard9.ai/preview": "true",
},
},
Spec: corev1.ServiceSpec{
// Use ExternalName-like routing via a selector that matches the pod.
// Since project pods have rdev.orchard9.ai/name=<id>, we select by pod name
// using the statefulset.kubernetes.io/pod-name label (set on StatefulSet pods).
Selector: map[string]string{
"statefulset.kubernetes.io/pod-name": opts.PodName,
},
Ports: []corev1.ServicePort{
{
Name: "http",
Port: int32(opts.Port),
TargetPort: intstr.FromInt32(int32(opts.Port)),
Protocol: corev1.ProtocolTCP,
},
},
Type: corev1.ServiceTypeClusterIP,
},
}
_, err := m.client.CoreV1().Services(ns).Create(ctx, svc, metav1.CreateOptions{})
if err != nil && !errors.IsAlreadyExists(err) {
return fmt.Errorf("create preview service: %w", err)
}
// Create Ingress for TLS-terminated route.
pathType := networkingv1.PathTypePrefix
tlsSecretName := strings.ReplaceAll(opts.Host, ".", "-") + "-tls"
annotations := map[string]string{}
if m.config.TLSIssuer != "" {
annotations["cert-manager.io/cluster-issuer"] = m.config.TLSIssuer
}
ingress := &networkingv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
Labels: map[string]string{
"rdev.orchard9.ai/session": opts.SessionID,
"rdev.orchard9.ai/preview": "true",
},
Annotations: annotations,
},
Spec: networkingv1.IngressSpec{
IngressClassName: &m.config.IngressClass,
TLS: []networkingv1.IngressTLS{
{
Hosts: []string{opts.Host},
SecretName: tlsSecretName,
},
},
Rules: []networkingv1.IngressRule{
{
Host: opts.Host,
IngressRuleValue: networkingv1.IngressRuleValue{
HTTP: &networkingv1.HTTPIngressRuleValue{
Paths: []networkingv1.HTTPIngressPath{
{
Path: "/",
PathType: &pathType,
Backend: networkingv1.IngressBackend{
Service: &networkingv1.IngressServiceBackend{
Name: name,
Port: networkingv1.ServiceBackendPort{
Number: int32(opts.Port),
},
},
},
},
},
},
},
},
},
},
}
_, err = m.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{})
if err != nil && !errors.IsAlreadyExists(err) {
// Clean up the service if ingress creation fails.
_ = m.client.CoreV1().Services(ns).Delete(ctx, name, metav1.DeleteOptions{})
return fmt.Errorf("create preview ingress: %w", err)
}
return nil
}
// DeletePreview removes the K8s Service + Ingress for a session preview.
func (m *PreviewManager) DeletePreview(ctx context.Context, sessionID string) error {
name := resourceName(sessionID)
ns := m.config.Namespace
// Delete Ingress (ignore not-found).
err := m.client.NetworkingV1().Ingresses(ns).Delete(ctx, name, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("delete preview ingress: %w", err)
}
// Delete Service (ignore not-found).
err = m.client.CoreV1().Services(ns).Delete(ctx, name, metav1.DeleteOptions{})
if err != nil && !errors.IsNotFound(err) {
return fmt.Errorf("delete preview service: %w", err)
}
return nil
}

View File

@ -0,0 +1,329 @@
// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// CheckoutRepository implements port.CheckoutRepository using PostgreSQL.
type CheckoutRepository struct {
db *sql.DB
}
// NewCheckoutRepository creates a new PostgreSQL checkout repository.
func NewCheckoutRepository(db *sql.DB) *CheckoutRepository {
return &CheckoutRepository{db: db}
}
// Ensure CheckoutRepository implements port.CheckoutRepository at compile time.
var _ port.CheckoutRepository = (*CheckoutRepository)(nil)
// Create stores a new checkout record.
func (r *CheckoutRepository) Create(ctx context.Context, checkout *domain.Checkout) error {
var id string
err := r.db.QueryRowContext(ctx, `
INSERT INTO checkouts (
project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
clone_url, checked_out_by, checked_out_at, expires_at, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`,
string(checkout.ProjectID),
checkout.Branch,
nullString(checkout.FeatureSlug),
checkout.GiteaTokenID,
checkout.GiteaTokenName,
checkout.CloneURL,
checkout.CheckedOutBy,
checkout.CheckedOutAt,
checkout.ExpiresAt,
string(checkout.Status),
).Scan(&id)
if err != nil {
// Check for unique constraint violation (active checkout exists)
if isUniqueViolation(err) {
return domain.ErrCheckoutAlreadyExists
}
return fmt.Errorf("insert checkout: %w", err)
}
checkout.ID = domain.CheckoutID(id)
return nil
}
// Get retrieves a checkout by ID.
func (r *CheckoutRepository) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
checkout, err := r.scanCheckout(r.db.QueryRowContext(ctx, `
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
clone_url, checked_out_by, checked_out_at, expires_at, status,
checked_in_at, review_task_id
FROM checkouts
WHERE id = $1
`, string(id)))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrCheckoutNotFound
}
if err != nil {
return nil, fmt.Errorf("query checkout: %w", err)
}
return checkout, nil
}
// GetByProjectBranch retrieves an active checkout for a project+branch.
func (r *CheckoutRepository) GetByProjectBranch(ctx context.Context, projectID domain.ProjectID, branch string) (*domain.Checkout, error) {
checkout, err := r.scanCheckout(r.db.QueryRowContext(ctx, `
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
clone_url, checked_out_by, checked_out_at, expires_at, status,
checked_in_at, review_task_id
FROM checkouts
WHERE project_id = $1 AND branch = $2 AND status = 'active'
`, string(projectID), branch))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrCheckoutNotFound
}
if err != nil {
return nil, fmt.Errorf("query checkout by project+branch: %w", err)
}
return checkout, nil
}
// List returns checkouts matching the given options.
func (r *CheckoutRepository) List(ctx context.Context, opts domain.CheckoutListOptions) ([]*domain.Checkout, error) {
query := `
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
clone_url, checked_out_by, checked_out_at, expires_at, status,
checked_in_at, review_task_id
FROM checkouts
`
var args []any
argIdx := 1
if opts.Status != nil {
query += fmt.Sprintf(" WHERE status = $%d", argIdx)
args = append(args, string(*opts.Status))
argIdx++
}
query += " ORDER BY checked_out_at DESC"
if opts.Limit > 0 {
query += fmt.Sprintf(" LIMIT $%d", argIdx)
args = append(args, opts.Limit)
argIdx++
}
if opts.Offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIdx)
args = append(args, opts.Offset)
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query checkouts: %w", err)
}
defer func() { _ = rows.Close() }()
return r.scanCheckouts(rows)
}
// ListByProject returns all checkouts for a project.
func (r *CheckoutRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
clone_url, checked_out_by, checked_out_at, expires_at, status,
checked_in_at, review_task_id
FROM checkouts
WHERE project_id = $1
ORDER BY checked_out_at DESC
`, string(projectID))
if err != nil {
return nil, fmt.Errorf("query checkouts by project: %w", err)
}
defer func() { _ = rows.Close() }()
return r.scanCheckouts(rows)
}
// UpdateStatus updates the status of a checkout.
func (r *CheckoutRepository) UpdateStatus(ctx context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
result, err := r.db.ExecContext(ctx, `
UPDATE checkouts
SET status = $2
WHERE id = $1
`, string(id), string(status))
if err != nil {
return fmt.Errorf("update checkout status: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrCheckoutNotFound
}
return nil
}
// SetCheckedIn marks a checkout as checked in with timestamp.
func (r *CheckoutRepository) SetCheckedIn(ctx context.Context, id domain.CheckoutID, reviewTaskID string) error {
result, err := r.db.ExecContext(ctx, `
UPDATE checkouts
SET status = 'checked_in', checked_in_at = NOW(), review_task_id = $2
WHERE id = $1 AND status = 'active'
`, string(id), nullString(reviewTaskID))
if err != nil {
return fmt.Errorf("set checked in: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrCheckoutNotActive
}
return nil
}
// SetReviewTask sets the review task ID for a checkout.
func (r *CheckoutRepository) SetReviewTask(ctx context.Context, id domain.CheckoutID, taskID string) error {
result, err := r.db.ExecContext(ctx, `
UPDATE checkouts
SET review_task_id = $2
WHERE id = $1
`, string(id), taskID)
if err != nil {
return fmt.Errorf("set review task: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrCheckoutNotFound
}
return nil
}
// CleanupExpired marks expired checkouts and returns their Gitea token IDs for revocation.
func (r *CheckoutRepository) CleanupExpired(ctx context.Context) ([]int64, error) {
rows, err := r.db.QueryContext(ctx, `
UPDATE checkouts
SET status = 'expired'
WHERE status = 'active' AND expires_at < NOW()
RETURNING gitea_token_id
`)
if err != nil {
return nil, fmt.Errorf("cleanup expired checkouts: %w", err)
}
defer func() { _ = rows.Close() }()
var tokenIDs []int64
for rows.Next() {
var tokenID int64
if err := rows.Scan(&tokenID); err != nil {
return nil, fmt.Errorf("scan token id: %w", err)
}
tokenIDs = append(tokenIDs, tokenID)
}
return tokenIDs, rows.Err()
}
// checkoutScanner is an interface for scanning checkout rows.
// Both sql.Row and sql.Rows implement this via their Scan method.
type checkoutScanner interface {
Scan(dest ...any) error
}
// scanCheckoutFields scans checkout fields from a scanner into a Checkout struct.
func (r *CheckoutRepository) scanCheckoutFields(scanner checkoutScanner) (*domain.Checkout, error) {
var (
checkout domain.Checkout
id string
projectID string
featureSlug sql.NullString
status string
checkedInAt sql.NullTime
reviewTaskID sql.NullString
)
err := scanner.Scan(
&id,
&projectID,
&checkout.Branch,
&featureSlug,
&checkout.GiteaTokenID,
&checkout.GiteaTokenName,
&checkout.CloneURL,
&checkout.CheckedOutBy,
&checkout.CheckedOutAt,
&checkout.ExpiresAt,
&status,
&checkedInAt,
&reviewTaskID,
)
if err != nil {
return nil, err
}
checkout.ID = domain.CheckoutID(id)
checkout.ProjectID = domain.ProjectID(projectID)
checkout.Status = domain.CheckoutStatus(status)
if featureSlug.Valid {
checkout.FeatureSlug = featureSlug.String
}
if checkedInAt.Valid {
checkout.CheckedInAt = &checkedInAt.Time
}
if reviewTaskID.Valid {
checkout.ReviewTaskID = reviewTaskID.String
}
return &checkout, nil
}
// scanCheckout scans a single row into a Checkout struct.
func (r *CheckoutRepository) scanCheckout(row *sql.Row) (*domain.Checkout, error) {
return r.scanCheckoutFields(row)
}
// scanCheckouts scans multiple rows into Checkout structs.
func (r *CheckoutRepository) scanCheckouts(rows *sql.Rows) ([]*domain.Checkout, error) {
var checkouts []*domain.Checkout
for rows.Next() {
checkout, err := r.scanCheckoutFields(rows)
if err != nil {
return nil, fmt.Errorf("scan checkout: %w", err)
}
checkouts = append(checkouts, checkout)
}
return checkouts, rows.Err()
}
// isUniqueViolation checks if the error is a PostgreSQL unique constraint violation.
func isUniqueViolation(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
// PostgreSQL error code for unique_violation is 23505
return strings.Contains(errStr, "unique constraint") ||
strings.Contains(errStr, "duplicate key") ||
strings.Contains(errStr, "23505")
}

View File

@ -0,0 +1,214 @@
// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// SessionRepository implements port.SessionRepository using PostgreSQL.
type SessionRepository struct {
db *sql.DB
}
// NewSessionRepository creates a new PostgreSQL session repository.
func NewSessionRepository(db *sql.DB) *SessionRepository {
return &SessionRepository{db: db}
}
// Ensure SessionRepository implements port.SessionRepository at compile time.
var _ port.SessionRepository = (*SessionRepository)(nil)
// Create stores a new session record.
func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) error {
var id string
err := r.db.QueryRowContext(ctx, `
INSERT INTO sessions (
project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`,
string(session.ProjectID),
string(session.CheckoutID),
session.PodName,
session.PreviewURL,
session.PreviewHost,
session.CreatedBy,
session.CreatedAt,
session.ExpiresAt,
string(session.Status),
).Scan(&id)
if err != nil {
if isUniqueViolation(err) {
return domain.ErrSessionExists
}
return fmt.Errorf("insert session: %w", err)
}
session.ID = domain.SessionID(id)
return nil
}
// Get retrieves a session by ID.
func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, ended_at
FROM sessions
WHERE id = $1
`, string(id)))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrSessionNotFound
}
if err != nil {
return nil, fmt.Errorf("query session: %w", err)
}
return session, nil
}
// GetActiveByProject retrieves the active session for a project.
func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error) {
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, ended_at
FROM sessions
WHERE project_id = $1 AND status = 'active'
`, string(projectID)))
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrSessionNotFound
}
if err != nil {
return nil, fmt.Errorf("query active session: %w", err)
}
return session, nil
}
// ListByProject returns all sessions for a project.
func (r *SessionRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, ended_at
FROM sessions
WHERE project_id = $1
ORDER BY created_at DESC
`, string(projectID))
if err != nil {
return nil, fmt.Errorf("query sessions by project: %w", err)
}
defer func() { _ = rows.Close() }()
return r.scanSessions(rows)
}
// SetEnded marks a session as ended with a timestamp.
func (r *SessionRepository) SetEnded(ctx context.Context, id domain.SessionID) error {
result, err := r.db.ExecContext(ctx, `
UPDATE sessions
SET status = 'ended', ended_at = NOW()
WHERE id = $1 AND status = 'active'
`, string(id))
if err != nil {
return fmt.Errorf("set session ended: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrSessionNotActive
}
return nil
}
// CleanupExpired marks expired sessions and returns them for preview teardown.
func (r *SessionRepository) CleanupExpired(ctx context.Context) ([]*domain.Session, error) {
rows, err := r.db.QueryContext(ctx, `
UPDATE sessions
SET status = 'expired', ended_at = NOW()
WHERE status = 'active' AND expires_at < NOW()
RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, ended_at
`)
if err != nil {
return nil, fmt.Errorf("cleanup expired sessions: %w", err)
}
defer func() { _ = rows.Close() }()
return r.scanSessions(rows)
}
// sessionScanner is an interface for scanning session rows.
type sessionScanner interface {
Scan(dest ...any) error
}
// scanSessionFields scans session fields from a scanner into a Session struct.
func (r *SessionRepository) scanSessionFields(scanner sessionScanner) (*domain.Session, error) {
var (
session domain.Session
id string
projectID string
checkoutID string
status string
endedAt sql.NullTime
)
err := scanner.Scan(
&id,
&projectID,
&checkoutID,
&session.PodName,
&session.PreviewURL,
&session.PreviewHost,
&session.CreatedBy,
&session.CreatedAt,
&session.ExpiresAt,
&status,
&endedAt,
)
if err != nil {
return nil, err
}
session.ID = domain.SessionID(id)
session.ProjectID = domain.ProjectID(projectID)
session.CheckoutID = domain.CheckoutID(checkoutID)
session.Status = domain.SessionStatus(status)
if endedAt.Valid {
session.EndedAt = &endedAt.Time
}
return &session, nil
}
// scanSession scans a single row into a Session struct.
func (r *SessionRepository) scanSession(row *sql.Row) (*domain.Session, error) {
return r.scanSessionFields(row)
}
// scanSessions scans multiple rows into Session structs.
func (r *SessionRepository) scanSessions(rows *sql.Rows) ([]*domain.Session, error) {
var sessions []*domain.Session
for rows.Next() {
session, err := r.scanSessionFields(rows)
if err != nil {
return nil, fmt.Errorf("scan session: %w", err)
}
sessions = append(sessions, session)
}
return sessions, rows.Err()
}

View File

@ -4,7 +4,6 @@
build-{{COMPONENT_NAME}}: build-{{COMPONENT_NAME}}:
depends_on: [preflight] depends_on: [preflight]
image: woodpeckerci/plugin-kaniko image: woodpeckerci/plugin-kaniko
failure: ignore
settings: settings:
registry: registry.threesix.ai registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}:
branch: main branch: main
event: push event: push
verify-{{COMPONENT_NAME}}:
depends_on: [build-{{COMPONENT_NAME}}]
image: alpine/curl
failure: ignore
commands:
- |
TAG="${CI_COMMIT_SHA:0:8}"
REPO="{{PROJECT_NAME}}/{{COMPONENT_NAME}}"
REGISTRY="registry.threesix.ai"
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
exit 0
elif [ "$HTTP_CODE" = "404" ]; then
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
echo " Build may have failed. Deploy will be skipped."
exit 1
else
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
exit 0
fi
when:
branch: main
event: push
deploy-{{COMPONENT_NAME}}: deploy-{{COMPONENT_NAME}}:
depends_on: [verify-{{COMPONENT_NAME}}]
image: bitnami/kubectl:latest image: bitnami/kubectl:latest
commands: commands:
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"

View File

@ -4,7 +4,6 @@
build-{{COMPONENT_NAME}}: build-{{COMPONENT_NAME}}:
depends_on: [preflight] depends_on: [preflight]
image: woodpeckerci/plugin-kaniko image: woodpeckerci/plugin-kaniko
failure: ignore
settings: settings:
registry: registry.threesix.ai registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}:
branch: main branch: main
event: push event: push
verify-{{COMPONENT_NAME}}:
depends_on: [build-{{COMPONENT_NAME}}]
image: alpine/curl
failure: ignore
commands:
- |
TAG="${CI_COMMIT_SHA:0:8}"
REPO="{{PROJECT_NAME}}/{{COMPONENT_NAME}}"
REGISTRY="registry.threesix.ai"
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
exit 0
elif [ "$HTTP_CODE" = "404" ]; then
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
echo " Build may have failed. Deploy will be skipped."
exit 1
else
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
exit 0
fi
when:
branch: main
event: push
deploy-{{COMPONENT_NAME}}: deploy-{{COMPONENT_NAME}}:
depends_on: [verify-{{COMPONENT_NAME}}]
image: bitnami/kubectl:latest image: bitnami/kubectl:latest
commands: commands:
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"

View File

@ -4,7 +4,6 @@
build-{{COMPONENT_NAME}}: build-{{COMPONENT_NAME}}:
depends_on: [preflight] depends_on: [preflight]
image: woodpeckerci/plugin-kaniko image: woodpeckerci/plugin-kaniko
failure: ignore
settings: settings:
registry: registry.threesix.ai registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}:
branch: main branch: main
event: push event: push
verify-{{COMPONENT_NAME}}:
depends_on: [build-{{COMPONENT_NAME}}]
image: alpine/curl
failure: ignore
commands:
- |
TAG="${CI_COMMIT_SHA:0:8}"
REPO="{{PROJECT_NAME}}/{{COMPONENT_NAME}}"
REGISTRY="registry.threesix.ai"
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
exit 0
elif [ "$HTTP_CODE" = "404" ]; then
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
echo " Build may have failed. Deploy will be skipped."
exit 1
else
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
exit 0
fi
when:
branch: main
event: push
deploy-{{COMPONENT_NAME}}: deploy-{{COMPONENT_NAME}}:
depends_on: [verify-{{COMPONENT_NAME}}]
image: bitnami/kubectl:latest image: bitnami/kubectl:latest
commands: commands:
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"

View File

@ -4,7 +4,6 @@
build-{{COMPONENT_NAME}}: build-{{COMPONENT_NAME}}:
depends_on: [preflight] depends_on: [preflight]
image: woodpeckerci/plugin-kaniko image: woodpeckerci/plugin-kaniko
failure: ignore
settings: settings:
registry: registry.threesix.ai registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}:
branch: main branch: main
event: push event: push
verify-{{COMPONENT_NAME}}:
depends_on: [build-{{COMPONENT_NAME}}]
image: alpine/curl
failure: ignore
commands:
- |
TAG="${CI_COMMIT_SHA:0:8}"
REPO="{{PROJECT_NAME}}/{{COMPONENT_NAME}}"
REGISTRY="registry.threesix.ai"
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
exit 0
elif [ "$HTTP_CODE" = "404" ]; then
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
echo " Build may have failed. Deploy will be skipped."
exit 1
else
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
exit 0
fi
when:
branch: main
event: push
deploy-{{COMPONENT_NAME}}: deploy-{{COMPONENT_NAME}}:
depends_on: [verify-{{COMPONENT_NAME}}]
image: bitnami/kubectl:latest image: bitnami/kubectl:latest
commands: commands:
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"

View File

@ -4,7 +4,6 @@
build-{{COMPONENT_NAME}}: build-{{COMPONENT_NAME}}:
depends_on: [preflight] depends_on: [preflight]
image: woodpeckerci/plugin-kaniko image: woodpeckerci/plugin-kaniko
failure: ignore
settings: settings:
registry: registry.threesix.ai registry: registry.threesix.ai
repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}}
@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}:
branch: main branch: main
event: push event: push
verify-{{COMPONENT_NAME}}:
depends_on: [build-{{COMPONENT_NAME}}]
image: alpine/curl
failure: ignore
commands:
- |
TAG="${CI_COMMIT_SHA:0:8}"
REPO="{{PROJECT_NAME}}/{{COMPONENT_NAME}}"
REGISTRY="registry.threesix.ai"
echo "==> Verifying image $REGISTRY/$REPO:$TAG exists in registry"
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--insecure \
"https://$REGISTRY/v2/$REPO/manifests/$TAG" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json")
if [ "$HTTP_CODE" = "200" ]; then
echo "==> Image verified: $REGISTRY/$REPO:$TAG"
exit 0
elif [ "$HTTP_CODE" = "404" ]; then
echo "==> WARNING: Image $REGISTRY/$REPO:$TAG not found in registry"
echo " Build may have failed. Deploy will be skipped."
exit 1
else
echo "==> WARNING: Registry check returned HTTP $HTTP_CODE"
exit 0
fi
when:
branch: main
event: push
deploy-{{COMPONENT_NAME}}: deploy-{{COMPONENT_NAME}}:
depends_on: [verify-{{COMPONENT_NAME}}]
image: bitnami/kubectl:latest image: bitnami/kubectl:latest
commands: commands:
- kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping" - kubectl set image deployment/{{PROJECT_NAME}}-{{COMPONENT_NAME}} {{COMPONENT_NAME}}=registry.threesix.ai/{{PROJECT_NAME}}/{{COMPONENT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects || echo "Deployment not found, skipping"

View File

@ -55,10 +55,10 @@ steps:
# COMPONENT_STEPS_BELOW # COMPONENT_STEPS_BELOW
# Do not remove the marker above - component steps are inserted here # Do not remove the marker above - component steps are inserted here
# Sync point after all component builds complete # Sync point after all component builds/deploys complete
# This step has NO depends_on, so it waits for ALL previous steps # depends_on is updated dynamically when components are added
# (including any component steps inserted above) to complete
build-complete: build-complete:
depends_on: [preflight] # BUILD_COMPLETE_DEPS
image: alpine:3.19 image: alpine:3.19
commands: commands:
- echo "All component builds complete" - echo "All component builds complete"

View File

@ -24,6 +24,8 @@ const (
ScopeBuildWrite = domain.ScopeBuildWrite ScopeBuildWrite = domain.ScopeBuildWrite
ScopeVerifyRead = domain.ScopeVerifyRead ScopeVerifyRead = domain.ScopeVerifyRead
ScopeVerifyWrite = domain.ScopeVerifyWrite ScopeVerifyWrite = domain.ScopeVerifyWrite
ScopeSessionsRead = domain.ScopeSessionsRead
ScopeSessionsExecute = domain.ScopeSessionsExecute
ScopeAdmin = domain.ScopeAdmin ScopeAdmin = domain.ScopeAdmin
) )

View File

@ -0,0 +1,39 @@
-- Migration: Create checkouts table for sidecar development flow
-- Tracks checkout sessions where users clone repos locally with temporary tokens
CREATE TABLE IF NOT EXISTS checkouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
branch VARCHAR(255) NOT NULL,
feature_slug VARCHAR(255),
gitea_token_id BIGINT NOT NULL,
gitea_token_name VARCHAR(255) NOT NULL,
clone_url TEXT NOT NULL,
checked_out_by VARCHAR(255) NOT NULL,
checked_out_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
checked_in_at TIMESTAMPTZ,
review_task_id TEXT,
CONSTRAINT checkouts_status_check CHECK (status IN ('active', 'checked_in', 'expired', 'revoked'))
);
-- Unique constraint: only one active checkout per project+branch
-- This prevents conflicts when multiple users try to checkout the same branch
CREATE UNIQUE INDEX idx_checkouts_active_branch
ON checkouts(project_id, branch)
WHERE status = 'active';
-- Index for cleanup job to find expired checkouts efficiently
CREATE INDEX idx_checkouts_expires
ON checkouts(expires_at)
WHERE status = 'active';
-- Index for listing checkouts by user
CREATE INDEX idx_checkouts_user
ON checkouts(checked_out_by, status);
-- Index for listing checkouts by project
CREATE INDEX idx_checkouts_project
ON checkouts(project_id, checked_out_at DESC);

View File

@ -0,0 +1,34 @@
-- Migration: Create sessions table for interactive remote development
-- Sessions compose checkout (git token) + pod binding + ephemeral preview URL
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id TEXT NOT NULL,
checkout_id UUID NOT NULL REFERENCES checkouts(id),
pod_name VARCHAR(255) NOT NULL,
preview_url TEXT NOT NULL,
preview_host VARCHAR(255) NOT NULL,
created_by VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
ended_at TIMESTAMPTZ,
CONSTRAINT sessions_status_check CHECK (status IN ('active', 'ended', 'expired'))
);
-- Only one active session per project at a time
CREATE UNIQUE INDEX idx_sessions_active_project
ON sessions(project_id) WHERE status = 'active';
-- Index for cleanup job to find expired sessions
CREATE INDEX idx_sessions_expires
ON sessions(expires_at) WHERE status = 'active';
-- Index for listing sessions by project
CREATE INDEX idx_sessions_project
ON sessions(project_id, created_at DESC);
-- Index for looking up session by checkout
CREATE INDEX idx_sessions_checkout
ON sessions(checkout_id);

View File

@ -27,6 +27,8 @@ const (
ScopeBuildWrite Scope = "build:write" ScopeBuildWrite Scope = "build:write"
ScopeVerifyRead Scope = "verify:read" ScopeVerifyRead Scope = "verify:read"
ScopeVerifyWrite Scope = "verify:write" ScopeVerifyWrite Scope = "verify:write"
ScopeSessionsRead Scope = "sessions:read"
ScopeSessionsExecute Scope = "sessions:execute"
ScopeAdmin Scope = "admin" ScopeAdmin Scope = "admin"
) )
@ -47,6 +49,8 @@ var AllScopes = []Scope{
ScopeBuildWrite, ScopeBuildWrite,
ScopeVerifyRead, ScopeVerifyRead,
ScopeVerifyWrite, ScopeVerifyWrite,
ScopeSessionsRead,
ScopeSessionsExecute,
ScopeAdmin, ScopeAdmin,
} }
@ -67,6 +71,8 @@ var ScopeDescriptions = map[Scope]string{
ScopeBuildWrite: "Start and manage builds", ScopeBuildWrite: "Start and manage builds",
ScopeVerifyRead: "View verify tasks and capture results", ScopeVerifyRead: "View verify tasks and capture results",
ScopeVerifyWrite: "Submit and cancel verify tasks", ScopeVerifyWrite: "Submit and cancel verify tasks",
ScopeSessionsRead: "View interactive development sessions",
ScopeSessionsExecute: "Create and end interactive development sessions",
ScopeAdmin: "Full administrative access (includes all scopes)", ScopeAdmin: "Full administrative access (includes all scopes)",
} }

126
internal/domain/checkout.go Normal file
View File

@ -0,0 +1,126 @@
// Package domain contains pure domain models with no external dependencies.
package domain
import "time"
// CheckoutID is a strongly-typed identifier for checkouts.
type CheckoutID string
// CheckoutStatus represents the state of a checkout.
type CheckoutStatus string
const (
// CheckoutStatusActive indicates the checkout is currently in use.
CheckoutStatusActive CheckoutStatus = "active"
// CheckoutStatusCheckedIn indicates the checkout was completed via checkin.
CheckoutStatusCheckedIn CheckoutStatus = "checked_in"
// CheckoutStatusExpired indicates the checkout token expired.
CheckoutStatusExpired CheckoutStatus = "expired"
// CheckoutStatusRevoked indicates the checkout was manually revoked.
CheckoutStatusRevoked CheckoutStatus = "revoked"
)
// IsTerminal returns true if the status is a final state.
func (s CheckoutStatus) IsTerminal() bool {
return s == CheckoutStatusCheckedIn || s == CheckoutStatusExpired || s == CheckoutStatusRevoked
}
// Checkout represents a checked-out development session.
// A checkout grants temporary git access via an HTTPS token for local development.
type Checkout struct {
// ID is the unique checkout identifier.
ID CheckoutID
// ProjectID is the project being checked out.
ProjectID ProjectID
// Branch is the git branch being worked on.
Branch string
// FeatureSlug is the optional SDLC feature link.
FeatureSlug string
// GiteaTokenID is the Gitea access token ID (for revocation).
GiteaTokenID int64
// GiteaTokenName is the name of the Gitea access token.
GiteaTokenName string
// CloneURL is the base HTTPS clone URL (without token).
// Format: https://git.threesix.ai/owner/repo.git
// The authenticated URL with token is only returned once at checkout creation.
CloneURL string
// CheckedOutBy is the user or API key that created the checkout.
CheckedOutBy string
// CheckedOutAt is when the checkout was created.
CheckedOutAt time.Time
// ExpiresAt is when the checkout token expires.
ExpiresAt time.Time
// Status is the current state of the checkout.
Status CheckoutStatus
// CheckedInAt is when the checkout was completed (if checked in).
CheckedInAt *time.Time
// ReviewTaskID is the work task ID for the review (if review was triggered).
ReviewTaskID string
}
// IsActive returns true if the checkout can still be used.
func (c *Checkout) IsActive() bool {
return c.Status == CheckoutStatusActive && time.Now().Before(c.ExpiresAt)
}
// IsExpired returns true if the checkout has expired.
func (c *Checkout) IsExpired() bool {
return c.Status == CheckoutStatusActive && time.Now().After(c.ExpiresAt)
}
// GitBranch represents a git branch in a repository.
type GitBranch struct {
// Name is the branch name.
Name string
// CommitSHA is the latest commit on the branch.
CommitSHA string
// Protected indicates if the branch has protection rules.
Protected bool
}
// GitAccessToken represents a temporary git access token.
type GitAccessToken struct {
// ID is the token ID (for revocation).
ID int64
// Name is a human-readable name for the token.
Name string
// Token is the actual token value (only available on creation).
Token string
// Scopes are the permissions granted to the token.
Scopes []string
// ExpiresAt is when the token expires.
ExpiresAt *time.Time
}
// CheckoutListOptions provides filtering options for listing checkouts.
type CheckoutListOptions struct {
// Status filters by checkout status (nil = all statuses).
Status *CheckoutStatus
// Limit is the maximum number of results.
Limit int
// Offset is the number of results to skip.
Offset int
}

View File

@ -88,6 +88,20 @@ var (
// Question errors // Question errors
ErrQuestionNotFound = errors.New("question not found") ErrQuestionNotFound = errors.New("question not found")
// Session errors
ErrSessionNotFound = errors.New("session not found")
ErrSessionNotActive = errors.New("session is not active")
ErrSessionExists = errors.New("active session already exists")
// Checkout errors
ErrCheckoutNotFound = errors.New("checkout not found")
ErrCheckoutExpired = errors.New("checkout has expired")
ErrCheckoutAlreadyExists = errors.New("active checkout already exists for branch")
ErrCheckoutNotActive = errors.New("checkout is not active")
ErrBranchNotFound = errors.New("branch not found")
ErrBranchProtected = errors.New("branch is protected")
ErrInvalidCheckoutID = errors.New("invalid checkout ID")
// Infrastructure errors (should typically be wrapped) // Infrastructure errors (should typically be wrapped)
ErrDatabaseConnection = errors.New("database connection error") ErrDatabaseConnection = errors.New("database connection error")
ErrKubernetesError = errors.New("kubernetes error") ErrKubernetesError = errors.New("kubernetes error")

View File

@ -0,0 +1,70 @@
// Package domain contains pure domain models with no external dependencies.
package domain
import "time"
// SessionID is a strongly-typed identifier for sessions.
type SessionID string
// SessionStatus represents the state of a session.
type SessionStatus string
const (
// SessionStatusActive indicates the session is currently in use.
SessionStatusActive SessionStatus = "active"
// SessionStatusEnded indicates the session was completed via checkin.
SessionStatusEnded SessionStatus = "ended"
// SessionStatusExpired indicates the session expired without checkin.
SessionStatusExpired SessionStatus = "expired"
)
// Session represents an interactive remote development session.
// A session composes a checkout (git token + branch), pod binding, and ephemeral preview URL.
type Session struct {
// ID is the unique session identifier.
ID SessionID
// ProjectID is the project this session belongs to.
ProjectID ProjectID
// CheckoutID links to the checkout that provides git access.
CheckoutID CheckoutID
// PodName is the bound project pod for command execution.
PodName string
// PreviewURL is the full HTTPS URL for the ephemeral preview.
// Format: https://{session-slug}.preview.threesix.ai
PreviewURL string
// PreviewHost is the hostname for the K8s Ingress.
// Format: {session-slug}.preview.threesix.ai
PreviewHost string
// CreatedBy is the user or API key that created the session.
CreatedBy string
// CreatedAt is when the session was created.
CreatedAt time.Time
// ExpiresAt is when the session will automatically expire.
ExpiresAt time.Time
// Status is the current state of the session.
Status SessionStatus
// EndedAt is when the session was ended (if ended or expired).
EndedAt *time.Time
}
// IsActive returns true if the session can still be used.
func (s *Session) IsActive() bool {
return s.Status == SessionStatusActive && time.Now().Before(s.ExpiresAt)
}
// IsExpired returns true if the session has expired.
func (s *Session) IsExpired() bool {
return s.Status == SessionStatusActive && time.Now().After(s.ExpiresAt)
}

View File

@ -0,0 +1,457 @@
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"
)
// CheckoutHandler handles checkout/checkin endpoints for sidecar development.
type CheckoutHandler struct {
checkoutService *service.CheckoutService
}
// NewCheckoutHandler creates a new checkout handler.
func NewCheckoutHandler(checkoutService *service.CheckoutService) *CheckoutHandler {
return &CheckoutHandler{checkoutService: checkoutService}
}
// Mount registers the checkout routes.
func (h *CheckoutHandler) Mount(r api.Router) {
r.Route("/projects/{id}/checkout", func(r chi.Router) {
// Branch listing (read access)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/branches", h.ListBranches)
// List active checkouts (read access)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/", h.List)
// Create checkout (execute access - creates tokens)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/", h.Create)
// Get single checkout (read access)
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
Get("/{checkout_id}", h.Get)
// Checkin (execute access - completes checkout)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/{checkout_id}/checkin", h.Checkin)
// Revoke checkout (admin only - security sensitive)
r.With(auth.RequireScope(auth.ScopeAdmin)).
Delete("/{checkout_id}", h.Revoke)
})
}
// BranchResponse is the JSON response for a branch.
type BranchResponse struct {
Name string `json:"name"`
CommitSHA string `json:"commit_sha"`
Protected bool `json:"protected"`
}
// ListBranches returns all branches for a project's repository.
// GET /projects/{id}/checkout/branches
func (h *CheckoutHandler) ListBranches(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(), TimeoutLookup)
defer cancel()
branches, err := h.checkoutService.ListBranches(ctx, domain.ProjectID(projectID))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
api.WriteInternalError(w, r, "Failed to list branches")
return
}
resp := make([]BranchResponse, len(branches))
for i, b := range branches {
resp[i] = BranchResponse{
Name: b.Name,
CommitSHA: b.CommitSHA,
Protected: b.Protected,
}
}
api.WriteSuccess(w, r, map[string]any{
"branches": resp,
})
}
// CheckoutRequest is the JSON body for creating a checkout.
type CheckoutRequest struct {
Branch string `json:"branch,omitempty"` // Existing branch to checkout
NewBranch string `json:"new_branch,omitempty"` // New branch to create
FromRef string `json:"from_ref,omitempty"` // Reference for new branch (default: main)
FeatureSlug string `json:"feature_slug,omitempty"` // Optional SDLC feature link
ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d")
}
// CheckoutResponse is the JSON response for a checkout.
type CheckoutResponse struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Branch string `json:"branch"`
FeatureSlug string `json:"feature_slug,omitempty"`
CloneURL string `json:"clone_url"`
CheckedOutBy string `json:"checked_out_by"`
CheckedOutAt string `json:"checked_out_at"`
ExpiresAt string `json:"expires_at"`
Status string `json:"status"`
CheckedInAt *string `json:"checked_in_at,omitempty"`
ReviewTaskID string `json:"review_task_id,omitempty"`
Instructions string `json:"instructions,omitempty"`
}
// List returns active checkouts for a project.
// GET /projects/{id}/checkout
func (h *CheckoutHandler) 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()
checkouts, err := h.checkoutService.List(ctx, domain.ProjectID(projectID))
if err != nil {
api.WriteInternalError(w, r, "Failed to list checkouts")
return
}
resp := make([]CheckoutResponse, len(checkouts))
for i, c := range checkouts {
resp[i] = checkoutToResponse(c, "")
}
api.WriteSuccess(w, r, map[string]any{
"checkouts": resp,
})
}
// Create creates a new checkout with a temporary git token.
// POST /projects/{id}/checkout
func (h *CheckoutHandler) 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 CheckoutRequest
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
}
}
// Cap at 30 days
if expiresIn > 30*24*time.Hour {
api.WriteBadRequest(w, r, "expires_in cannot exceed 30 days")
return
}
}
// Get user from API key
checkedOutBy := "unknown"
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil {
checkedOutBy = string(apiKey.ID)
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
result, err := h.checkoutService.Checkout(ctx, service.CheckoutRequest{
ProjectID: domain.ProjectID(projectID),
Branch: req.Branch,
NewBranch: req.NewBranch,
FromRef: req.FromRef,
FeatureSlug: req.FeatureSlug,
ExpiresIn: expiresIn,
CheckedOutBy: checkedOutBy,
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
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 checkout")
return
}
// Return authenticated URL only at creation time
resp := checkoutToResponse(result.Checkout, result.Instructions)
resp.CloneURL = result.AuthenticatedCloneURL // Override with authenticated URL for creation response
api.WriteCreated(w, r, resp)
}
// Get retrieves a checkout by ID.
// GET /projects/{id}/checkout/{checkout_id}
func (h *CheckoutHandler) 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
}
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
api.WriteBadRequest(w, r, "checkout_id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
defer cancel()
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteInternalError(w, r, "Failed to get checkout")
return
}
// Verify checkout belongs to project
if string(checkout.ProjectID) != projectID {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteSuccess(w, r, checkoutToResponse(checkout, ""))
}
// CheckinRequest is the JSON body for checking in.
type CheckinRequestBody struct {
SkipReview bool `json:"skip_review,omitempty"` // Skip automatic review
AutoMerge bool `json:"auto_merge,omitempty"` // Merge to main if review passes
}
// CheckinResponse is the JSON response for a checkin.
type CheckinResponse struct {
CheckoutID string `json:"checkout_id"`
Status string `json:"status"`
ReviewTaskID string `json:"review_task_id,omitempty"`
Message string `json:"message"`
}
// Checkin completes a checkout and optionally queues a review.
// POST /projects/{id}/checkout/{checkout_id}/checkin
func (h *CheckoutHandler) 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
}
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
api.WriteBadRequest(w, r, "checkout_id is required")
return
}
var req CheckinRequestBody
if err := api.DecodeJSON(r, &req); err != nil {
// Empty body is OK - use defaults
req = CheckinRequestBody{}
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// First verify checkout exists and belongs to project
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteInternalError(w, r, "Failed to get checkout")
return
}
if string(checkout.ProjectID) != projectID {
api.WriteNotFound(w, r, "checkout not found")
return
}
result, err := h.checkoutService.Checkin(ctx, service.CheckinRequest{
CheckoutID: domain.CheckoutID(checkoutID),
SkipReview: req.SkipReview,
AutoMerge: req.AutoMerge,
})
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
if errors.Is(err, domain.ErrCheckoutNotActive) {
api.WriteBadRequest(w, r, "checkout is not active")
return
}
api.WriteInternalError(w, r, "Failed to checkin")
return
}
message := "Checkout completed. Token has been revoked."
if result.ReviewTaskID != "" {
message = "Checkout completed. Review task queued."
}
api.WriteSuccess(w, r, CheckinResponse{
CheckoutID: string(result.CheckoutID),
Status: string(result.Status),
ReviewTaskID: result.ReviewTaskID,
Message: message,
})
}
// Revoke manually revokes an active checkout.
// DELETE /projects/{id}/checkout/{checkout_id}
func (h *CheckoutHandler) Revoke(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
}
checkoutID := chi.URLParam(r, "checkout_id")
if checkoutID == "" {
api.WriteBadRequest(w, r, "checkout_id is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
// First verify checkout exists and belongs to project
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
if err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
api.WriteInternalError(w, r, "Failed to get checkout")
return
}
if string(checkout.ProjectID) != projectID {
api.WriteNotFound(w, r, "checkout not found")
return
}
if err := h.checkoutService.Revoke(ctx, domain.CheckoutID(checkoutID)); err != nil {
if errors.Is(err, domain.ErrCheckoutNotFound) {
api.WriteNotFound(w, r, "checkout not found")
return
}
if errors.Is(err, domain.ErrCheckoutNotActive) {
api.WriteBadRequest(w, r, "checkout is not active")
return
}
api.WriteInternalError(w, r, "Failed to revoke checkout")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "revoked",
"id": checkoutID,
"message": "Checkout revoked. Token has been invalidated.",
})
}
// checkoutToResponse converts a domain checkout to a response.
func checkoutToResponse(c *domain.Checkout, instructions string) CheckoutResponse {
resp := CheckoutResponse{
ID: string(c.ID),
ProjectID: string(c.ProjectID),
Branch: c.Branch,
FeatureSlug: c.FeatureSlug,
CloneURL: c.CloneURL,
CheckedOutBy: c.CheckedOutBy,
CheckedOutAt: c.CheckedOutAt.Format(time.RFC3339),
ExpiresAt: c.ExpiresAt.Format(time.RFC3339),
Status: string(c.Status),
ReviewTaskID: c.ReviewTaskID,
Instructions: instructions,
}
if c.CheckedInAt != nil {
s := c.CheckedInAt.Format(time.RFC3339)
resp.CheckedInAt = &s
}
return resp
}

View File

@ -172,7 +172,7 @@ func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request)
return return
} }
if err := h.deployer.Undeploy(ctx, projectID); err != nil { if err := h.deployer.UndeployAll(ctx, projectID); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to undeploy: %v", err)) api.WriteInternalError(w, r, fmt.Sprintf("failed to undeploy: %v", err))
return return
} }

View File

@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
) )
// mockGitRepository implements port.GitRepository for testing. // mockGitRepository implements port.GitRepository for testing.
@ -88,6 +89,43 @@ func (m *mockGitRepository) DeleteWebhook(context.Context, string, string, int64
return m.err return m.err
} }
func (m *mockGitRepository) ListBranches(_ context.Context, _, _ string) ([]*domain.GitBranch, error) {
if m.err != nil {
return nil, m.err
}
return []*domain.GitBranch{
{Name: "main", CommitSHA: "abc123", Protected: true},
{Name: "develop", CommitSHA: "def456", Protected: false},
}, nil
}
func (m *mockGitRepository) CreateBranch(_ context.Context, _, _, branchName, _ string) (*domain.GitBranch, error) {
if m.err != nil {
return nil, m.err
}
return &domain.GitBranch{
Name: branchName,
CommitSHA: "newcommit123",
Protected: false,
}, nil
}
func (m *mockGitRepository) CreateAccessToken(_ context.Context, name string, _ []string, _ *time.Time) (*domain.GitAccessToken, error) {
if m.err != nil {
return nil, m.err
}
return &domain.GitAccessToken{
ID: 12345,
Name: name,
Token: "test-token-12345",
Scopes: []string{"write:repository"},
}, nil
}
func (m *mockGitRepository) DeleteAccessToken(_ context.Context, _ int64) error {
return m.err
}
// mockDNSProvider implements port.DNSProvider for testing. // mockDNSProvider implements port.DNSProvider for testing.
type mockDNSProvider struct { type mockDNSProvider struct {
records map[string]*domain.DNSRecord records map[string]*domain.DNSRecord
@ -215,6 +253,14 @@ func (m *mockDeployer) Undeploy(_ context.Context, projectName string) error {
return nil return nil
} }
func (m *mockDeployer) UndeployAll(_ context.Context, projectName string) error {
if m.err != nil {
return m.err
}
delete(m.deployments, projectName)
return nil
}
func (m *mockDeployer) GetStatus(_ context.Context, projectName string) (*domain.DeployStatus, error) { func (m *mockDeployer) GetStatus(_ context.Context, projectName string) (*domain.DeployStatus, error) {
if m.err != nil { if m.err != nil {
return nil, m.err return nil, m.err
@ -295,3 +341,29 @@ func (m *mockDeployer) AddIngressPath(_ context.Context, _, _, _, _ string, _ in
func (m *mockDeployer) RemoveIngressPath(_ context.Context, _, _, _ string) error { func (m *mockDeployer) RemoveIngressPath(_ context.Context, _, _, _ string) error {
return m.err return m.err
} }
// mockPreviewManager implements port.PreviewManager for testing.
type mockPreviewManager struct {
previews map[string]bool
err error
}
func newMockPreviewManager() *mockPreviewManager {
return &mockPreviewManager{previews: make(map[string]bool)}
}
func (m *mockPreviewManager) CreatePreview(_ context.Context, opts port.PreviewOptions) error {
if m.err != nil {
return m.err
}
m.previews[opts.SessionID] = true
return nil
}
func (m *mockPreviewManager) DeletePreview(_ context.Context, sessionID string) error {
if m.err != nil {
return m.err
}
delete(m.previews, sessionID)
return nil
}

View File

@ -0,0 +1,415 @@
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
}

View File

@ -0,0 +1,505 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
)
// mockSessionRepository implements port.SessionRepository for testing.
type mockSessionRepository struct {
sessions map[string]*domain.Session
err error
}
func newMockSessionRepository() *mockSessionRepository {
return &mockSessionRepository{sessions: make(map[string]*domain.Session)}
}
func (m *mockSessionRepository) Create(_ context.Context, session *domain.Session) error {
if m.err != nil {
return m.err
}
// Check unique constraint: only one active per project.
for _, s := range m.sessions {
if s.ProjectID == session.ProjectID && s.Status == domain.SessionStatusActive {
return domain.ErrSessionExists
}
}
session.ID = domain.SessionID("test-session-id")
m.sessions[string(session.ID)] = session
return nil
}
func (m *mockSessionRepository) Get(_ context.Context, id domain.SessionID) (*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
s, ok := m.sessions[string(id)]
if !ok {
return nil, domain.ErrSessionNotFound
}
return s, nil
}
func (m *mockSessionRepository) GetActiveByProject(_ context.Context, projectID domain.ProjectID) (*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
for _, s := range m.sessions {
if s.ProjectID == projectID && s.Status == domain.SessionStatusActive {
return s, nil
}
}
return nil, domain.ErrSessionNotFound
}
func (m *mockSessionRepository) ListByProject(_ context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
var result []*domain.Session
for _, s := range m.sessions {
if s.ProjectID == projectID {
result = append(result, s)
}
}
return result, nil
}
func (m *mockSessionRepository) SetEnded(_ context.Context, id domain.SessionID) error {
if m.err != nil {
return m.err
}
s, ok := m.sessions[string(id)]
if !ok || s.Status != domain.SessionStatusActive {
return domain.ErrSessionNotActive
}
s.Status = domain.SessionStatusEnded
now := time.Now()
s.EndedAt = &now
return nil
}
func (m *mockSessionRepository) CleanupExpired(_ context.Context) ([]*domain.Session, error) {
if m.err != nil {
return nil, m.err
}
var expired []*domain.Session
for _, s := range m.sessions {
if s.Status == domain.SessionStatusActive && time.Now().After(s.ExpiresAt) {
s.Status = domain.SessionStatusExpired
now := time.Now()
s.EndedAt = &now
expired = append(expired, s)
}
}
return expired, nil
}
// mockCheckoutRepository implements port.CheckoutRepository for testing sessions.
type mockCheckoutRepository struct {
checkouts map[string]*domain.Checkout
err error
}
func newMockCheckoutRepository() *mockCheckoutRepository {
return &mockCheckoutRepository{checkouts: make(map[string]*domain.Checkout)}
}
func (m *mockCheckoutRepository) Create(_ context.Context, c *domain.Checkout) error {
if m.err != nil {
return m.err
}
c.ID = domain.CheckoutID("test-checkout-id")
m.checkouts[string(c.ID)] = c
return nil
}
func (m *mockCheckoutRepository) Get(_ context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
if m.err != nil {
return nil, m.err
}
c, ok := m.checkouts[string(id)]
if !ok {
return nil, domain.ErrCheckoutNotFound
}
return c, nil
}
func (m *mockCheckoutRepository) GetByProjectBranch(_ context.Context, _ domain.ProjectID, _ string) (*domain.Checkout, error) {
return nil, domain.ErrCheckoutNotFound
}
func (m *mockCheckoutRepository) List(_ context.Context, _ domain.CheckoutListOptions) ([]*domain.Checkout, error) {
return nil, nil
}
func (m *mockCheckoutRepository) ListByProject(_ context.Context, _ domain.ProjectID) ([]*domain.Checkout, error) {
return nil, nil
}
func (m *mockCheckoutRepository) UpdateStatus(_ context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
if c, ok := m.checkouts[string(id)]; ok {
c.Status = status
}
return nil
}
func (m *mockCheckoutRepository) SetCheckedIn(_ context.Context, id domain.CheckoutID, _ string) error {
if c, ok := m.checkouts[string(id)]; ok {
c.Status = domain.CheckoutStatusCheckedIn
now := time.Now()
c.CheckedInAt = &now
}
return nil
}
func (m *mockCheckoutRepository) SetReviewTask(_ context.Context, _ domain.CheckoutID, _ string) error {
return nil
}
func (m *mockCheckoutRepository) CleanupExpired(_ context.Context) ([]int64, error) {
return nil, nil
}
// setupSessionTest creates a sessions handler with mock dependencies.
// It reuses mockProjectRepo from queue_test.go.
func setupSessionTest() (*SessionsHandler, *mockSessionRepository, *mockProjectRepo) {
sessionRepo := newMockSessionRepository()
checkoutRepo := newMockCheckoutRepository()
projectRepo := newMockProjectRepo()
gitRepo := newMockGitRepository()
previewMgr := newMockPreviewManager()
// Add a test project.
projectRepo.projects["test-project"] = &domain.Project{
ID: "test-project",
Name: "test-project",
PodName: "test-project-0",
Status: domain.ProjectStatusRunning,
}
checkoutService := service.NewCheckoutService(
checkoutRepo,
gitRepo,
projectRepo,
service.CheckoutServiceConfig{
GiteaURL: "https://git.threesix.ai",
DefaultOwner: "threesix",
DefaultExpiry: 24 * time.Hour,
},
)
sessionService := service.NewSessionService(
sessionRepo,
checkoutService,
projectRepo,
previewMgr,
service.SessionServiceConfig{
PreviewDomain: "preview.threesix.ai",
DefaultExpiry: 24 * time.Hour,
},
)
handler := NewSessionsHandler(sessionService)
return handler, sessionRepo, projectRepo
}
func TestSessionsHandler_Create(t *testing.T) {
handler, _, _ := setupSessionTest()
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("create_session", func(t *testing.T) {
body := `{"branch": "develop"}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data map, got %T", resp["data"])
}
if data["id"] == "" {
t.Error("expected non-empty session id")
}
if data["preview_url"] == "" {
t.Error("expected non-empty preview_url")
}
if data["auth_clone_url"] == "" {
t.Error("expected auth_clone_url on creation")
}
if data["instructions"] == "" {
t.Error("expected instructions on creation")
}
if data["pod_name"] != "test-project-0" {
t.Errorf("expected pod_name=test-project-0, got %v", data["pod_name"])
}
})
t.Run("create_session_no_branch", func(t *testing.T) {
body := `{}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
}
})
t.Run("create_session_project_not_found", func(t *testing.T) {
body := `{"branch": "develop"}`
req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/sessions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
}
})
}
func TestSessionsHandler_Get(t *testing.T) {
handler, sessionRepo, _ := setupSessionTest()
// Seed a session.
session := &domain.Session{
ID: "session-abc",
ProjectID: "test-project",
CheckoutID: "checkout-abc",
PodName: "test-project-0",
PreviewURL: "https://abc.preview.threesix.ai",
PreviewHost: "abc.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Status: domain.SessionStatusActive,
}
sessionRepo.sessions["session-abc"] = session
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("get_existing", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/session-abc", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
})
t.Run("get_wrong_project", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/other-project/sessions/session-abc", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
}
})
t.Run("get_not_found", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/nonexistent", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
}
})
}
func TestSessionsHandler_List(t *testing.T) {
handler, sessionRepo, _ := setupSessionTest()
// Seed sessions.
sessionRepo.sessions["session-1"] = &domain.Session{
ID: "session-1",
ProjectID: "test-project",
CheckoutID: "checkout-1",
PodName: "test-project-0",
PreviewURL: "https://s1.preview.threesix.ai",
PreviewHost: "s1.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Status: domain.SessionStatusActive,
}
sessionRepo.sessions["session-2"] = &domain.Session{
ID: "session-2",
ProjectID: "test-project",
CheckoutID: "checkout-2",
PodName: "test-project-0",
PreviewURL: "https://s2.preview.threesix.ai",
PreviewHost: "s2.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now().Add(-1 * time.Hour),
ExpiresAt: time.Now().Add(23 * time.Hour),
Status: domain.SessionStatusEnded,
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data map, got %T", resp["data"])
}
sessions, ok := data["sessions"].([]any)
if !ok {
t.Fatalf("expected sessions array, got %T", data["sessions"])
}
if len(sessions) != 2 {
t.Errorf("got %d sessions, want 2", len(sessions))
}
}
func TestSessionsHandler_Checkin(t *testing.T) {
handler, sessionRepo, _ := setupSessionTest()
// Seed an active session with a matching checkout.
sessionRepo.sessions["session-end"] = &domain.Session{
ID: "session-end",
ProjectID: "test-project",
CheckoutID: "checkout-end",
PodName: "test-project-0",
PreviewURL: "https://end.preview.threesix.ai",
PreviewHost: "end.preview.threesix.ai",
CreatedBy: "test",
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
Status: domain.SessionStatusActive,
}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
t.Run("checkin_session", func(t *testing.T) {
// Checkout doesn't exist in the mock, but session service continues on checkout errors.
body := `{"skip_review": true}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
})
t.Run("checkin_already_ended", func(t *testing.T) {
body := `{}`
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
})
}
func TestWorkersHandler_PoolStatus(t *testing.T) {
registry := newMockWorkerRegistry()
queue := newMockWorkQueue()
workerService := service.NewWorkerService(registry, queue)
handler := NewWorkersHandler(workerService).WithWorkQueue(queue)
registry.workers["w1"] = &domain.Worker{
ID: "w1", Hostname: "h1", Status: domain.WorkerStatusIdle,
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
}
registry.workers["w2"] = &domain.Worker{
ID: "w2", Hostname: "h2", Status: domain.WorkerStatusBusy,
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
}
// Add a pending task to the queue.
queue.tasks["t1"] = &domain.WorkTask{ID: "t1", Status: domain.WorkTaskStatusPending}
router := chi.NewRouter()
router.Use(testAdminAuth)
handler.Mount(router)
req := httptest.NewRequest(http.MethodGet, "/workers/pool", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatalf("expected data map, got %T", resp["data"])
}
if int(data["total"].(float64)) != 2 {
t.Errorf("total: got %v, want 2", data["total"])
}
if int(data["idle"].(float64)) != 1 {
t.Errorf("idle: got %v, want 1", data["idle"])
}
if int(data["busy"].(float64)) != 1 {
t.Errorf("busy: got %v, want 1", data["busy"])
}
if int(data["available"].(float64)) != 1 {
t.Errorf("available: got %v, want 1", data["available"])
}
if int(data["queue_depth"].(float64)) != 1 {
t.Errorf("queue_depth: got %v, want 1", data["queue_depth"])
}
}
// Verify port.PreviewManager is implemented by the mock.
var _ port.PreviewManager = (*mockPreviewManager)(nil)

View File

@ -17,6 +17,7 @@ import (
type WorkersHandler struct { type WorkersHandler struct {
workerService *service.WorkerService workerService *service.WorkerService
workService service.WorkServiceFailer workService service.WorkServiceFailer
workQueue port.WorkQueue
} }
// NewWorkersHandler creates a new workers handler. // NewWorkersHandler creates a new workers handler.
@ -33,11 +34,18 @@ func (h *WorkersHandler) WithWorkService(ws service.WorkServiceFailer) *WorkersH
return h return h
} }
// WithWorkQueue adds a work queue for pool status endpoint.
func (h *WorkersHandler) WithWorkQueue(wq port.WorkQueue) *WorkersHandler {
h.workQueue = wq
return h
}
// Mount registers the worker pool routes. // Mount registers the worker pool routes.
func (h *WorkersHandler) Mount(r api.Router) { func (h *WorkersHandler) Mount(r api.Router) {
r.Route("/workers", func(r chi.Router) { r.Route("/workers", func(r chi.Router) {
// Read operations // Read operations
r.With(auth.RequireScope(auth.ScopeWorkersRead, auth.ScopeAdmin)).Get("/", h.List) r.With(auth.RequireScope(auth.ScopeWorkersRead, auth.ScopeAdmin)).Get("/", h.List)
r.With(auth.RequireScope(auth.ScopeWorkersRead, auth.ScopeAdmin)).Get("/pool", h.PoolStatus)
r.With(auth.RequireScope(auth.ScopeWorkersRead, auth.ScopeAdmin)).Get("/{workerId}", h.Get) r.With(auth.RequireScope(auth.ScopeWorkersRead, auth.ScopeAdmin)).Get("/{workerId}", h.Get)
// Write operations // Write operations
@ -386,3 +394,56 @@ func (h *WorkersHandler) FailTask(w http.ResponseWriter, r *http.Request) {
"status": "failed", "status": "failed",
}) })
} }
// PoolStatusResponse is the aggregate worker pool capacity.
type PoolStatusResponse struct {
Total int `json:"total"`
Idle int `json:"idle"`
Busy int `json:"busy"`
Draining int `json:"draining"`
Offline int `json:"offline"`
Available int `json:"available"` // Idle workers ready to accept work
QueueDepth int64 `json:"queue_depth"` // Pending tasks in work queue
}
// PoolStatus returns aggregate worker pool capacity for scaling decisions.
// GET /workers/pool
func (h *WorkersHandler) PoolStatus(w http.ResponseWriter, r *http.Request) {
workers, err := h.workerService.ListWorkers(r.Context(), port.WorkerFilter{})
if err != nil {
api.WriteInternalError(w, r, "failed to list workers")
return
}
idle, busy, draining, offline := 0, 0, 0, 0
for _, wkr := range workers {
switch wkr.Status {
case domain.WorkerStatusIdle:
idle++
case domain.WorkerStatusBusy:
busy++
case domain.WorkerStatusDraining:
draining++
case domain.WorkerStatusOffline:
offline++
}
}
var queueDepth int64
if h.workQueue != nil {
stats, err := h.workQueue.GetStats(r.Context())
if err == nil && stats != nil {
queueDepth = stats.Pending
}
}
api.WriteSuccess(w, r, PoolStatusResponse{
Total: len(workers),
Idle: idle,
Busy: busy,
Draining: draining,
Offline: offline,
Available: idle,
QueueDepth: queueDepth,
})
}

View File

@ -0,0 +1,38 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import (
"context"
"github.com/orchard9/rdev/internal/domain"
)
// CheckoutRepository manages checkout persistence.
type CheckoutRepository interface {
// Create stores a new checkout record.
Create(ctx context.Context, checkout *domain.Checkout) error
// Get retrieves a checkout by ID.
Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error)
// GetByProjectBranch retrieves an active checkout for a project+branch.
GetByProjectBranch(ctx context.Context, projectID domain.ProjectID, branch string) (*domain.Checkout, error)
// List returns checkouts matching the given options.
List(ctx context.Context, opts domain.CheckoutListOptions) ([]*domain.Checkout, error)
// ListByProject returns all checkouts for a project.
ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error)
// UpdateStatus updates the status of a checkout.
UpdateStatus(ctx context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error
// SetCheckedIn marks a checkout as checked in with timestamp.
SetCheckedIn(ctx context.Context, id domain.CheckoutID, reviewTaskID string) error
// SetReviewTask sets the review task ID for a checkout.
SetReviewTask(ctx context.Context, id domain.CheckoutID, taskID string) error
// CleanupExpired marks expired checkouts and returns their Gitea token IDs for revocation.
CleanupExpired(ctx context.Context) ([]int64, error)
}

View File

@ -14,9 +14,13 @@ type Deployer interface {
// For monorepo projects with ComponentPath set, creates component-specific resources. // For monorepo projects with ComponentPath set, creates component-specific resources.
Deploy(ctx context.Context, spec domain.DeploySpec) error Deploy(ctx context.Context, spec domain.DeploySpec) error
// Undeploy removes all deployment resources for a project. // Undeploy removes all deployment resources for a project by exact name.
Undeploy(ctx context.Context, projectName string) error Undeploy(ctx context.Context, projectName string) error
// UndeployAll removes all deployment resources matching the project label.
// This handles monorepo components (e.g., {project}-{component}) that Undeploy misses.
UndeployAll(ctx context.Context, projectName string) error
// UndeployComponent removes deployment resources for a specific component. // UndeployComponent removes deployment resources for a specific component.
// The componentPath is the path within the monorepo (e.g., "services/auth-api"). // The componentPath is the path within the monorepo (e.g., "services/auth-api").
UndeployComponent(ctx context.Context, projectName, componentPath string) error UndeployComponent(ctx context.Context, projectName, componentPath string) error

View File

@ -3,6 +3,7 @@ package port
import ( import (
"context" "context"
"time"
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
) )
@ -39,4 +40,17 @@ type GitRepository interface {
// DeleteWebhook removes a webhook from a repo. // DeleteWebhook removes a webhook from a repo.
DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error DeleteWebhook(ctx context.Context, owner, repo string, webhookID int64) error
// ListBranches returns all branches for a repository.
ListBranches(ctx context.Context, owner, repo string) ([]*domain.GitBranch, error)
// CreateBranch creates a new branch from a reference (branch name or commit SHA).
CreateBranch(ctx context.Context, owner, repo, branchName, fromRef string) (*domain.GitBranch, error)
// CreateAccessToken creates a new personal access token for git operations.
// Returns the token value (only available once), token ID, and any error.
CreateAccessToken(ctx context.Context, name string, scopes []string, expiresAt *time.Time) (*domain.GitAccessToken, error)
// DeleteAccessToken revokes and deletes an access token.
DeleteAccessToken(ctx context.Context, tokenID int64) error
} }

32
internal/port/preview.go Normal file
View File

@ -0,0 +1,32 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import "context"
// PreviewManager manages ephemeral preview URLs via K8s Service + Ingress.
type PreviewManager interface {
// CreatePreview creates a K8s Service + Ingress for the session preview.
// The service routes traffic to the target pod on the specified port.
CreatePreview(ctx context.Context, opts PreviewOptions) error
// DeletePreview removes the K8s Service + Ingress for a session preview.
DeletePreview(ctx context.Context, sessionID string) error
}
// PreviewOptions configures an ephemeral preview.
type PreviewOptions struct {
// SessionID is used as the resource name prefix (e.g., "session-{id}").
SessionID string
// Namespace is the K8s namespace (typically "rdev").
Namespace string
// PodName is the target pod to route traffic to.
PodName string
// Host is the preview hostname (e.g., "abc123.preview.threesix.ai").
Host string
// Port is the target port on the pod (default: 8080).
Port int
}

View File

@ -0,0 +1,29 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import (
"context"
"github.com/orchard9/rdev/internal/domain"
)
// SessionRepository manages session persistence.
type SessionRepository interface {
// Create stores a new session record.
Create(ctx context.Context, session *domain.Session) error
// Get retrieves a session by ID.
Get(ctx context.Context, id domain.SessionID) (*domain.Session, error)
// GetActiveByProject retrieves the active session for a project (at most one).
GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error)
// ListByProject returns all sessions for a project, ordered by created_at DESC.
ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error)
// SetEnded marks a session as ended with a timestamp.
SetEnded(ctx context.Context, id domain.SessionID) error
// CleanupExpired marks expired sessions and returns them for preview teardown.
CleanupExpired(ctx context.Context) ([]*domain.Session, error)
}

View File

@ -0,0 +1,479 @@
// 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
}

View File

@ -2,6 +2,7 @@ package service
import ( import (
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/domain"
@ -64,7 +65,8 @@ func (s *ComponentService) updateGoWork(existing, componentPath string) string {
return strings.Join(newLines, "\n") return strings.Join(newLines, "\n")
} }
// updateWoodpeckerYml inserts the component step at the COMPONENT_STEPS_BELOW marker. // updateWoodpeckerYml inserts the component step at the COMPONENT_STEPS_BELOW marker
// and updates build-complete's depends_on to include the new deploy step.
func (s *ComponentService) updateWoodpeckerYml(existing, stepYaml string) string { func (s *ComponentService) updateWoodpeckerYml(existing, stepYaml string) string {
marker := "# COMPONENT_STEPS_BELOW" marker := "# COMPONENT_STEPS_BELOW"
@ -84,7 +86,72 @@ func (s *ComponentService) updateWoodpeckerYml(existing, stepYaml string) string
} }
// Insert after the marker // Insert after the marker
return strings.Replace(existing, marker, marker+"\n\n"+strings.TrimRight(sb.String(), "\n"), 1) result := strings.Replace(existing, marker, marker+"\n\n"+strings.TrimRight(sb.String(), "\n"), 1)
// Extract deploy step name from the step YAML (e.g., "deploy-studio-api:")
deployStep := extractDeployStepName(stepYaml)
if deployStep != "" {
result = addBuildCompleteDep(result, deployStep)
}
return result
}
// extractDeployStepName finds the deploy-{name}: key in a step YAML block.
func extractDeployStepName(stepYaml string) string {
re := regexp.MustCompile(`(?m)^deploy-([a-zA-Z0-9_-]+):`)
match := re.FindStringSubmatch(stepYaml)
if len(match) >= 2 {
return "deploy-" + match[1]
}
return ""
}
// addBuildCompleteDep appends a step name to build-complete's depends_on line
// identified by the BUILD_COMPLETE_DEPS marker.
func addBuildCompleteDep(yml, stepName string) string {
const depsMarker = "# BUILD_COMPLETE_DEPS"
lines := strings.Split(yml, "\n")
for i, line := range lines {
if !strings.Contains(line, depsMarker) {
continue
}
// Parse existing depends_on array from the line
// Format: " depends_on: [preflight, deploy-foo] # BUILD_COMPLETE_DEPS"
re := regexp.MustCompile(`depends_on:\s*\[([^\]]*)\]`)
match := re.FindStringSubmatch(line)
if len(match) < 2 {
break
}
// Parse existing deps
existing := strings.TrimSpace(match[1])
var deps []string
for _, d := range strings.Split(existing, ",") {
d = strings.TrimSpace(d)
if d != "" {
deps = append(deps, d)
}
}
// Append new dep if not already present
for _, d := range deps {
if d == stepName {
return yml
}
}
deps = append(deps, stepName)
// Reconstruct the line preserving indentation
indent := line[:len(line)-len(strings.TrimLeft(line, " \t"))]
newLine := fmt.Sprintf("%sdepends_on: [%s] %s", indent, strings.Join(deps, ", "), depsMarker)
lines[i] = newLine
return strings.Join(lines, "\n")
}
return yml
} }
// updateClaudeMd adds the component to the routing table. // updateClaudeMd adds the component to the routing table.

View File

@ -693,7 +693,7 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
// 1. Undeploy if deployed // 1. Undeploy if deployed
if s.deployer != nil && status.DeploymentStatus != "none" { if s.deployer != nil && status.DeploymentStatus != "none" {
if err := s.deployer.Undeploy(ctx, projectID); err != nil { if err := s.deployer.UndeployAll(ctx, projectID); err != nil {
log.Warn("failed to undeploy", logging.FieldError, err) log.Warn("failed to undeploy", logging.FieldError, err)
} }
} }

View File

@ -0,0 +1,383 @@
// 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
}

View File

@ -0,0 +1,118 @@
package worker
import (
"context"
"sync"
"time"
"github.com/orchard9/rdev/internal/logging"
)
// CheckoutCleanupService defines the interface for checkout cleanup operations.
// This allows the worker to depend on the service interface rather than the concrete type.
type CheckoutCleanupService interface {
CleanupExpired(ctx context.Context) (int, error)
}
// CheckoutCleanup runs periodic cleanup of expired checkouts.
// Expired checkouts have their Gitea tokens revoked and status updated.
type CheckoutCleanup struct {
service CheckoutCleanupService
cleanupInterval time.Duration
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// CheckoutCleanupConfig holds configuration for checkout cleanup.
type CheckoutCleanupConfig struct {
// CleanupInterval is how often to run cleanup.
// Default: 5 minutes.
CleanupInterval time.Duration
}
// DefaultCheckoutCleanupConfig returns sensible defaults.
func DefaultCheckoutCleanupConfig() *CheckoutCleanupConfig {
return &CheckoutCleanupConfig{
CleanupInterval: 5 * time.Minute,
}
}
// NewCheckoutCleanup creates a new checkout cleanup worker.
func NewCheckoutCleanup(service CheckoutCleanupService, cfg *CheckoutCleanupConfig) *CheckoutCleanup {
if cfg == nil {
cfg = DefaultCheckoutCleanupConfig()
}
ctx, cancel := context.WithCancel(context.Background())
return &CheckoutCleanup{
service: service,
cleanupInterval: cfg.CleanupInterval,
ctx: ctx,
cancel: cancel,
}
}
// Start begins the cleanup loop.
func (c *CheckoutCleanup) Start() {
log := logging.FromContext(c.ctx).WithWorker("checkout-cleanup")
log.Info("checkout cleanup started",
"cleanup_interval", c.cleanupInterval,
)
c.wg.Add(1)
go c.cleanupLoop()
}
// Stop gracefully shuts down the cleanup worker.
func (c *CheckoutCleanup) Stop() {
log := logging.FromContext(c.ctx).WithWorker("checkout-cleanup")
log.Info("checkout cleanup stopping")
c.cancel()
c.wg.Wait()
log.Info("checkout cleanup stopped")
}
// cleanupLoop runs periodic cleanup.
func (c *CheckoutCleanup) cleanupLoop() {
defer c.wg.Done()
// Run immediately on start
c.runCleanup()
ticker := time.NewTicker(c.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.runCleanup()
}
}
}
// runCleanup marks expired checkouts and revokes their tokens.
func (c *CheckoutCleanup) runCleanup() {
ctx, cancel := context.WithTimeout(c.ctx, TimeoutMaintenance)
defer cancel()
log := logging.FromContext(ctx).WithWorker("checkout-cleanup")
cleaned, err := c.service.CleanupExpired(ctx)
if err != nil {
log.Error("failed to cleanup expired checkouts",
logging.FieldError, err,
)
return
}
if cleaned > 0 {
log.Info("cleaned up expired checkouts",
"count", cleaned,
)
}
}

View File

@ -0,0 +1,214 @@
package worker
import (
"context"
"database/sql"
"sync"
"time"
"github.com/orchard9/rdev/internal/logging"
)
// resourceReconciler abstracts the K8s operations needed by the GC worker.
// The concrete deployer.Deployer satisfies this interface implicitly.
type resourceReconciler interface {
ListProjectLabels(ctx context.Context) ([]string, error)
GetOldestResourceTime(ctx context.Context, projectName string) (time.Time, bool, error)
UndeployAll(ctx context.Context, projectName string) error
}
// projectChecker determines whether a project exists in the database.
type projectChecker interface {
projectExists(ctx context.Context, projectID string) (bool, error)
}
// dbProjectChecker checks project existence via SQL.
type dbProjectChecker struct {
db *sql.DB
}
func (c *dbProjectChecker) projectExists(ctx context.Context, projectID string) (bool, error) {
var exists bool
err := c.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1)", projectID).Scan(&exists)
return exists, err
}
// ResourceGCConfig holds configuration for the GC worker.
type ResourceGCConfig struct {
// MinAge is the minimum resource age before deletion. Default: 1 hour.
MinAge time.Duration
// ReconcileInterval is how often to run reconciliation. Default: 15 minutes.
ReconcileInterval time.Duration
}
// DefaultResourceGCConfig returns sensible defaults.
func DefaultResourceGCConfig() *ResourceGCConfig {
return &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 15 * time.Minute,
}
}
// ResourceGC periodically finds K8s resources whose project label doesn't
// match any project in the database, and deletes them after a safety window.
type ResourceGC struct {
reconciler resourceReconciler
checker projectChecker
config *ResourceGCConfig
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewResourceGC creates a new resource GC worker.
// The reconciler must implement ListProjectLabels, GetOldestResourceTime, and UndeployAll
// (deployer.Deployer satisfies this). If cfg is nil, defaults are used.
func NewResourceGC(reconciler resourceReconciler, db *sql.DB, cfg *ResourceGCConfig) *ResourceGC {
if cfg == nil {
cfg = DefaultResourceGCConfig()
}
ctx, cancel := context.WithCancel(context.Background())
return &ResourceGC{
reconciler: reconciler,
checker: &dbProjectChecker{db: db},
config: cfg,
ctx: ctx,
cancel: cancel,
}
}
// newResourceGCWithChecker creates a ResourceGC with a custom project checker (for testing).
func newResourceGCWithChecker(reconciler resourceReconciler, checker projectChecker, cfg *ResourceGCConfig) *ResourceGC {
if cfg == nil {
cfg = DefaultResourceGCConfig()
}
ctx, cancel := context.WithCancel(context.Background())
return &ResourceGC{
reconciler: reconciler,
checker: checker,
config: cfg,
ctx: ctx,
cancel: cancel,
}
}
// Start begins the reconciliation loop.
func (g *ResourceGC) Start() {
log := logging.FromContext(g.ctx).WithWorker("resource-gc")
log.Info("resource GC started",
"min_age", g.config.MinAge,
"reconcile_interval", g.config.ReconcileInterval,
)
g.wg.Add(1)
go g.reconcileLoop()
}
// Stop gracefully shuts down the GC worker.
func (g *ResourceGC) Stop() {
log := logging.FromContext(g.ctx).WithWorker("resource-gc")
log.Info("resource GC stopping")
g.cancel()
g.wg.Wait()
log.Info("resource GC stopped")
}
func (g *ResourceGC) reconcileLoop() {
defer g.wg.Done()
// Run immediately on start
g.runReconciliation()
ticker := time.NewTicker(g.config.ReconcileInterval)
defer ticker.Stop()
for {
select {
case <-g.ctx.Done():
return
case <-ticker.C:
g.runReconciliation()
}
}
}
func (g *ResourceGC) runReconciliation() {
ctx, cancel := context.WithTimeout(g.ctx, TimeoutWorkExecution)
defer cancel()
log := logging.FromContext(ctx).WithWorker("resource-gc")
labels, err := g.reconciler.ListProjectLabels(ctx)
if err != nil {
log.Error("failed to list project labels", logging.FieldError, err)
return
}
var deleted, skipped, errCount int
var firstErr error
for _, project := range labels {
exists, err := g.checker.projectExists(ctx, project)
if err != nil {
log.Error("failed to check project existence",
logging.FieldProjectID, project,
logging.FieldError, err,
)
continue
}
if exists {
continue
}
// Project not in DB — check resource age before deleting
oldest, found, err := g.reconciler.GetOldestResourceTime(ctx, project)
if err != nil {
log.Error("failed to get resource age",
logging.FieldProjectID, project,
logging.FieldError, err,
)
continue
}
if !found {
continue
}
age := time.Since(oldest)
if age < g.config.MinAge {
log.Info("skipping young orphan",
logging.FieldProjectID, project,
"age", age,
"min_age", g.config.MinAge,
)
skipped++
continue
}
log.Info("deleting orphaned resources",
logging.FieldProjectID, project,
"age", age,
)
if err := g.reconciler.UndeployAll(ctx, project); err != nil {
log.Error("failed to delete orphaned resources",
logging.FieldProjectID, project,
logging.FieldError, err,
)
errCount++
if firstErr == nil {
firstErr = err
}
continue
}
deleted++
}
if deleted > 0 || skipped > 0 || errCount > 0 {
log.Info("reconciliation complete",
"deleted", deleted,
"skipped", skipped,
"errors", errCount,
"total_labels", len(labels),
)
}
}

View File

@ -0,0 +1,189 @@
package worker
import (
"context"
"testing"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
"github.com/orchard9/rdev/internal/adapter/deployer"
)
// mockProjectChecker simulates DB project existence checks.
type mockProjectChecker struct {
projects map[string]bool
}
func (m *mockProjectChecker) projectExists(_ context.Context, projectID string) (bool, error) {
return m.projects[projectID], nil
}
func newTestDeployerForGC(client *fake.Clientset) *deployer.Deployer {
return deployer.NewDeployer(client, deployer.Config{Namespace: "projects"})
}
func createDeployment(t *testing.T, client *fake.Clientset, name, project string, createdAt time.Time) {
t.Helper()
replicas := int32(1)
_, err := client.AppsV1().Deployments("projects").Create(context.Background(), &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: "projects",
Labels: map[string]string{"app": name, "project": project},
CreationTimestamp: metav1.NewTime(createdAt),
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}},
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: name, Image: "test:latest"}}},
},
},
}, metav1.CreateOptions{})
if err != nil {
t.Fatalf("failed to create deployment %s: %v", name, err)
}
}
func TestResourceGC_DeletesOrphans(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
// Create resources for two projects, both old enough
oldTime := time.Now().Add(-2 * time.Hour)
createDeployment(t, client, "orphan-proj", "orphan-proj", oldTime)
createDeployment(t, client, "valid-proj", "valid-proj", oldTime)
checker := &mockProjectChecker{
projects: map[string]bool{
"valid-proj": true, // exists in DB
// orphan-proj is NOT in DB
},
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour, // won't tick during test
})
gc.runReconciliation()
// Orphan should be deleted
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 1 {
t.Fatalf("expected 1 deployment, got %d", len(deps.Items))
}
if deps.Items[0].Name != "valid-proj" {
t.Errorf("remaining deployment = %q, want %q", deps.Items[0].Name, "valid-proj")
}
}
func TestResourceGC_SkipsValidProjects(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
oldTime := time.Now().Add(-2 * time.Hour)
createDeployment(t, client, "proj-a", "proj-a", oldTime)
createDeployment(t, client, "proj-b", "proj-b", oldTime)
checker := &mockProjectChecker{
projects: map[string]bool{
"proj-a": true,
"proj-b": true,
},
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour,
})
gc.runReconciliation()
// Both should remain
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 2 {
t.Errorf("expected 2 deployments, got %d", len(deps.Items))
}
}
func TestResourceGC_SkipsYoungOrphans(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
// Create a recent deployment (younger than minAge)
recentTime := time.Now().Add(-5 * time.Minute)
createDeployment(t, client, "new-orphan", "new-orphan", recentTime)
checker := &mockProjectChecker{
projects: map[string]bool{}, // not in DB
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour,
})
gc.runReconciliation()
// Should NOT be deleted (too young)
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 1 {
t.Errorf("expected 1 deployment (young orphan preserved), got %d", len(deps.Items))
}
}
func TestResourceGC_DeletesMonorepoComponents(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
ctx := context.Background()
oldTime := time.Now().Add(-2 * time.Hour)
createDeployment(t, client, "myapp", "myapp", oldTime)
createDeployment(t, client, "myapp-studio-ui", "myapp", oldTime)
createDeployment(t, client, "myapp-studio-api", "myapp", oldTime)
checker := &mockProjectChecker{
projects: map[string]bool{}, // not in DB
}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 1 * time.Hour,
})
gc.runReconciliation()
// All 3 should be deleted
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
if len(deps.Items) != 0 {
t.Errorf("expected 0 deployments, got %d", len(deps.Items))
}
}
func TestResourceGC_StartStop(t *testing.T) {
client := fake.NewSimpleClientset()
d := newTestDeployerForGC(client)
checker := &mockProjectChecker{projects: map[string]bool{}}
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
MinAge: 1 * time.Hour,
ReconcileInterval: 24 * time.Hour, // long interval so ticker won't fire
})
gc.Start()
// Give the goroutine time to run initial reconciliation
time.Sleep(50 * time.Millisecond)
gc.Stop() // should not hang
}

View File

@ -0,0 +1,117 @@
package worker
import (
"context"
"sync"
"time"
"github.com/orchard9/rdev/internal/logging"
)
// SessionCleanupService defines the interface for session cleanup operations.
type SessionCleanupService interface {
CleanupExpired(ctx context.Context) (int, error)
}
// SessionCleanup runs periodic cleanup of expired sessions.
// Expired sessions have their preview resources torn down and checkout tokens revoked.
type SessionCleanup struct {
service SessionCleanupService
cleanupInterval time.Duration
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
// SessionCleanupConfig holds configuration for session cleanup.
type SessionCleanupConfig struct {
// CleanupInterval is how often to run cleanup.
// Default: 5 minutes.
CleanupInterval time.Duration
}
// DefaultSessionCleanupConfig returns sensible defaults.
func DefaultSessionCleanupConfig() *SessionCleanupConfig {
return &SessionCleanupConfig{
CleanupInterval: 5 * time.Minute,
}
}
// NewSessionCleanup creates a new session cleanup worker.
func NewSessionCleanup(service SessionCleanupService, cfg *SessionCleanupConfig) *SessionCleanup {
if cfg == nil {
cfg = DefaultSessionCleanupConfig()
}
ctx, cancel := context.WithCancel(context.Background())
return &SessionCleanup{
service: service,
cleanupInterval: cfg.CleanupInterval,
ctx: ctx,
cancel: cancel,
}
}
// Start begins the cleanup loop.
func (c *SessionCleanup) Start() {
log := logging.FromContext(c.ctx).WithWorker("session-cleanup")
log.Info("session cleanup started",
"cleanup_interval", c.cleanupInterval,
)
c.wg.Add(1)
go c.cleanupLoop()
}
// Stop gracefully shuts down the cleanup worker.
func (c *SessionCleanup) Stop() {
log := logging.FromContext(c.ctx).WithWorker("session-cleanup")
log.Info("session cleanup stopping")
c.cancel()
c.wg.Wait()
log.Info("session cleanup stopped")
}
// cleanupLoop runs periodic cleanup.
func (c *SessionCleanup) cleanupLoop() {
defer c.wg.Done()
// Run immediately on start.
c.runCleanup()
ticker := time.NewTicker(c.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-c.ctx.Done():
return
case <-ticker.C:
c.runCleanup()
}
}
}
// runCleanup tears down expired sessions.
func (c *SessionCleanup) runCleanup() {
ctx, cancel := context.WithTimeout(c.ctx, TimeoutMaintenance)
defer cancel()
log := logging.FromContext(ctx).WithWorker("session-cleanup")
cleaned, err := c.service.CleanupExpired(ctx)
if err != nil {
log.Error("failed to cleanup expired sessions",
logging.FieldError, err,
)
return
}
if cleaned > 0 {
log.Info("cleaned up expired sessions",
"count", cleaned,
)
}
}