diff --git a/CLAUDE.md b/CLAUDE.md index 2f0d8fc..e036d53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | | **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) | -| **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 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) | @@ -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) | | **SDLC orchestration** | [services/sdlc.md](.claude/guides/services/sdlc.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 | ## 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`. - **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. -- **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. ## 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 | | 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 | +| 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 diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 788d898..a039aaa 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -303,6 +303,46 @@ func main() { // Create verify service (orchestrates verify task submission and tracking) 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) sdlcPodExec := kubernetes.NewSDLCExecutor(kubernetes.SDLCExecutorConfig{Namespace: namespace, Logger: logger}) @@ -485,13 +525,25 @@ func main() { agentsHandler := handlers.NewAgentsHandler(agentRegistry) // Initialize worker pool handlers - workersHandler := handlers.NewWorkersHandler(workerService).WithWorkService(workService) + workersHandler := handlers.NewWorkersHandler(workerService).WithWorkService(workService).WithWorkQueue(workQueueRepo) buildsHandler := handlers.NewBuildsHandler(buildService) createAndBuildHandler := handlers.NewCreateAndBuildHandler(projectInfraService, buildService) sdlcHandler := handlers.NewSDLCHandler(sdlcService) 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) sagaRepo := postgres.NewSagaRepository(database.DB) sagaExecutor := service.NewSagaExecutor(sagaRepo, logger) @@ -588,6 +640,12 @@ func main() { sdlcOrchestratorHandler.Mount(app.Router()) sdlcGenerateHandler.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()) sagaHandler.Mount(app.Router()) @@ -658,6 +716,27 @@ func main() { }) 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 app.EnableDocs(buildOpenAPISpec()) @@ -668,6 +747,15 @@ func main() { workExecutor.Stop() queueMaintenance.Stop() operationCleanup.Stop() + if checkoutCleanup != nil { + checkoutCleanup.Stop() + } + if sessionCleanup != nil { + sessionCleanup.Stop() + } + if resourceGC != nil { + resourceGC.Stop() + } queueProcessor.Stop() webhookDispatcher.Stop() projectRepo.StopWatching() diff --git a/internal/adapter/deployer/deployer.go b/internal/adapter/deployer/deployer.go index 4034fb6..a49ac85 100644 --- a/internal/adapter/deployer/deployer.go +++ b/internal/adapter/deployer/deployer.go @@ -4,11 +4,12 @@ package deployer import ( "bytes" "context" + "errors" "fmt" "time" 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" "k8s.io/client-go/kubernetes" @@ -35,13 +36,13 @@ type Config struct { // Deployer manages Kubernetes deployments for projects. type Deployer struct { - client *kubernetes.Clientset + client kubernetes.Interface ingressClient IngressClient config Config } // 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 { 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. -func NewDeployerWithIngressClient(client *kubernetes.Clientset, ingressClient IngressClient, cfg Config) *Deployer { +func NewDeployerWithIngressClient(client kubernetes.Interface, ingressClient IngressClient, cfg Config) *Deployer { if cfg.DefaultReplicas == 0 { cfg.DefaultReplicas = 1 } @@ -137,38 +138,153 @@ func (d *Deployer) Undeploy(ctx context.Context, projectName string) error { // Delete Ingress 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) } // Delete Service 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) } // Delete Deployment 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) } // Delete Secret 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 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. func (d *Deployer) GetStatus(ctx context.Context, projectName string) (*domain.DeployStatus, error) { ns := d.config.Namespace deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, projectName, metav1.GetOptions{}) if err != nil { - if errors.IsNotFound(err) { + if k8serr.IsNotFound(err) { return nil, nil } return nil, fmt.Errorf("failed to get deployment: %w", err) diff --git a/internal/adapter/deployer/deployer_undeploy_test.go b/internal/adapter/deployer/deployer_undeploy_test.go new file mode 100644 index 0000000..c56b0c7 --- /dev/null +++ b/internal/adapter/deployer/deployer_undeploy_test.go @@ -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") + } +} diff --git a/internal/adapter/deployer/k8s_client.go b/internal/adapter/deployer/k8s_client.go index 789b416..77f84a6 100644 --- a/internal/adapter/deployer/k8s_client.go +++ b/internal/adapter/deployer/k8s_client.go @@ -16,9 +16,9 @@ type IngressClient interface { 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 { - clientset *kubernetes.Clientset + clientset kubernetes.Interface } // GetIngress retrieves an Ingress by namespace and name. diff --git a/internal/adapter/gitea/client.go b/internal/adapter/gitea/client.go index dcfaf42..26bebb1 100644 --- a/internal/adapter/gitea/client.go +++ b/internal/adapter/gitea/client.go @@ -251,6 +251,114 @@ func (c *Client) Check(ctx context.Context) domain.ExternalSystemStatus { 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. func repoFromGitea(r *gitea.Repository) *domain.Repo { return &domain.Repo{ diff --git a/internal/adapter/kubernetes/preview.go b/internal/adapter/kubernetes/preview.go new file mode 100644 index 0000000..55d5130 --- /dev/null +++ b/internal/adapter/kubernetes/preview.go @@ -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=. + 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=, 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 +} diff --git a/internal/adapter/postgres/checkout_repository.go b/internal/adapter/postgres/checkout_repository.go new file mode 100644 index 0000000..2671d61 --- /dev/null +++ b/internal/adapter/postgres/checkout_repository.go @@ -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") +} diff --git a/internal/adapter/postgres/session_repository.go b/internal/adapter/postgres/session_repository.go new file mode 100644 index 0000000..ff329b6 --- /dev/null +++ b/internal/adapter/postgres/session_repository.go @@ -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() +} diff --git a/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl index 4a74c95..6675932 100644 --- a/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-astro/.woodpecker.step.yml.tmpl @@ -4,7 +4,6 @@ build-{{COMPONENT_NAME}}: depends_on: [preflight] image: woodpeckerci/plugin-kaniko - failure: ignore settings: registry: registry.threesix.ai repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} @@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}: branch: main 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}}: + depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest 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" diff --git a/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl index 1f5e9a8..9613af7 100644 --- a/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-nextjs/.woodpecker.step.yml.tmpl @@ -4,7 +4,6 @@ build-{{COMPONENT_NAME}}: depends_on: [preflight] image: woodpeckerci/plugin-kaniko - failure: ignore settings: registry: registry.threesix.ai repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} @@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}: branch: main 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}}: + depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest 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" diff --git a/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl index ae0943b..285c9a8 100644 --- a/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/app-react/.woodpecker.step.yml.tmpl @@ -4,7 +4,6 @@ build-{{COMPONENT_NAME}}: depends_on: [preflight] image: woodpeckerci/plugin-kaniko - failure: ignore settings: registry: registry.threesix.ai repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} @@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}: branch: main 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}}: + depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest 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" diff --git a/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl index 3ddef15..49ee954 100644 --- a/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/service/.woodpecker.step.yml.tmpl @@ -4,7 +4,6 @@ build-{{COMPONENT_NAME}}: depends_on: [preflight] image: woodpeckerci/plugin-kaniko - failure: ignore settings: registry: registry.threesix.ai repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} @@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}: branch: main 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}}: + depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest 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" diff --git a/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl index 0516870..cc00e2c 100644 --- a/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl +++ b/internal/adapter/templates/templates/components/worker/.woodpecker.step.yml.tmpl @@ -4,7 +4,6 @@ build-{{COMPONENT_NAME}}: depends_on: [preflight] image: woodpeckerci/plugin-kaniko - failure: ignore settings: registry: registry.threesix.ai repo: {{PROJECT_NAME}}/{{COMPONENT_NAME}} @@ -19,7 +18,37 @@ build-{{COMPONENT_NAME}}: branch: main 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}}: + depends_on: [verify-{{COMPONENT_NAME}}] image: bitnami/kubectl:latest 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" diff --git a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl index 7a20a54..44a7bd5 100644 --- a/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl +++ b/internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl @@ -55,10 +55,10 @@ steps: # COMPONENT_STEPS_BELOW # Do not remove the marker above - component steps are inserted here - # Sync point after all component builds complete - # This step has NO depends_on, so it waits for ALL previous steps - # (including any component steps inserted above) to complete + # Sync point after all component builds/deploys complete + # depends_on is updated dynamically when components are added build-complete: + depends_on: [preflight] # BUILD_COMPLETE_DEPS image: alpine:3.19 commands: - echo "All component builds complete" diff --git a/internal/auth/scopes.go b/internal/auth/scopes.go index 018a3a0..241d7d3 100644 --- a/internal/auth/scopes.go +++ b/internal/auth/scopes.go @@ -24,6 +24,8 @@ const ( ScopeBuildWrite = domain.ScopeBuildWrite ScopeVerifyRead = domain.ScopeVerifyRead ScopeVerifyWrite = domain.ScopeVerifyWrite + ScopeSessionsRead = domain.ScopeSessionsRead + ScopeSessionsExecute = domain.ScopeSessionsExecute ScopeAdmin = domain.ScopeAdmin ) diff --git a/internal/db/migrations/022_checkouts.sql b/internal/db/migrations/022_checkouts.sql new file mode 100644 index 0000000..b826e72 --- /dev/null +++ b/internal/db/migrations/022_checkouts.sql @@ -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); diff --git a/internal/db/migrations/023_sessions.sql b/internal/db/migrations/023_sessions.sql new file mode 100644 index 0000000..d690b02 --- /dev/null +++ b/internal/db/migrations/023_sessions.sql @@ -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); diff --git a/internal/domain/apikey.go b/internal/domain/apikey.go index e53dc54..380b1f2 100644 --- a/internal/domain/apikey.go +++ b/internal/domain/apikey.go @@ -27,6 +27,8 @@ const ( ScopeBuildWrite Scope = "build:write" ScopeVerifyRead Scope = "verify:read" ScopeVerifyWrite Scope = "verify:write" + ScopeSessionsRead Scope = "sessions:read" + ScopeSessionsExecute Scope = "sessions:execute" ScopeAdmin Scope = "admin" ) @@ -47,6 +49,8 @@ var AllScopes = []Scope{ ScopeBuildWrite, ScopeVerifyRead, ScopeVerifyWrite, + ScopeSessionsRead, + ScopeSessionsExecute, ScopeAdmin, } @@ -67,6 +71,8 @@ var ScopeDescriptions = map[Scope]string{ ScopeBuildWrite: "Start and manage builds", ScopeVerifyRead: "View verify tasks and capture results", 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)", } diff --git a/internal/domain/checkout.go b/internal/domain/checkout.go new file mode 100644 index 0000000..438f1a5 --- /dev/null +++ b/internal/domain/checkout.go @@ -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 +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 75e5267..2756624 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -88,6 +88,20 @@ var ( // Question errors 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) ErrDatabaseConnection = errors.New("database connection error") ErrKubernetesError = errors.New("kubernetes error") diff --git a/internal/domain/session.go b/internal/domain/session.go new file mode 100644 index 0000000..071b91c --- /dev/null +++ b/internal/domain/session.go @@ -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) +} diff --git a/internal/handlers/checkout.go b/internal/handlers/checkout.go new file mode 100644 index 0000000..e4eb01a --- /dev/null +++ b/internal/handlers/checkout.go @@ -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 +} diff --git a/internal/handlers/infrastructure_deploy.go b/internal/handlers/infrastructure_deploy.go index 6ee5657..2c10716 100644 --- a/internal/handlers/infrastructure_deploy.go +++ b/internal/handlers/infrastructure_deploy.go @@ -172,7 +172,7 @@ func (h *InfrastructureHandler) Undeploy(w http.ResponseWriter, r *http.Request) 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)) return } diff --git a/internal/handlers/infrastructure_mocks_test.go b/internal/handlers/infrastructure_mocks_test.go index 3467b9c..4322bb0 100644 --- a/internal/handlers/infrastructure_mocks_test.go +++ b/internal/handlers/infrastructure_mocks_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/port" ) // mockGitRepository implements port.GitRepository for testing. @@ -88,6 +89,43 @@ func (m *mockGitRepository) DeleteWebhook(context.Context, string, string, int64 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. type mockDNSProvider struct { records map[string]*domain.DNSRecord @@ -215,6 +253,14 @@ func (m *mockDeployer) Undeploy(_ context.Context, projectName string) error { 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) { if m.err != nil { return nil, m.err @@ -295,3 +341,29 @@ func (m *mockDeployer) AddIngressPath(_ context.Context, _, _, _, _ string, _ in func (m *mockDeployer) RemoveIngressPath(_ context.Context, _, _, _ string) error { 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 +} diff --git a/internal/handlers/sessions.go b/internal/handlers/sessions.go new file mode 100644 index 0000000..c7330bc --- /dev/null +++ b/internal/handlers/sessions.go @@ -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 +} diff --git a/internal/handlers/sessions_test.go b/internal/handlers/sessions_test.go new file mode 100644 index 0000000..c0e23cb --- /dev/null +++ b/internal/handlers/sessions_test.go @@ -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) diff --git a/internal/handlers/workers.go b/internal/handlers/workers.go index 1ed7463..b090d9b 100644 --- a/internal/handlers/workers.go +++ b/internal/handlers/workers.go @@ -17,6 +17,7 @@ import ( type WorkersHandler struct { workerService *service.WorkerService workService service.WorkServiceFailer + workQueue port.WorkQueue } // NewWorkersHandler creates a new workers handler. @@ -33,11 +34,18 @@ func (h *WorkersHandler) WithWorkService(ws service.WorkServiceFailer) *WorkersH 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. func (h *WorkersHandler) Mount(r api.Router) { r.Route("/workers", func(r chi.Router) { // Read operations 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) // Write operations @@ -386,3 +394,56 @@ func (h *WorkersHandler) FailTask(w http.ResponseWriter, r *http.Request) { "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, + }) +} diff --git a/internal/port/checkout_repository.go b/internal/port/checkout_repository.go new file mode 100644 index 0000000..8630929 --- /dev/null +++ b/internal/port/checkout_repository.go @@ -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) +} diff --git a/internal/port/deployer.go b/internal/port/deployer.go index c224bec..02e88f2 100644 --- a/internal/port/deployer.go +++ b/internal/port/deployer.go @@ -14,9 +14,13 @@ type Deployer interface { // For monorepo projects with ComponentPath set, creates component-specific resources. 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 + // 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. // The componentPath is the path within the monorepo (e.g., "services/auth-api"). UndeployComponent(ctx context.Context, projectName, componentPath string) error diff --git a/internal/port/git_repository.go b/internal/port/git_repository.go index 6876420..f0aa0b5 100644 --- a/internal/port/git_repository.go +++ b/internal/port/git_repository.go @@ -3,6 +3,7 @@ package port import ( "context" + "time" "github.com/orchard9/rdev/internal/domain" ) @@ -39,4 +40,17 @@ type GitRepository interface { // DeleteWebhook removes a webhook from a repo. 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 } diff --git a/internal/port/preview.go b/internal/port/preview.go new file mode 100644 index 0000000..d3a9549 --- /dev/null +++ b/internal/port/preview.go @@ -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 +} diff --git a/internal/port/session_repository.go b/internal/port/session_repository.go new file mode 100644 index 0000000..0830d48 --- /dev/null +++ b/internal/port/session_repository.go @@ -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) +} diff --git a/internal/service/checkout_service.go b/internal/service/checkout_service.go new file mode 100644 index 0000000..65592bb --- /dev/null +++ b/internal/service/checkout_service.go @@ -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://@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 +} diff --git a/internal/service/component_updates.go b/internal/service/component_updates.go index 37c561c..ea79ff8 100644 --- a/internal/service/component_updates.go +++ b/internal/service/component_updates.go @@ -2,6 +2,7 @@ package service import ( "fmt" + "regexp" "strings" "github.com/orchard9/rdev/internal/domain" @@ -64,7 +65,8 @@ func (s *ComponentService) updateGoWork(existing, componentPath string) string { 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 { marker := "# COMPONENT_STEPS_BELOW" @@ -84,7 +86,72 @@ func (s *ComponentService) updateWoodpeckerYml(existing, stepYaml string) string } // 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. diff --git a/internal/service/project_infra_crud.go b/internal/service/project_infra_crud.go index 196d3f1..a78fdc5 100644 --- a/internal/service/project_infra_crud.go +++ b/internal/service/project_infra_crud.go @@ -693,7 +693,7 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin // 1. Undeploy if deployed 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) } } diff --git a/internal/service/session_service.go b/internal/service/session_service.go new file mode 100644 index 0000000..b736364 --- /dev/null +++ b/internal/service/session_service.go @@ -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 +} diff --git a/internal/worker/checkout_cleanup.go b/internal/worker/checkout_cleanup.go new file mode 100644 index 0000000..447f943 --- /dev/null +++ b/internal/worker/checkout_cleanup.go @@ -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, + ) + } +} diff --git a/internal/worker/resource_gc.go b/internal/worker/resource_gc.go new file mode 100644 index 0000000..3f39a38 --- /dev/null +++ b/internal/worker/resource_gc.go @@ -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), + ) + } + +} diff --git a/internal/worker/resource_gc_test.go b/internal/worker/resource_gc_test.go new file mode 100644 index 0000000..04acf30 --- /dev/null +++ b/internal/worker/resource_gc_test.go @@ -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 +} diff --git a/internal/worker/session_cleanup.go b/internal/worker/session_cleanup.go new file mode 100644 index 0000000..fc60e6d --- /dev/null +++ b/internal/worker/session_cleanup.go @@ -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, + ) + } +}