feat: label-based undeploy, GC reconciliation, checkout/sessions, pool status
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Add UndeployAll() using label selectors to clean up monorepo components on project deletion (replaces name-based Undeploy in DeleteProject and the direct undeploy handler) - Add ResourceGC background worker that periodically finds K8s resources whose project label has no matching DB record, deletes after 1h safety window - Widen deployer client type from *kubernetes.Clientset to kubernetes.Interface for testability - UndeployAll accumulates errors via errors.Join instead of failing fast - Add checkout/checkin sidecar dev flow: temporary git tokens, branch checkout, review on checkin with cleanup workers - Add interactive sessions: pod binding, command execution, SSE streaming, ephemeral preview URLs with session cleanup workers - Add GET /workers/pool endpoint for aggregate capacity and queue depth - Add sessions:read and sessions:execute auth scopes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1714b5921a
commit
9226454b85
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
241
internal/adapter/deployer/deployer_undeploy_test.go
Normal file
241
internal/adapter/deployer/deployer_undeploy_test.go
Normal file
@ -0,0 +1,241 @@
|
||||
package deployer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
)
|
||||
|
||||
func newTestDeployer(client *fake.Clientset) *Deployer {
|
||||
return NewDeployer(client, Config{Namespace: "projects"})
|
||||
}
|
||||
|
||||
func createFakeDeployment(t *testing.T, client *fake.Clientset, name, project string) {
|
||||
t.Helper()
|
||||
replicas := int32(1)
|
||||
_, err := client.AppsV1().Deployments("projects").Create(context.Background(), &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "projects",
|
||||
Labels: map[string]string{"app": name, "project": project},
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}},
|
||||
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: name, Image: "test:latest"}}},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create deployment %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createFakeService(t *testing.T, client *fake.Clientset, name, project string) {
|
||||
t.Helper()
|
||||
_, err := client.CoreV1().Services("projects").Create(context.Background(), &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "projects",
|
||||
Labels: map[string]string{"app": name, "project": project},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
Selector: map[string]string{"app": name},
|
||||
Ports: []corev1.ServicePort{{Port: 8080}},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create service %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createFakeIngress(t *testing.T, client *fake.Clientset, name, project string) {
|
||||
t.Helper()
|
||||
_, err := client.NetworkingV1().Ingresses("projects").Create(context.Background(), &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "projects",
|
||||
Labels: map[string]string{"app": name, "project": project},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create ingress %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func createFakeSecret(t *testing.T, client *fake.Clientset, name, project string) {
|
||||
t.Helper()
|
||||
_, err := client.CoreV1().Secrets("projects").Create(context.Background(), &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "projects",
|
||||
Labels: map[string]string{"project": project},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create secret %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeployAll_SingleApp(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployer(client)
|
||||
ctx := context.Background()
|
||||
|
||||
createFakeDeployment(t, client, "myapp", "myapp")
|
||||
createFakeService(t, client, "myapp", "myapp")
|
||||
createFakeIngress(t, client, "myapp", "myapp")
|
||||
createFakeSecret(t, client, "myapp-env", "myapp")
|
||||
|
||||
if err := d.UndeployAll(ctx, "myapp"); err != nil {
|
||||
t.Fatalf("UndeployAll failed: %v", err)
|
||||
}
|
||||
|
||||
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(deps.Items) != 0 {
|
||||
t.Errorf("expected 0 deployments, got %d", len(deps.Items))
|
||||
}
|
||||
svcs, _ := client.CoreV1().Services("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(svcs.Items) != 0 {
|
||||
t.Errorf("expected 0 services, got %d", len(svcs.Items))
|
||||
}
|
||||
ings, _ := client.NetworkingV1().Ingresses("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(ings.Items) != 0 {
|
||||
t.Errorf("expected 0 ingresses, got %d", len(ings.Items))
|
||||
}
|
||||
secs, _ := client.CoreV1().Secrets("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(secs.Items) != 0 {
|
||||
t.Errorf("expected 0 secrets, got %d", len(secs.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeployAll_MonorepoComponents(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployer(client)
|
||||
ctx := context.Background()
|
||||
|
||||
// Main project + two components all sharing "project=myapp" label
|
||||
createFakeDeployment(t, client, "myapp", "myapp")
|
||||
createFakeDeployment(t, client, "myapp-studio-ui", "myapp")
|
||||
createFakeDeployment(t, client, "myapp-studio-api", "myapp")
|
||||
createFakeService(t, client, "myapp", "myapp")
|
||||
createFakeService(t, client, "myapp-studio-ui", "myapp")
|
||||
createFakeService(t, client, "myapp-studio-api", "myapp")
|
||||
createFakeIngress(t, client, "myapp", "myapp")
|
||||
|
||||
// Also create a resource for a DIFFERENT project - should not be deleted
|
||||
createFakeDeployment(t, client, "other-project", "other-project")
|
||||
createFakeService(t, client, "other-project", "other-project")
|
||||
|
||||
if err := d.UndeployAll(ctx, "myapp"); err != nil {
|
||||
t.Fatalf("UndeployAll failed: %v", err)
|
||||
}
|
||||
|
||||
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(deps.Items) != 1 {
|
||||
t.Errorf("expected 1 deployment (other-project), got %d", len(deps.Items))
|
||||
}
|
||||
if len(deps.Items) == 1 && deps.Items[0].Name != "other-project" {
|
||||
t.Errorf("remaining deployment = %q, want %q", deps.Items[0].Name, "other-project")
|
||||
}
|
||||
|
||||
svcs, _ := client.CoreV1().Services("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(svcs.Items) != 1 {
|
||||
t.Errorf("expected 1 service (other-project), got %d", len(svcs.Items))
|
||||
}
|
||||
|
||||
ings, _ := client.NetworkingV1().Ingresses("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(ings.Items) != 0 {
|
||||
t.Errorf("expected 0 ingresses, got %d", len(ings.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeployAll_IdempotentNoOp(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployer(client)
|
||||
ctx := context.Background()
|
||||
|
||||
// No resources exist for this project
|
||||
if err := d.UndeployAll(ctx, "nonexistent"); err != nil {
|
||||
t.Fatalf("UndeployAll should be idempotent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectLabels(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployer(client)
|
||||
ctx := context.Background()
|
||||
|
||||
createFakeDeployment(t, client, "proj-a", "proj-a")
|
||||
createFakeDeployment(t, client, "proj-b", "proj-b")
|
||||
createFakeDeployment(t, client, "proj-b-ui", "proj-b")
|
||||
createFakeDeployment(t, client, "proj-b-api", "proj-b")
|
||||
|
||||
labels, err := d.ListProjectLabels(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListProjectLabels failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have exactly 2 unique labels
|
||||
if len(labels) != 2 {
|
||||
t.Fatalf("expected 2 unique labels, got %d: %v", len(labels), labels)
|
||||
}
|
||||
|
||||
found := make(map[string]bool)
|
||||
for _, l := range labels {
|
||||
found[l] = true
|
||||
}
|
||||
if !found["proj-a"] || !found["proj-b"] {
|
||||
t.Errorf("expected proj-a and proj-b, got %v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectLabels_Empty(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployer(client)
|
||||
|
||||
labels, err := d.ListProjectLabels(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("ListProjectLabels failed: %v", err)
|
||||
}
|
||||
if len(labels) != 0 {
|
||||
t.Errorf("expected 0 labels, got %d", len(labels))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOldestResourceTime(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployer(client)
|
||||
ctx := context.Background()
|
||||
|
||||
createFakeDeployment(t, client, "myapp", "myapp")
|
||||
createFakeDeployment(t, client, "myapp-ui", "myapp")
|
||||
|
||||
_, found, err := d.GetOldestResourceTime(ctx, "myapp")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOldestResourceTime failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected found=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOldestResourceTime_NoResources(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployer(client)
|
||||
|
||||
_, found, err := d.GetOldestResourceTime(context.Background(), "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOldestResourceTime failed: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected found=false for nonexistent project")
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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{
|
||||
|
||||
183
internal/adapter/kubernetes/preview.go
Normal file
183
internal/adapter/kubernetes/preview.go
Normal file
@ -0,0 +1,183 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
// Ensure PreviewManager implements port.PreviewManager at compile time.
|
||||
var _ port.PreviewManager = (*PreviewManager)(nil)
|
||||
|
||||
// PreviewConfig holds configuration for the preview manager.
|
||||
type PreviewConfig struct {
|
||||
// Namespace is the K8s namespace for preview resources.
|
||||
Namespace string
|
||||
|
||||
// IngressClass is the ingress controller class (e.g., "traefik").
|
||||
IngressClass string
|
||||
|
||||
// TLSIssuer is the cert-manager cluster issuer name.
|
||||
TLSIssuer string
|
||||
}
|
||||
|
||||
// PreviewManager manages ephemeral preview URLs via K8s Service + Ingress.
|
||||
type PreviewManager struct {
|
||||
client *kubernetes.Clientset
|
||||
config PreviewConfig
|
||||
}
|
||||
|
||||
// NewPreviewManager creates a new K8s preview manager.
|
||||
func NewPreviewManager(client *kubernetes.Clientset, cfg PreviewConfig) *PreviewManager {
|
||||
if cfg.IngressClass == "" {
|
||||
cfg.IngressClass = "traefik"
|
||||
}
|
||||
return &PreviewManager{
|
||||
client: client,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// resourceName returns the K8s resource name for a session.
|
||||
func resourceName(sessionID string) string {
|
||||
return "session-" + sessionID
|
||||
}
|
||||
|
||||
// CreatePreview creates a K8s Service + Ingress for the session preview.
|
||||
func (m *PreviewManager) CreatePreview(ctx context.Context, opts port.PreviewOptions) error {
|
||||
if opts.Port == 0 {
|
||||
opts.Port = 8080
|
||||
}
|
||||
|
||||
name := resourceName(opts.SessionID)
|
||||
ns := opts.Namespace
|
||||
if ns == "" {
|
||||
ns = m.config.Namespace
|
||||
}
|
||||
|
||||
// Create Service targeting the pod by its rdev project label.
|
||||
// Project pods are labeled with rdev.orchard9.ai/name=<project-id>.
|
||||
svc := &corev1.Service{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
Labels: map[string]string{
|
||||
"rdev.orchard9.ai/session": opts.SessionID,
|
||||
"rdev.orchard9.ai/preview": "true",
|
||||
},
|
||||
},
|
||||
Spec: corev1.ServiceSpec{
|
||||
// Use ExternalName-like routing via a selector that matches the pod.
|
||||
// Since project pods have rdev.orchard9.ai/name=<id>, we select by pod name
|
||||
// using the statefulset.kubernetes.io/pod-name label (set on StatefulSet pods).
|
||||
Selector: map[string]string{
|
||||
"statefulset.kubernetes.io/pod-name": opts.PodName,
|
||||
},
|
||||
Ports: []corev1.ServicePort{
|
||||
{
|
||||
Name: "http",
|
||||
Port: int32(opts.Port),
|
||||
TargetPort: intstr.FromInt32(int32(opts.Port)),
|
||||
Protocol: corev1.ProtocolTCP,
|
||||
},
|
||||
},
|
||||
Type: corev1.ServiceTypeClusterIP,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := m.client.CoreV1().Services(ns).Create(ctx, svc, metav1.CreateOptions{})
|
||||
if err != nil && !errors.IsAlreadyExists(err) {
|
||||
return fmt.Errorf("create preview service: %w", err)
|
||||
}
|
||||
|
||||
// Create Ingress for TLS-terminated route.
|
||||
pathType := networkingv1.PathTypePrefix
|
||||
tlsSecretName := strings.ReplaceAll(opts.Host, ".", "-") + "-tls"
|
||||
|
||||
annotations := map[string]string{}
|
||||
if m.config.TLSIssuer != "" {
|
||||
annotations["cert-manager.io/cluster-issuer"] = m.config.TLSIssuer
|
||||
}
|
||||
|
||||
ingress := &networkingv1.Ingress{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: ns,
|
||||
Labels: map[string]string{
|
||||
"rdev.orchard9.ai/session": opts.SessionID,
|
||||
"rdev.orchard9.ai/preview": "true",
|
||||
},
|
||||
Annotations: annotations,
|
||||
},
|
||||
Spec: networkingv1.IngressSpec{
|
||||
IngressClassName: &m.config.IngressClass,
|
||||
TLS: []networkingv1.IngressTLS{
|
||||
{
|
||||
Hosts: []string{opts.Host},
|
||||
SecretName: tlsSecretName,
|
||||
},
|
||||
},
|
||||
Rules: []networkingv1.IngressRule{
|
||||
{
|
||||
Host: opts.Host,
|
||||
IngressRuleValue: networkingv1.IngressRuleValue{
|
||||
HTTP: &networkingv1.HTTPIngressRuleValue{
|
||||
Paths: []networkingv1.HTTPIngressPath{
|
||||
{
|
||||
Path: "/",
|
||||
PathType: &pathType,
|
||||
Backend: networkingv1.IngressBackend{
|
||||
Service: &networkingv1.IngressServiceBackend{
|
||||
Name: name,
|
||||
Port: networkingv1.ServiceBackendPort{
|
||||
Number: int32(opts.Port),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = m.client.NetworkingV1().Ingresses(ns).Create(ctx, ingress, metav1.CreateOptions{})
|
||||
if err != nil && !errors.IsAlreadyExists(err) {
|
||||
// Clean up the service if ingress creation fails.
|
||||
_ = m.client.CoreV1().Services(ns).Delete(ctx, name, metav1.DeleteOptions{})
|
||||
return fmt.Errorf("create preview ingress: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePreview removes the K8s Service + Ingress for a session preview.
|
||||
func (m *PreviewManager) DeletePreview(ctx context.Context, sessionID string) error {
|
||||
name := resourceName(sessionID)
|
||||
ns := m.config.Namespace
|
||||
|
||||
// Delete Ingress (ignore not-found).
|
||||
err := m.client.NetworkingV1().Ingresses(ns).Delete(ctx, name, metav1.DeleteOptions{})
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return fmt.Errorf("delete preview ingress: %w", err)
|
||||
}
|
||||
|
||||
// Delete Service (ignore not-found).
|
||||
err = m.client.CoreV1().Services(ns).Delete(ctx, name, metav1.DeleteOptions{})
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return fmt.Errorf("delete preview service: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
329
internal/adapter/postgres/checkout_repository.go
Normal file
329
internal/adapter/postgres/checkout_repository.go
Normal file
@ -0,0 +1,329 @@
|
||||
// Package postgres provides PostgreSQL-based implementations of port interfaces.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
// CheckoutRepository implements port.CheckoutRepository using PostgreSQL.
|
||||
type CheckoutRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewCheckoutRepository creates a new PostgreSQL checkout repository.
|
||||
func NewCheckoutRepository(db *sql.DB) *CheckoutRepository {
|
||||
return &CheckoutRepository{db: db}
|
||||
}
|
||||
|
||||
// Ensure CheckoutRepository implements port.CheckoutRepository at compile time.
|
||||
var _ port.CheckoutRepository = (*CheckoutRepository)(nil)
|
||||
|
||||
// Create stores a new checkout record.
|
||||
func (r *CheckoutRepository) Create(ctx context.Context, checkout *domain.Checkout) error {
|
||||
var id string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO checkouts (
|
||||
project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
|
||||
clone_url, checked_out_by, checked_out_at, expires_at, status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
`,
|
||||
string(checkout.ProjectID),
|
||||
checkout.Branch,
|
||||
nullString(checkout.FeatureSlug),
|
||||
checkout.GiteaTokenID,
|
||||
checkout.GiteaTokenName,
|
||||
checkout.CloneURL,
|
||||
checkout.CheckedOutBy,
|
||||
checkout.CheckedOutAt,
|
||||
checkout.ExpiresAt,
|
||||
string(checkout.Status),
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
// Check for unique constraint violation (active checkout exists)
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrCheckoutAlreadyExists
|
||||
}
|
||||
return fmt.Errorf("insert checkout: %w", err)
|
||||
}
|
||||
|
||||
checkout.ID = domain.CheckoutID(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a checkout by ID.
|
||||
func (r *CheckoutRepository) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
|
||||
checkout, err := r.scanCheckout(r.db.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
|
||||
clone_url, checked_out_by, checked_out_at, expires_at, status,
|
||||
checked_in_at, review_task_id
|
||||
FROM checkouts
|
||||
WHERE id = $1
|
||||
`, string(id)))
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrCheckoutNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query checkout: %w", err)
|
||||
}
|
||||
return checkout, nil
|
||||
}
|
||||
|
||||
// GetByProjectBranch retrieves an active checkout for a project+branch.
|
||||
func (r *CheckoutRepository) GetByProjectBranch(ctx context.Context, projectID domain.ProjectID, branch string) (*domain.Checkout, error) {
|
||||
checkout, err := r.scanCheckout(r.db.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
|
||||
clone_url, checked_out_by, checked_out_at, expires_at, status,
|
||||
checked_in_at, review_task_id
|
||||
FROM checkouts
|
||||
WHERE project_id = $1 AND branch = $2 AND status = 'active'
|
||||
`, string(projectID), branch))
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrCheckoutNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query checkout by project+branch: %w", err)
|
||||
}
|
||||
return checkout, nil
|
||||
}
|
||||
|
||||
// List returns checkouts matching the given options.
|
||||
func (r *CheckoutRepository) List(ctx context.Context, opts domain.CheckoutListOptions) ([]*domain.Checkout, error) {
|
||||
query := `
|
||||
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
|
||||
clone_url, checked_out_by, checked_out_at, expires_at, status,
|
||||
checked_in_at, review_task_id
|
||||
FROM checkouts
|
||||
`
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
if opts.Status != nil {
|
||||
query += fmt.Sprintf(" WHERE status = $%d", argIdx)
|
||||
args = append(args, string(*opts.Status))
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += " ORDER BY checked_out_at DESC"
|
||||
|
||||
if opts.Limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT $%d", argIdx)
|
||||
args = append(args, opts.Limit)
|
||||
argIdx++
|
||||
}
|
||||
if opts.Offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET $%d", argIdx)
|
||||
args = append(args, opts.Offset)
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query checkouts: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanCheckouts(rows)
|
||||
}
|
||||
|
||||
// ListByProject returns all checkouts for a project.
|
||||
func (r *CheckoutRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, project_id, branch, feature_slug, gitea_token_id, gitea_token_name,
|
||||
clone_url, checked_out_by, checked_out_at, expires_at, status,
|
||||
checked_in_at, review_task_id
|
||||
FROM checkouts
|
||||
WHERE project_id = $1
|
||||
ORDER BY checked_out_at DESC
|
||||
`, string(projectID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query checkouts by project: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanCheckouts(rows)
|
||||
}
|
||||
|
||||
// UpdateStatus updates the status of a checkout.
|
||||
func (r *CheckoutRepository) UpdateStatus(ctx context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE checkouts
|
||||
SET status = $2
|
||||
WHERE id = $1
|
||||
`, string(id), string(status))
|
||||
if err != nil {
|
||||
return fmt.Errorf("update checkout status: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrCheckoutNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCheckedIn marks a checkout as checked in with timestamp.
|
||||
func (r *CheckoutRepository) SetCheckedIn(ctx context.Context, id domain.CheckoutID, reviewTaskID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE checkouts
|
||||
SET status = 'checked_in', checked_in_at = NOW(), review_task_id = $2
|
||||
WHERE id = $1 AND status = 'active'
|
||||
`, string(id), nullString(reviewTaskID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set checked in: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrCheckoutNotActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetReviewTask sets the review task ID for a checkout.
|
||||
func (r *CheckoutRepository) SetReviewTask(ctx context.Context, id domain.CheckoutID, taskID string) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE checkouts
|
||||
SET review_task_id = $2
|
||||
WHERE id = $1
|
||||
`, string(id), taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set review task: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrCheckoutNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExpired marks expired checkouts and returns their Gitea token IDs for revocation.
|
||||
func (r *CheckoutRepository) CleanupExpired(ctx context.Context) ([]int64, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
UPDATE checkouts
|
||||
SET status = 'expired'
|
||||
WHERE status = 'active' AND expires_at < NOW()
|
||||
RETURNING gitea_token_id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cleanup expired checkouts: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var tokenIDs []int64
|
||||
for rows.Next() {
|
||||
var tokenID int64
|
||||
if err := rows.Scan(&tokenID); err != nil {
|
||||
return nil, fmt.Errorf("scan token id: %w", err)
|
||||
}
|
||||
tokenIDs = append(tokenIDs, tokenID)
|
||||
}
|
||||
|
||||
return tokenIDs, rows.Err()
|
||||
}
|
||||
|
||||
// checkoutScanner is an interface for scanning checkout rows.
|
||||
// Both sql.Row and sql.Rows implement this via their Scan method.
|
||||
type checkoutScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanCheckoutFields scans checkout fields from a scanner into a Checkout struct.
|
||||
func (r *CheckoutRepository) scanCheckoutFields(scanner checkoutScanner) (*domain.Checkout, error) {
|
||||
var (
|
||||
checkout domain.Checkout
|
||||
id string
|
||||
projectID string
|
||||
featureSlug sql.NullString
|
||||
status string
|
||||
checkedInAt sql.NullTime
|
||||
reviewTaskID sql.NullString
|
||||
)
|
||||
|
||||
err := scanner.Scan(
|
||||
&id,
|
||||
&projectID,
|
||||
&checkout.Branch,
|
||||
&featureSlug,
|
||||
&checkout.GiteaTokenID,
|
||||
&checkout.GiteaTokenName,
|
||||
&checkout.CloneURL,
|
||||
&checkout.CheckedOutBy,
|
||||
&checkout.CheckedOutAt,
|
||||
&checkout.ExpiresAt,
|
||||
&status,
|
||||
&checkedInAt,
|
||||
&reviewTaskID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
checkout.ID = domain.CheckoutID(id)
|
||||
checkout.ProjectID = domain.ProjectID(projectID)
|
||||
checkout.Status = domain.CheckoutStatus(status)
|
||||
|
||||
if featureSlug.Valid {
|
||||
checkout.FeatureSlug = featureSlug.String
|
||||
}
|
||||
if checkedInAt.Valid {
|
||||
checkout.CheckedInAt = &checkedInAt.Time
|
||||
}
|
||||
if reviewTaskID.Valid {
|
||||
checkout.ReviewTaskID = reviewTaskID.String
|
||||
}
|
||||
|
||||
return &checkout, nil
|
||||
}
|
||||
|
||||
// scanCheckout scans a single row into a Checkout struct.
|
||||
func (r *CheckoutRepository) scanCheckout(row *sql.Row) (*domain.Checkout, error) {
|
||||
return r.scanCheckoutFields(row)
|
||||
}
|
||||
|
||||
// scanCheckouts scans multiple rows into Checkout structs.
|
||||
func (r *CheckoutRepository) scanCheckouts(rows *sql.Rows) ([]*domain.Checkout, error) {
|
||||
var checkouts []*domain.Checkout
|
||||
|
||||
for rows.Next() {
|
||||
checkout, err := r.scanCheckoutFields(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan checkout: %w", err)
|
||||
}
|
||||
checkouts = append(checkouts, checkout)
|
||||
}
|
||||
|
||||
return checkouts, rows.Err()
|
||||
}
|
||||
|
||||
// isUniqueViolation checks if the error is a PostgreSQL unique constraint violation.
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
// PostgreSQL error code for unique_violation is 23505
|
||||
return strings.Contains(errStr, "unique constraint") ||
|
||||
strings.Contains(errStr, "duplicate key") ||
|
||||
strings.Contains(errStr, "23505")
|
||||
}
|
||||
214
internal/adapter/postgres/session_repository.go
Normal file
214
internal/adapter/postgres/session_repository.go
Normal file
@ -0,0 +1,214 @@
|
||||
// Package postgres provides PostgreSQL-based implementations of port interfaces.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
// SessionRepository implements port.SessionRepository using PostgreSQL.
|
||||
type SessionRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSessionRepository creates a new PostgreSQL session repository.
|
||||
func NewSessionRepository(db *sql.DB) *SessionRepository {
|
||||
return &SessionRepository{db: db}
|
||||
}
|
||||
|
||||
// Ensure SessionRepository implements port.SessionRepository at compile time.
|
||||
var _ port.SessionRepository = (*SessionRepository)(nil)
|
||||
|
||||
// Create stores a new session record.
|
||||
func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) error {
|
||||
var id string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO sessions (
|
||||
project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||
created_by, created_at, expires_at, status
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id
|
||||
`,
|
||||
string(session.ProjectID),
|
||||
string(session.CheckoutID),
|
||||
session.PodName,
|
||||
session.PreviewURL,
|
||||
session.PreviewHost,
|
||||
session.CreatedBy,
|
||||
session.CreatedAt,
|
||||
session.ExpiresAt,
|
||||
string(session.Status),
|
||||
).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return domain.ErrSessionExists
|
||||
}
|
||||
return fmt.Errorf("insert session: %w", err)
|
||||
}
|
||||
|
||||
session.ID = domain.SessionID(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a session by ID.
|
||||
func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
|
||||
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||
created_by, created_at, expires_at, status, ended_at
|
||||
FROM sessions
|
||||
WHERE id = $1
|
||||
`, string(id)))
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query session: %w", err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetActiveByProject retrieves the active session for a project.
|
||||
func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error) {
|
||||
session, err := r.scanSession(r.db.QueryRowContext(ctx, `
|
||||
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||
created_by, created_at, expires_at, status, ended_at
|
||||
FROM sessions
|
||||
WHERE project_id = $1 AND status = 'active'
|
||||
`, string(projectID)))
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, domain.ErrSessionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query active session: %w", err)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// ListByProject returns all sessions for a project.
|
||||
func (r *SessionRepository) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||
created_by, created_at, expires_at, status, ended_at
|
||||
FROM sessions
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`, string(projectID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query sessions by project: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanSessions(rows)
|
||||
}
|
||||
|
||||
// SetEnded marks a session as ended with a timestamp.
|
||||
func (r *SessionRepository) SetEnded(ctx context.Context, id domain.SessionID) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET status = 'ended', ended_at = NOW()
|
||||
WHERE id = $1 AND status = 'active'
|
||||
`, string(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set session ended: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("rows affected: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return domain.ErrSessionNotActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExpired marks expired sessions and returns them for preview teardown.
|
||||
func (r *SessionRepository) CleanupExpired(ctx context.Context) ([]*domain.Session, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET status = 'expired', ended_at = NOW()
|
||||
WHERE status = 'active' AND expires_at < NOW()
|
||||
RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host,
|
||||
created_by, created_at, expires_at, status, ended_at
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cleanup expired sessions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanSessions(rows)
|
||||
}
|
||||
|
||||
// sessionScanner is an interface for scanning session rows.
|
||||
type sessionScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanSessionFields scans session fields from a scanner into a Session struct.
|
||||
func (r *SessionRepository) scanSessionFields(scanner sessionScanner) (*domain.Session, error) {
|
||||
var (
|
||||
session domain.Session
|
||||
id string
|
||||
projectID string
|
||||
checkoutID string
|
||||
status string
|
||||
endedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := scanner.Scan(
|
||||
&id,
|
||||
&projectID,
|
||||
&checkoutID,
|
||||
&session.PodName,
|
||||
&session.PreviewURL,
|
||||
&session.PreviewHost,
|
||||
&session.CreatedBy,
|
||||
&session.CreatedAt,
|
||||
&session.ExpiresAt,
|
||||
&status,
|
||||
&endedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session.ID = domain.SessionID(id)
|
||||
session.ProjectID = domain.ProjectID(projectID)
|
||||
session.CheckoutID = domain.CheckoutID(checkoutID)
|
||||
session.Status = domain.SessionStatus(status)
|
||||
|
||||
if endedAt.Valid {
|
||||
session.EndedAt = &endedAt.Time
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// scanSession scans a single row into a Session struct.
|
||||
func (r *SessionRepository) scanSession(row *sql.Row) (*domain.Session, error) {
|
||||
return r.scanSessionFields(row)
|
||||
}
|
||||
|
||||
// scanSessions scans multiple rows into Session structs.
|
||||
func (r *SessionRepository) scanSessions(rows *sql.Rows) ([]*domain.Session, error) {
|
||||
var sessions []*domain.Session
|
||||
|
||||
for rows.Next() {
|
||||
session, err := r.scanSessionFields(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan session: %w", err)
|
||||
}
|
||||
sessions = append(sessions, session)
|
||||
}
|
||||
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -24,6 +24,8 @@ const (
|
||||
ScopeBuildWrite = domain.ScopeBuildWrite
|
||||
ScopeVerifyRead = domain.ScopeVerifyRead
|
||||
ScopeVerifyWrite = domain.ScopeVerifyWrite
|
||||
ScopeSessionsRead = domain.ScopeSessionsRead
|
||||
ScopeSessionsExecute = domain.ScopeSessionsExecute
|
||||
ScopeAdmin = domain.ScopeAdmin
|
||||
)
|
||||
|
||||
|
||||
39
internal/db/migrations/022_checkouts.sql
Normal file
39
internal/db/migrations/022_checkouts.sql
Normal file
@ -0,0 +1,39 @@
|
||||
-- Migration: Create checkouts table for sidecar development flow
|
||||
-- Tracks checkout sessions where users clone repos locally with temporary tokens
|
||||
|
||||
CREATE TABLE IF NOT EXISTS checkouts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id TEXT NOT NULL,
|
||||
branch VARCHAR(255) NOT NULL,
|
||||
feature_slug VARCHAR(255),
|
||||
gitea_token_id BIGINT NOT NULL,
|
||||
gitea_token_name VARCHAR(255) NOT NULL,
|
||||
clone_url TEXT NOT NULL,
|
||||
checked_out_by VARCHAR(255) NOT NULL,
|
||||
checked_out_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
checked_in_at TIMESTAMPTZ,
|
||||
review_task_id TEXT,
|
||||
|
||||
CONSTRAINT checkouts_status_check CHECK (status IN ('active', 'checked_in', 'expired', 'revoked'))
|
||||
);
|
||||
|
||||
-- Unique constraint: only one active checkout per project+branch
|
||||
-- This prevents conflicts when multiple users try to checkout the same branch
|
||||
CREATE UNIQUE INDEX idx_checkouts_active_branch
|
||||
ON checkouts(project_id, branch)
|
||||
WHERE status = 'active';
|
||||
|
||||
-- Index for cleanup job to find expired checkouts efficiently
|
||||
CREATE INDEX idx_checkouts_expires
|
||||
ON checkouts(expires_at)
|
||||
WHERE status = 'active';
|
||||
|
||||
-- Index for listing checkouts by user
|
||||
CREATE INDEX idx_checkouts_user
|
||||
ON checkouts(checked_out_by, status);
|
||||
|
||||
-- Index for listing checkouts by project
|
||||
CREATE INDEX idx_checkouts_project
|
||||
ON checkouts(project_id, checked_out_at DESC);
|
||||
34
internal/db/migrations/023_sessions.sql
Normal file
34
internal/db/migrations/023_sessions.sql
Normal file
@ -0,0 +1,34 @@
|
||||
-- Migration: Create sessions table for interactive remote development
|
||||
-- Sessions compose checkout (git token) + pod binding + ephemeral preview URL
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id TEXT NOT NULL,
|
||||
checkout_id UUID NOT NULL REFERENCES checkouts(id),
|
||||
pod_name VARCHAR(255) NOT NULL,
|
||||
preview_url TEXT NOT NULL,
|
||||
preview_host VARCHAR(255) NOT NULL,
|
||||
created_by VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
ended_at TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT sessions_status_check CHECK (status IN ('active', 'ended', 'expired'))
|
||||
);
|
||||
|
||||
-- Only one active session per project at a time
|
||||
CREATE UNIQUE INDEX idx_sessions_active_project
|
||||
ON sessions(project_id) WHERE status = 'active';
|
||||
|
||||
-- Index for cleanup job to find expired sessions
|
||||
CREATE INDEX idx_sessions_expires
|
||||
ON sessions(expires_at) WHERE status = 'active';
|
||||
|
||||
-- Index for listing sessions by project
|
||||
CREATE INDEX idx_sessions_project
|
||||
ON sessions(project_id, created_at DESC);
|
||||
|
||||
-- Index for looking up session by checkout
|
||||
CREATE INDEX idx_sessions_checkout
|
||||
ON sessions(checkout_id);
|
||||
@ -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)",
|
||||
}
|
||||
|
||||
|
||||
126
internal/domain/checkout.go
Normal file
126
internal/domain/checkout.go
Normal file
@ -0,0 +1,126 @@
|
||||
// Package domain contains pure domain models with no external dependencies.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// CheckoutID is a strongly-typed identifier for checkouts.
|
||||
type CheckoutID string
|
||||
|
||||
// CheckoutStatus represents the state of a checkout.
|
||||
type CheckoutStatus string
|
||||
|
||||
const (
|
||||
// CheckoutStatusActive indicates the checkout is currently in use.
|
||||
CheckoutStatusActive CheckoutStatus = "active"
|
||||
|
||||
// CheckoutStatusCheckedIn indicates the checkout was completed via checkin.
|
||||
CheckoutStatusCheckedIn CheckoutStatus = "checked_in"
|
||||
|
||||
// CheckoutStatusExpired indicates the checkout token expired.
|
||||
CheckoutStatusExpired CheckoutStatus = "expired"
|
||||
|
||||
// CheckoutStatusRevoked indicates the checkout was manually revoked.
|
||||
CheckoutStatusRevoked CheckoutStatus = "revoked"
|
||||
)
|
||||
|
||||
// IsTerminal returns true if the status is a final state.
|
||||
func (s CheckoutStatus) IsTerminal() bool {
|
||||
return s == CheckoutStatusCheckedIn || s == CheckoutStatusExpired || s == CheckoutStatusRevoked
|
||||
}
|
||||
|
||||
// Checkout represents a checked-out development session.
|
||||
// A checkout grants temporary git access via an HTTPS token for local development.
|
||||
type Checkout struct {
|
||||
// ID is the unique checkout identifier.
|
||||
ID CheckoutID
|
||||
|
||||
// ProjectID is the project being checked out.
|
||||
ProjectID ProjectID
|
||||
|
||||
// Branch is the git branch being worked on.
|
||||
Branch string
|
||||
|
||||
// FeatureSlug is the optional SDLC feature link.
|
||||
FeatureSlug string
|
||||
|
||||
// GiteaTokenID is the Gitea access token ID (for revocation).
|
||||
GiteaTokenID int64
|
||||
|
||||
// GiteaTokenName is the name of the Gitea access token.
|
||||
GiteaTokenName string
|
||||
|
||||
// CloneURL is the base HTTPS clone URL (without token).
|
||||
// Format: https://git.threesix.ai/owner/repo.git
|
||||
// The authenticated URL with token is only returned once at checkout creation.
|
||||
CloneURL string
|
||||
|
||||
// CheckedOutBy is the user or API key that created the checkout.
|
||||
CheckedOutBy string
|
||||
|
||||
// CheckedOutAt is when the checkout was created.
|
||||
CheckedOutAt time.Time
|
||||
|
||||
// ExpiresAt is when the checkout token expires.
|
||||
ExpiresAt time.Time
|
||||
|
||||
// Status is the current state of the checkout.
|
||||
Status CheckoutStatus
|
||||
|
||||
// CheckedInAt is when the checkout was completed (if checked in).
|
||||
CheckedInAt *time.Time
|
||||
|
||||
// ReviewTaskID is the work task ID for the review (if review was triggered).
|
||||
ReviewTaskID string
|
||||
}
|
||||
|
||||
// IsActive returns true if the checkout can still be used.
|
||||
func (c *Checkout) IsActive() bool {
|
||||
return c.Status == CheckoutStatusActive && time.Now().Before(c.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsExpired returns true if the checkout has expired.
|
||||
func (c *Checkout) IsExpired() bool {
|
||||
return c.Status == CheckoutStatusActive && time.Now().After(c.ExpiresAt)
|
||||
}
|
||||
|
||||
// GitBranch represents a git branch in a repository.
|
||||
type GitBranch struct {
|
||||
// Name is the branch name.
|
||||
Name string
|
||||
|
||||
// CommitSHA is the latest commit on the branch.
|
||||
CommitSHA string
|
||||
|
||||
// Protected indicates if the branch has protection rules.
|
||||
Protected bool
|
||||
}
|
||||
|
||||
// GitAccessToken represents a temporary git access token.
|
||||
type GitAccessToken struct {
|
||||
// ID is the token ID (for revocation).
|
||||
ID int64
|
||||
|
||||
// Name is a human-readable name for the token.
|
||||
Name string
|
||||
|
||||
// Token is the actual token value (only available on creation).
|
||||
Token string
|
||||
|
||||
// Scopes are the permissions granted to the token.
|
||||
Scopes []string
|
||||
|
||||
// ExpiresAt is when the token expires.
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// CheckoutListOptions provides filtering options for listing checkouts.
|
||||
type CheckoutListOptions struct {
|
||||
// Status filters by checkout status (nil = all statuses).
|
||||
Status *CheckoutStatus
|
||||
|
||||
// Limit is the maximum number of results.
|
||||
Limit int
|
||||
|
||||
// Offset is the number of results to skip.
|
||||
Offset int
|
||||
}
|
||||
@ -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")
|
||||
|
||||
70
internal/domain/session.go
Normal file
70
internal/domain/session.go
Normal file
@ -0,0 +1,70 @@
|
||||
// Package domain contains pure domain models with no external dependencies.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// SessionID is a strongly-typed identifier for sessions.
|
||||
type SessionID string
|
||||
|
||||
// SessionStatus represents the state of a session.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
// SessionStatusActive indicates the session is currently in use.
|
||||
SessionStatusActive SessionStatus = "active"
|
||||
|
||||
// SessionStatusEnded indicates the session was completed via checkin.
|
||||
SessionStatusEnded SessionStatus = "ended"
|
||||
|
||||
// SessionStatusExpired indicates the session expired without checkin.
|
||||
SessionStatusExpired SessionStatus = "expired"
|
||||
)
|
||||
|
||||
// Session represents an interactive remote development session.
|
||||
// A session composes a checkout (git token + branch), pod binding, and ephemeral preview URL.
|
||||
type Session struct {
|
||||
// ID is the unique session identifier.
|
||||
ID SessionID
|
||||
|
||||
// ProjectID is the project this session belongs to.
|
||||
ProjectID ProjectID
|
||||
|
||||
// CheckoutID links to the checkout that provides git access.
|
||||
CheckoutID CheckoutID
|
||||
|
||||
// PodName is the bound project pod for command execution.
|
||||
PodName string
|
||||
|
||||
// PreviewURL is the full HTTPS URL for the ephemeral preview.
|
||||
// Format: https://{session-slug}.preview.threesix.ai
|
||||
PreviewURL string
|
||||
|
||||
// PreviewHost is the hostname for the K8s Ingress.
|
||||
// Format: {session-slug}.preview.threesix.ai
|
||||
PreviewHost string
|
||||
|
||||
// CreatedBy is the user or API key that created the session.
|
||||
CreatedBy string
|
||||
|
||||
// CreatedAt is when the session was created.
|
||||
CreatedAt time.Time
|
||||
|
||||
// ExpiresAt is when the session will automatically expire.
|
||||
ExpiresAt time.Time
|
||||
|
||||
// Status is the current state of the session.
|
||||
Status SessionStatus
|
||||
|
||||
// EndedAt is when the session was ended (if ended or expired).
|
||||
EndedAt *time.Time
|
||||
}
|
||||
|
||||
// IsActive returns true if the session can still be used.
|
||||
func (s *Session) IsActive() bool {
|
||||
return s.Status == SessionStatusActive && time.Now().Before(s.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsExpired returns true if the session has expired.
|
||||
func (s *Session) IsExpired() bool {
|
||||
return s.Status == SessionStatusActive && time.Now().After(s.ExpiresAt)
|
||||
}
|
||||
457
internal/handlers/checkout.go
Normal file
457
internal/handlers/checkout.go
Normal file
@ -0,0 +1,457 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/internal/auth"
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
"github.com/orchard9/rdev/internal/validate"
|
||||
"github.com/orchard9/rdev/pkg/api"
|
||||
)
|
||||
|
||||
// CheckoutHandler handles checkout/checkin endpoints for sidecar development.
|
||||
type CheckoutHandler struct {
|
||||
checkoutService *service.CheckoutService
|
||||
}
|
||||
|
||||
// NewCheckoutHandler creates a new checkout handler.
|
||||
func NewCheckoutHandler(checkoutService *service.CheckoutService) *CheckoutHandler {
|
||||
return &CheckoutHandler{checkoutService: checkoutService}
|
||||
}
|
||||
|
||||
// Mount registers the checkout routes.
|
||||
func (h *CheckoutHandler) Mount(r api.Router) {
|
||||
r.Route("/projects/{id}/checkout", func(r chi.Router) {
|
||||
// Branch listing (read access)
|
||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
||||
Get("/branches", h.ListBranches)
|
||||
|
||||
// List active checkouts (read access)
|
||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
||||
Get("/", h.List)
|
||||
|
||||
// Create checkout (execute access - creates tokens)
|
||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||
Post("/", h.Create)
|
||||
|
||||
// Get single checkout (read access)
|
||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
||||
Get("/{checkout_id}", h.Get)
|
||||
|
||||
// Checkin (execute access - completes checkout)
|
||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||
Post("/{checkout_id}/checkin", h.Checkin)
|
||||
|
||||
// Revoke checkout (admin only - security sensitive)
|
||||
r.With(auth.RequireScope(auth.ScopeAdmin)).
|
||||
Delete("/{checkout_id}", h.Revoke)
|
||||
})
|
||||
}
|
||||
|
||||
// BranchResponse is the JSON response for a branch.
|
||||
type BranchResponse struct {
|
||||
Name string `json:"name"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
// ListBranches returns all branches for a project's repository.
|
||||
// GET /projects/{id}/checkout/branches
|
||||
func (h *CheckoutHandler) ListBranches(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLookup)
|
||||
defer cancel()
|
||||
|
||||
branches, err := h.checkoutService.ListBranches(ctx, domain.ProjectID(projectID))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrProjectNotFound) {
|
||||
api.WriteNotFound(w, r, "project not found")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to list branches")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]BranchResponse, len(branches))
|
||||
for i, b := range branches {
|
||||
resp[i] = BranchResponse{
|
||||
Name: b.Name,
|
||||
CommitSHA: b.CommitSHA,
|
||||
Protected: b.Protected,
|
||||
}
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]any{
|
||||
"branches": resp,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckoutRequest is the JSON body for creating a checkout.
|
||||
type CheckoutRequest struct {
|
||||
Branch string `json:"branch,omitempty"` // Existing branch to checkout
|
||||
NewBranch string `json:"new_branch,omitempty"` // New branch to create
|
||||
FromRef string `json:"from_ref,omitempty"` // Reference for new branch (default: main)
|
||||
FeatureSlug string `json:"feature_slug,omitempty"` // Optional SDLC feature link
|
||||
ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d")
|
||||
}
|
||||
|
||||
// CheckoutResponse is the JSON response for a checkout.
|
||||
type CheckoutResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Branch string `json:"branch"`
|
||||
FeatureSlug string `json:"feature_slug,omitempty"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
CheckedOutBy string `json:"checked_out_by"`
|
||||
CheckedOutAt string `json:"checked_out_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
Status string `json:"status"`
|
||||
CheckedInAt *string `json:"checked_in_at,omitempty"`
|
||||
ReviewTaskID string `json:"review_task_id,omitempty"`
|
||||
Instructions string `json:"instructions,omitempty"`
|
||||
}
|
||||
|
||||
// List returns active checkouts for a project.
|
||||
// GET /projects/{id}/checkout
|
||||
func (h *CheckoutHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
||||
defer cancel()
|
||||
|
||||
checkouts, err := h.checkoutService.List(ctx, domain.ProjectID(projectID))
|
||||
if err != nil {
|
||||
api.WriteInternalError(w, r, "Failed to list checkouts")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]CheckoutResponse, len(checkouts))
|
||||
for i, c := range checkouts {
|
||||
resp[i] = checkoutToResponse(c, "")
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]any{
|
||||
"checkouts": resp,
|
||||
})
|
||||
}
|
||||
|
||||
// Create creates a new checkout with a temporary git token.
|
||||
// POST /projects/{id}/checkout
|
||||
func (h *CheckoutHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
var req CheckoutRequest
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate: need either branch or new_branch
|
||||
if req.Branch == "" && req.NewBranch == "" {
|
||||
api.WriteBadRequest(w, r, "branch or new_branch is required")
|
||||
return
|
||||
}
|
||||
if req.Branch != "" && req.NewBranch != "" {
|
||||
api.WriteBadRequest(w, r, "specify either branch or new_branch, not both")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate branch name if provided
|
||||
if req.NewBranch != "" {
|
||||
if err := validate.Name(req.NewBranch, "new_branch"); err != nil {
|
||||
api.WriteBadRequest(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse expiry duration
|
||||
var expiresIn time.Duration
|
||||
if req.ExpiresIn != "" {
|
||||
var err error
|
||||
expiresIn, err = time.ParseDuration(req.ExpiresIn)
|
||||
if err != nil {
|
||||
// Try parsing as days (e.g., "7d")
|
||||
if len(req.ExpiresIn) > 1 && req.ExpiresIn[len(req.ExpiresIn)-1] == 'd' {
|
||||
days, parseErr := strconv.Atoi(req.ExpiresIn[:len(req.ExpiresIn)-1])
|
||||
if parseErr == nil && days > 0 && days <= 30 {
|
||||
expiresIn = time.Duration(days) * 24 * time.Hour
|
||||
} else {
|
||||
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
|
||||
return
|
||||
}
|
||||
}
|
||||
// Cap at 30 days
|
||||
if expiresIn > 30*24*time.Hour {
|
||||
api.WriteBadRequest(w, r, "expires_in cannot exceed 30 days")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get user from API key
|
||||
checkedOutBy := "unknown"
|
||||
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil {
|
||||
checkedOutBy = string(apiKey.ID)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.checkoutService.Checkout(ctx, service.CheckoutRequest{
|
||||
ProjectID: domain.ProjectID(projectID),
|
||||
Branch: req.Branch,
|
||||
NewBranch: req.NewBranch,
|
||||
FromRef: req.FromRef,
|
||||
FeatureSlug: req.FeatureSlug,
|
||||
ExpiresIn: expiresIn,
|
||||
CheckedOutBy: checkedOutBy,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrProjectNotFound) {
|
||||
api.WriteNotFound(w, r, "project not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrBranchNotFound) {
|
||||
api.WriteNotFound(w, r, "branch not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrBranchProtected) {
|
||||
api.WriteBadRequest(w, r, "cannot checkout protected branch")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrCheckoutAlreadyExists) {
|
||||
api.WriteError(w, r, http.StatusConflict, "CHECKOUT_EXISTS",
|
||||
"active checkout already exists for this branch")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to create checkout")
|
||||
return
|
||||
}
|
||||
|
||||
// Return authenticated URL only at creation time
|
||||
resp := checkoutToResponse(result.Checkout, result.Instructions)
|
||||
resp.CloneURL = result.AuthenticatedCloneURL // Override with authenticated URL for creation response
|
||||
api.WriteCreated(w, r, resp)
|
||||
}
|
||||
|
||||
// Get retrieves a checkout by ID.
|
||||
// GET /projects/{id}/checkout/{checkout_id}
|
||||
func (h *CheckoutHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
checkoutID := chi.URLParam(r, "checkout_id")
|
||||
if checkoutID == "" {
|
||||
api.WriteBadRequest(w, r, "checkout_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
||||
defer cancel()
|
||||
|
||||
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrCheckoutNotFound) {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to get checkout")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify checkout belongs to project
|
||||
if string(checkout.ProjectID) != projectID {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, checkoutToResponse(checkout, ""))
|
||||
}
|
||||
|
||||
// CheckinRequest is the JSON body for checking in.
|
||||
type CheckinRequestBody struct {
|
||||
SkipReview bool `json:"skip_review,omitempty"` // Skip automatic review
|
||||
AutoMerge bool `json:"auto_merge,omitempty"` // Merge to main if review passes
|
||||
}
|
||||
|
||||
// CheckinResponse is the JSON response for a checkin.
|
||||
type CheckinResponse struct {
|
||||
CheckoutID string `json:"checkout_id"`
|
||||
Status string `json:"status"`
|
||||
ReviewTaskID string `json:"review_task_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Checkin completes a checkout and optionally queues a review.
|
||||
// POST /projects/{id}/checkout/{checkout_id}/checkin
|
||||
func (h *CheckoutHandler) Checkin(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
checkoutID := chi.URLParam(r, "checkout_id")
|
||||
if checkoutID == "" {
|
||||
api.WriteBadRequest(w, r, "checkout_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req CheckinRequestBody
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
// Empty body is OK - use defaults
|
||||
req = CheckinRequestBody{}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
// First verify checkout exists and belongs to project
|
||||
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrCheckoutNotFound) {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to get checkout")
|
||||
return
|
||||
}
|
||||
|
||||
if string(checkout.ProjectID) != projectID {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.checkoutService.Checkin(ctx, service.CheckinRequest{
|
||||
CheckoutID: domain.CheckoutID(checkoutID),
|
||||
SkipReview: req.SkipReview,
|
||||
AutoMerge: req.AutoMerge,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrCheckoutNotFound) {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrCheckoutNotActive) {
|
||||
api.WriteBadRequest(w, r, "checkout is not active")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to checkin")
|
||||
return
|
||||
}
|
||||
|
||||
message := "Checkout completed. Token has been revoked."
|
||||
if result.ReviewTaskID != "" {
|
||||
message = "Checkout completed. Review task queued."
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, CheckinResponse{
|
||||
CheckoutID: string(result.CheckoutID),
|
||||
Status: string(result.Status),
|
||||
ReviewTaskID: result.ReviewTaskID,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Revoke manually revokes an active checkout.
|
||||
// DELETE /projects/{id}/checkout/{checkout_id}
|
||||
func (h *CheckoutHandler) Revoke(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
checkoutID := chi.URLParam(r, "checkout_id")
|
||||
if checkoutID == "" {
|
||||
api.WriteBadRequest(w, r, "checkout_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
// First verify checkout exists and belongs to project
|
||||
checkout, err := h.checkoutService.Get(ctx, domain.CheckoutID(checkoutID))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrCheckoutNotFound) {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to get checkout")
|
||||
return
|
||||
}
|
||||
|
||||
if string(checkout.ProjectID) != projectID {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.checkoutService.Revoke(ctx, domain.CheckoutID(checkoutID)); err != nil {
|
||||
if errors.Is(err, domain.ErrCheckoutNotFound) {
|
||||
api.WriteNotFound(w, r, "checkout not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrCheckoutNotActive) {
|
||||
api.WriteBadRequest(w, r, "checkout is not active")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to revoke checkout")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]string{
|
||||
"status": "revoked",
|
||||
"id": checkoutID,
|
||||
"message": "Checkout revoked. Token has been invalidated.",
|
||||
})
|
||||
}
|
||||
|
||||
// checkoutToResponse converts a domain checkout to a response.
|
||||
func checkoutToResponse(c *domain.Checkout, instructions string) CheckoutResponse {
|
||||
resp := CheckoutResponse{
|
||||
ID: string(c.ID),
|
||||
ProjectID: string(c.ProjectID),
|
||||
Branch: c.Branch,
|
||||
FeatureSlug: c.FeatureSlug,
|
||||
CloneURL: c.CloneURL,
|
||||
CheckedOutBy: c.CheckedOutBy,
|
||||
CheckedOutAt: c.CheckedOutAt.Format(time.RFC3339),
|
||||
ExpiresAt: c.ExpiresAt.Format(time.RFC3339),
|
||||
Status: string(c.Status),
|
||||
ReviewTaskID: c.ReviewTaskID,
|
||||
Instructions: instructions,
|
||||
}
|
||||
|
||||
if c.CheckedInAt != nil {
|
||||
s := c.CheckedInAt.Format(time.RFC3339)
|
||||
resp.CheckedInAt = &s
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
415
internal/handlers/sessions.go
Normal file
415
internal/handlers/sessions.go
Normal file
@ -0,0 +1,415 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/internal/auth"
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
"github.com/orchard9/rdev/internal/validate"
|
||||
"github.com/orchard9/rdev/pkg/api"
|
||||
)
|
||||
|
||||
// SessionsHandler handles interactive remote development session endpoints.
|
||||
type SessionsHandler struct {
|
||||
sessionService *service.SessionService
|
||||
}
|
||||
|
||||
// NewSessionsHandler creates a new sessions handler.
|
||||
func NewSessionsHandler(sessionService *service.SessionService) *SessionsHandler {
|
||||
return &SessionsHandler{sessionService: sessionService}
|
||||
}
|
||||
|
||||
// Mount registers the session routes.
|
||||
func (h *SessionsHandler) Mount(r api.Router) {
|
||||
r.Route("/projects/{id}/sessions", func(r chi.Router) {
|
||||
// List sessions (read access)
|
||||
r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
||||
Get("/", h.List)
|
||||
|
||||
// Create session (execute access)
|
||||
r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||
Post("/", h.Create)
|
||||
|
||||
// Get session (read access)
|
||||
r.With(auth.RequireScope(auth.ScopeSessionsRead, auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
||||
Get("/{sid}", h.Get)
|
||||
|
||||
// End session via checkin (execute access)
|
||||
r.With(auth.RequireScope(auth.ScopeSessionsExecute, auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||
Post("/{sid}/checkin", h.Checkin)
|
||||
|
||||
// Force-terminate session (admin only)
|
||||
r.With(auth.RequireScope(auth.ScopeAdmin)).
|
||||
Delete("/{sid}", h.Delete)
|
||||
})
|
||||
}
|
||||
|
||||
// CreateSessionRequest is the JSON body for creating a session.
|
||||
type CreateSessionRequest struct {
|
||||
Branch string `json:"branch,omitempty"`
|
||||
NewBranch string `json:"new_branch,omitempty"`
|
||||
FromRef string `json:"from_ref,omitempty"`
|
||||
FeatureSlug string `json:"feature_slug,omitempty"`
|
||||
ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d")
|
||||
PreviewPort int `json:"preview_port,omitempty"` // Default: 8080
|
||||
}
|
||||
|
||||
// SessionResponse is the JSON response for a session.
|
||||
type SessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
CheckoutID string `json:"checkout_id"`
|
||||
PodName string `json:"pod_name"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
EndedAt *string `json:"ended_at,omitempty"`
|
||||
AuthCloneURL string `json:"auth_clone_url,omitempty"` // Only at creation
|
||||
Branch string `json:"branch,omitempty"` // Only at creation
|
||||
Instructions string `json:"instructions,omitempty"` // Only at creation
|
||||
}
|
||||
|
||||
// SessionCheckinRequest is the JSON body for ending a session.
|
||||
type SessionCheckinRequest struct {
|
||||
SkipReview bool `json:"skip_review,omitempty"`
|
||||
AutoMerge bool `json:"auto_merge,omitempty"`
|
||||
}
|
||||
|
||||
// SessionCheckinResponse is the JSON response for ending a session.
|
||||
type SessionCheckinResponse struct {
|
||||
SessionID string `json:"session_id"`
|
||||
Status string `json:"status"`
|
||||
ReviewTaskID string `json:"review_task_id,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// List returns all sessions for a project.
|
||||
// GET /projects/{id}/sessions
|
||||
func (h *SessionsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
||||
defer cancel()
|
||||
|
||||
sessions, err := h.sessionService.ListByProject(ctx, domain.ProjectID(projectID))
|
||||
if err != nil {
|
||||
api.WriteInternalError(w, r, "Failed to list sessions")
|
||||
return
|
||||
}
|
||||
|
||||
resp := make([]SessionResponse, len(sessions))
|
||||
for i, s := range sessions {
|
||||
resp[i] = sessionToResponse(s)
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]any{
|
||||
"sessions": resp,
|
||||
})
|
||||
}
|
||||
|
||||
// Create creates a new interactive development session.
|
||||
// POST /projects/{id}/sessions
|
||||
func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateSessionRequest
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate: need either branch or new_branch.
|
||||
if req.Branch == "" && req.NewBranch == "" {
|
||||
api.WriteBadRequest(w, r, "branch or new_branch is required")
|
||||
return
|
||||
}
|
||||
if req.Branch != "" && req.NewBranch != "" {
|
||||
api.WriteBadRequest(w, r, "specify either branch or new_branch, not both")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate branch name if provided.
|
||||
if req.NewBranch != "" {
|
||||
if err := validate.Name(req.NewBranch, "new_branch"); err != nil {
|
||||
api.WriteBadRequest(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Parse expiry duration.
|
||||
var expiresIn time.Duration
|
||||
if req.ExpiresIn != "" {
|
||||
var err error
|
||||
expiresIn, err = time.ParseDuration(req.ExpiresIn)
|
||||
if err != nil {
|
||||
// Try parsing as days (e.g., "7d").
|
||||
if len(req.ExpiresIn) > 1 && req.ExpiresIn[len(req.ExpiresIn)-1] == 'd' {
|
||||
days, parseErr := strconv.Atoi(req.ExpiresIn[:len(req.ExpiresIn)-1])
|
||||
if parseErr == nil && days > 0 && days <= 30 {
|
||||
expiresIn = time.Duration(days) * 24 * time.Hour
|
||||
} else {
|
||||
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
api.WriteBadRequest(w, r, "expires_in must be a valid duration (e.g., 24h, 7d)")
|
||||
return
|
||||
}
|
||||
}
|
||||
if expiresIn > 30*24*time.Hour {
|
||||
api.WriteBadRequest(w, r, "expires_in cannot exceed 30 days")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get user from API key.
|
||||
createdBy := "unknown"
|
||||
if apiKey := auth.GetAPIKey(r.Context()); apiKey != nil {
|
||||
createdBy = string(apiKey.ID)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutOrchestration)
|
||||
defer cancel()
|
||||
|
||||
result, err := h.sessionService.CreateSession(ctx, service.CreateSessionRequest{
|
||||
ProjectID: domain.ProjectID(projectID),
|
||||
Branch: req.Branch,
|
||||
NewBranch: req.NewBranch,
|
||||
FromRef: req.FromRef,
|
||||
FeatureSlug: req.FeatureSlug,
|
||||
ExpiresIn: expiresIn,
|
||||
PreviewPort: req.PreviewPort,
|
||||
CreatedBy: createdBy,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrProjectNotFound) {
|
||||
api.WriteNotFound(w, r, "project not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrProjectNotRunning) {
|
||||
api.WriteBadRequest(w, r, "project pod is not running")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrSessionExists) {
|
||||
api.WriteError(w, r, http.StatusConflict, "SESSION_EXISTS",
|
||||
"active session already exists for this project")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrBranchNotFound) {
|
||||
api.WriteNotFound(w, r, "branch not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrBranchProtected) {
|
||||
api.WriteBadRequest(w, r, "cannot checkout protected branch")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrCheckoutAlreadyExists) {
|
||||
api.WriteError(w, r, http.StatusConflict, "CHECKOUT_EXISTS",
|
||||
"active checkout already exists for this branch")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to create session")
|
||||
return
|
||||
}
|
||||
|
||||
resp := sessionToResponse(result.Session)
|
||||
resp.AuthCloneURL = result.AuthenticatedCloneURL
|
||||
resp.Branch = result.Branch
|
||||
resp.Instructions = result.Instructions
|
||||
api.WriteCreated(w, r, resp)
|
||||
}
|
||||
|
||||
// Get retrieves a session by ID.
|
||||
// GET /projects/{id}/sessions/{sid}
|
||||
func (h *SessionsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
sid := chi.URLParam(r, "sid")
|
||||
if sid == "" {
|
||||
api.WriteBadRequest(w, r, "session id is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutFastLookup)
|
||||
defer cancel()
|
||||
|
||||
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to get session")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify session belongs to project.
|
||||
if string(session.ProjectID) != projectID {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, sessionToResponse(session))
|
||||
}
|
||||
|
||||
// Checkin ends a session and optionally queues a review.
|
||||
// POST /projects/{id}/sessions/{sid}/checkin
|
||||
func (h *SessionsHandler) Checkin(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
sid := chi.URLParam(r, "sid")
|
||||
if sid == "" {
|
||||
api.WriteBadRequest(w, r, "session id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req SessionCheckinRequest
|
||||
if err := api.DecodeJSON(r, &req); err != nil {
|
||||
req = SessionCheckinRequest{}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
// Verify session exists and belongs to project.
|
||||
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to get session")
|
||||
return
|
||||
}
|
||||
if string(session.ProjectID) != projectID {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.sessionService.EndSession(ctx, service.EndSessionRequest{
|
||||
SessionID: domain.SessionID(sid),
|
||||
SkipReview: req.SkipReview,
|
||||
AutoMerge: req.AutoMerge,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrSessionNotActive) {
|
||||
api.WriteBadRequest(w, r, "session is not active")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to end session")
|
||||
return
|
||||
}
|
||||
|
||||
message := "Session ended. Preview removed, token revoked."
|
||||
if result.ReviewTaskID != "" {
|
||||
message = "Session ended. Review task queued."
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, SessionCheckinResponse{
|
||||
SessionID: string(result.SessionID),
|
||||
Status: string(result.Status),
|
||||
ReviewTaskID: result.ReviewTaskID,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete force-terminates a session (admin only).
|
||||
// DELETE /projects/{id}/sessions/{sid}
|
||||
func (h *SessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := chi.URLParam(r, "id")
|
||||
if err := domain.ValidateProjectID(projectID); err != nil {
|
||||
api.WriteBadRequest(w, r, "invalid project id")
|
||||
return
|
||||
}
|
||||
|
||||
sid := chi.URLParam(r, "sid")
|
||||
if sid == "" {
|
||||
api.WriteBadRequest(w, r, "session id is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
// Verify session exists and belongs to project.
|
||||
session, err := h.sessionService.Get(ctx, domain.SessionID(sid))
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to get session")
|
||||
return
|
||||
}
|
||||
if string(session.ProjectID) != projectID {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionService.ForceEnd(ctx, domain.SessionID(sid)); err != nil {
|
||||
if errors.Is(err, domain.ErrSessionNotFound) {
|
||||
api.WriteNotFound(w, r, "session not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, domain.ErrSessionNotActive) {
|
||||
api.WriteBadRequest(w, r, "session is not active")
|
||||
return
|
||||
}
|
||||
api.WriteInternalError(w, r, "Failed to terminate session")
|
||||
return
|
||||
}
|
||||
|
||||
api.WriteSuccess(w, r, map[string]string{
|
||||
"status": "terminated",
|
||||
"id": sid,
|
||||
"message": "Session force-terminated. Preview removed, token revoked.",
|
||||
})
|
||||
}
|
||||
|
||||
// sessionToResponse converts a domain session to a response.
|
||||
func sessionToResponse(s *domain.Session) SessionResponse {
|
||||
resp := SessionResponse{
|
||||
ID: string(s.ID),
|
||||
ProjectID: string(s.ProjectID),
|
||||
CheckoutID: string(s.CheckoutID),
|
||||
PodName: s.PodName,
|
||||
PreviewURL: s.PreviewURL,
|
||||
Status: string(s.Status),
|
||||
CreatedBy: s.CreatedBy,
|
||||
CreatedAt: s.CreatedAt.Format(time.RFC3339),
|
||||
ExpiresAt: s.ExpiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if s.EndedAt != nil {
|
||||
t := s.EndedAt.Format(time.RFC3339)
|
||||
resp.EndedAt = &t
|
||||
}
|
||||
return resp
|
||||
}
|
||||
505
internal/handlers/sessions_test.go
Normal file
505
internal/handlers/sessions_test.go
Normal file
@ -0,0 +1,505 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
"github.com/orchard9/rdev/internal/service"
|
||||
)
|
||||
|
||||
// mockSessionRepository implements port.SessionRepository for testing.
|
||||
type mockSessionRepository struct {
|
||||
sessions map[string]*domain.Session
|
||||
err error
|
||||
}
|
||||
|
||||
func newMockSessionRepository() *mockSessionRepository {
|
||||
return &mockSessionRepository{sessions: make(map[string]*domain.Session)}
|
||||
}
|
||||
|
||||
func (m *mockSessionRepository) Create(_ context.Context, session *domain.Session) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
// Check unique constraint: only one active per project.
|
||||
for _, s := range m.sessions {
|
||||
if s.ProjectID == session.ProjectID && s.Status == domain.SessionStatusActive {
|
||||
return domain.ErrSessionExists
|
||||
}
|
||||
}
|
||||
session.ID = domain.SessionID("test-session-id")
|
||||
m.sessions[string(session.ID)] = session
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSessionRepository) Get(_ context.Context, id domain.SessionID) (*domain.Session, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
s, ok := m.sessions[string(id)]
|
||||
if !ok {
|
||||
return nil, domain.ErrSessionNotFound
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *mockSessionRepository) GetActiveByProject(_ context.Context, projectID domain.ProjectID) (*domain.Session, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
for _, s := range m.sessions {
|
||||
if s.ProjectID == projectID && s.Status == domain.SessionStatusActive {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
return nil, domain.ErrSessionNotFound
|
||||
}
|
||||
|
||||
func (m *mockSessionRepository) ListByProject(_ context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
var result []*domain.Session
|
||||
for _, s := range m.sessions {
|
||||
if s.ProjectID == projectID {
|
||||
result = append(result, s)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockSessionRepository) SetEnded(_ context.Context, id domain.SessionID) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
s, ok := m.sessions[string(id)]
|
||||
if !ok || s.Status != domain.SessionStatusActive {
|
||||
return domain.ErrSessionNotActive
|
||||
}
|
||||
s.Status = domain.SessionStatusEnded
|
||||
now := time.Now()
|
||||
s.EndedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSessionRepository) CleanupExpired(_ context.Context) ([]*domain.Session, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
var expired []*domain.Session
|
||||
for _, s := range m.sessions {
|
||||
if s.Status == domain.SessionStatusActive && time.Now().After(s.ExpiresAt) {
|
||||
s.Status = domain.SessionStatusExpired
|
||||
now := time.Now()
|
||||
s.EndedAt = &now
|
||||
expired = append(expired, s)
|
||||
}
|
||||
}
|
||||
return expired, nil
|
||||
}
|
||||
|
||||
// mockCheckoutRepository implements port.CheckoutRepository for testing sessions.
|
||||
type mockCheckoutRepository struct {
|
||||
checkouts map[string]*domain.Checkout
|
||||
err error
|
||||
}
|
||||
|
||||
func newMockCheckoutRepository() *mockCheckoutRepository {
|
||||
return &mockCheckoutRepository{checkouts: make(map[string]*domain.Checkout)}
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) Create(_ context.Context, c *domain.Checkout) error {
|
||||
if m.err != nil {
|
||||
return m.err
|
||||
}
|
||||
c.ID = domain.CheckoutID("test-checkout-id")
|
||||
m.checkouts[string(c.ID)] = c
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) Get(_ context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
c, ok := m.checkouts[string(id)]
|
||||
if !ok {
|
||||
return nil, domain.ErrCheckoutNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) GetByProjectBranch(_ context.Context, _ domain.ProjectID, _ string) (*domain.Checkout, error) {
|
||||
return nil, domain.ErrCheckoutNotFound
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) List(_ context.Context, _ domain.CheckoutListOptions) ([]*domain.Checkout, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) ListByProject(_ context.Context, _ domain.ProjectID) ([]*domain.Checkout, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) UpdateStatus(_ context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error {
|
||||
if c, ok := m.checkouts[string(id)]; ok {
|
||||
c.Status = status
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) SetCheckedIn(_ context.Context, id domain.CheckoutID, _ string) error {
|
||||
if c, ok := m.checkouts[string(id)]; ok {
|
||||
c.Status = domain.CheckoutStatusCheckedIn
|
||||
now := time.Now()
|
||||
c.CheckedInAt = &now
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) SetReviewTask(_ context.Context, _ domain.CheckoutID, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockCheckoutRepository) CleanupExpired(_ context.Context) ([]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// setupSessionTest creates a sessions handler with mock dependencies.
|
||||
// It reuses mockProjectRepo from queue_test.go.
|
||||
func setupSessionTest() (*SessionsHandler, *mockSessionRepository, *mockProjectRepo) {
|
||||
sessionRepo := newMockSessionRepository()
|
||||
checkoutRepo := newMockCheckoutRepository()
|
||||
projectRepo := newMockProjectRepo()
|
||||
gitRepo := newMockGitRepository()
|
||||
previewMgr := newMockPreviewManager()
|
||||
|
||||
// Add a test project.
|
||||
projectRepo.projects["test-project"] = &domain.Project{
|
||||
ID: "test-project",
|
||||
Name: "test-project",
|
||||
PodName: "test-project-0",
|
||||
Status: domain.ProjectStatusRunning,
|
||||
}
|
||||
|
||||
checkoutService := service.NewCheckoutService(
|
||||
checkoutRepo,
|
||||
gitRepo,
|
||||
projectRepo,
|
||||
service.CheckoutServiceConfig{
|
||||
GiteaURL: "https://git.threesix.ai",
|
||||
DefaultOwner: "threesix",
|
||||
DefaultExpiry: 24 * time.Hour,
|
||||
},
|
||||
)
|
||||
|
||||
sessionService := service.NewSessionService(
|
||||
sessionRepo,
|
||||
checkoutService,
|
||||
projectRepo,
|
||||
previewMgr,
|
||||
service.SessionServiceConfig{
|
||||
PreviewDomain: "preview.threesix.ai",
|
||||
DefaultExpiry: 24 * time.Hour,
|
||||
},
|
||||
)
|
||||
|
||||
handler := NewSessionsHandler(sessionService)
|
||||
return handler, sessionRepo, projectRepo
|
||||
}
|
||||
|
||||
func TestSessionsHandler_Create(t *testing.T) {
|
||||
handler, _, _ := setupSessionTest()
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
t.Run("create_session", func(t *testing.T) {
|
||||
body := `{"branch": "develop"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data map, got %T", resp["data"])
|
||||
}
|
||||
|
||||
if data["id"] == "" {
|
||||
t.Error("expected non-empty session id")
|
||||
}
|
||||
if data["preview_url"] == "" {
|
||||
t.Error("expected non-empty preview_url")
|
||||
}
|
||||
if data["auth_clone_url"] == "" {
|
||||
t.Error("expected auth_clone_url on creation")
|
||||
}
|
||||
if data["instructions"] == "" {
|
||||
t.Error("expected instructions on creation")
|
||||
}
|
||||
if data["pod_name"] != "test-project-0" {
|
||||
t.Errorf("expected pod_name=test-project-0, got %v", data["pod_name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create_session_no_branch", func(t *testing.T) {
|
||||
body := `{}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create_session_project_not_found", func(t *testing.T) {
|
||||
body := `{"branch": "develop"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/nonexistent/sessions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusNotFound, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionsHandler_Get(t *testing.T) {
|
||||
handler, sessionRepo, _ := setupSessionTest()
|
||||
|
||||
// Seed a session.
|
||||
session := &domain.Session{
|
||||
ID: "session-abc",
|
||||
ProjectID: "test-project",
|
||||
CheckoutID: "checkout-abc",
|
||||
PodName: "test-project-0",
|
||||
PreviewURL: "https://abc.preview.threesix.ai",
|
||||
PreviewHost: "abc.preview.threesix.ai",
|
||||
CreatedBy: "test",
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
Status: domain.SessionStatusActive,
|
||||
}
|
||||
sessionRepo.sessions["session-abc"] = session
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
t.Run("get_existing", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/session-abc", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get_wrong_project", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/projects/other-project/sessions/session-abc", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get_not_found", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions/nonexistent", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionsHandler_List(t *testing.T) {
|
||||
handler, sessionRepo, _ := setupSessionTest()
|
||||
|
||||
// Seed sessions.
|
||||
sessionRepo.sessions["session-1"] = &domain.Session{
|
||||
ID: "session-1",
|
||||
ProjectID: "test-project",
|
||||
CheckoutID: "checkout-1",
|
||||
PodName: "test-project-0",
|
||||
PreviewURL: "https://s1.preview.threesix.ai",
|
||||
PreviewHost: "s1.preview.threesix.ai",
|
||||
CreatedBy: "test",
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
Status: domain.SessionStatusActive,
|
||||
}
|
||||
sessionRepo.sessions["session-2"] = &domain.Session{
|
||||
ID: "session-2",
|
||||
ProjectID: "test-project",
|
||||
CheckoutID: "checkout-2",
|
||||
PodName: "test-project-0",
|
||||
PreviewURL: "https://s2.preview.threesix.ai",
|
||||
PreviewHost: "s2.preview.threesix.ai",
|
||||
CreatedBy: "test",
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
ExpiresAt: time.Now().Add(23 * time.Hour),
|
||||
Status: domain.SessionStatusEnded,
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sessions", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data map, got %T", resp["data"])
|
||||
}
|
||||
sessions, ok := data["sessions"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected sessions array, got %T", data["sessions"])
|
||||
}
|
||||
if len(sessions) != 2 {
|
||||
t.Errorf("got %d sessions, want 2", len(sessions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionsHandler_Checkin(t *testing.T) {
|
||||
handler, sessionRepo, _ := setupSessionTest()
|
||||
|
||||
// Seed an active session with a matching checkout.
|
||||
sessionRepo.sessions["session-end"] = &domain.Session{
|
||||
ID: "session-end",
|
||||
ProjectID: "test-project",
|
||||
CheckoutID: "checkout-end",
|
||||
PodName: "test-project-0",
|
||||
PreviewURL: "https://end.preview.threesix.ai",
|
||||
PreviewHost: "end.preview.threesix.ai",
|
||||
CreatedBy: "test",
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
Status: domain.SessionStatusActive,
|
||||
}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
t.Run("checkin_session", func(t *testing.T) {
|
||||
// Checkout doesn't exist in the mock, but session service continues on checkout errors.
|
||||
body := `{"skip_review": true}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("checkin_already_ended", func(t *testing.T) {
|
||||
body := `{}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sessions/session-end/checkin", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWorkersHandler_PoolStatus(t *testing.T) {
|
||||
registry := newMockWorkerRegistry()
|
||||
queue := newMockWorkQueue()
|
||||
workerService := service.NewWorkerService(registry, queue)
|
||||
handler := NewWorkersHandler(workerService).WithWorkQueue(queue)
|
||||
|
||||
registry.workers["w1"] = &domain.Worker{
|
||||
ID: "w1", Hostname: "h1", Status: domain.WorkerStatusIdle,
|
||||
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
|
||||
}
|
||||
registry.workers["w2"] = &domain.Worker{
|
||||
ID: "w2", Hostname: "h2", Status: domain.WorkerStatusBusy,
|
||||
RegisteredAt: time.Now(), LastHeartbeat: time.Now(),
|
||||
}
|
||||
|
||||
// Add a pending task to the queue.
|
||||
queue.tasks["t1"] = &domain.WorkTask{ID: "t1", Status: domain.WorkTaskStatusPending}
|
||||
|
||||
router := chi.NewRouter()
|
||||
router.Use(testAdminAuth)
|
||||
handler.Mount(router)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/workers/pool", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected data map, got %T", resp["data"])
|
||||
}
|
||||
|
||||
if int(data["total"].(float64)) != 2 {
|
||||
t.Errorf("total: got %v, want 2", data["total"])
|
||||
}
|
||||
if int(data["idle"].(float64)) != 1 {
|
||||
t.Errorf("idle: got %v, want 1", data["idle"])
|
||||
}
|
||||
if int(data["busy"].(float64)) != 1 {
|
||||
t.Errorf("busy: got %v, want 1", data["busy"])
|
||||
}
|
||||
if int(data["available"].(float64)) != 1 {
|
||||
t.Errorf("available: got %v, want 1", data["available"])
|
||||
}
|
||||
if int(data["queue_depth"].(float64)) != 1 {
|
||||
t.Errorf("queue_depth: got %v, want 1", data["queue_depth"])
|
||||
}
|
||||
}
|
||||
|
||||
// Verify port.PreviewManager is implemented by the mock.
|
||||
var _ port.PreviewManager = (*mockPreviewManager)(nil)
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
38
internal/port/checkout_repository.go
Normal file
38
internal/port/checkout_repository.go
Normal file
@ -0,0 +1,38 @@
|
||||
// Package port defines interfaces (ports) for external dependencies.
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
)
|
||||
|
||||
// CheckoutRepository manages checkout persistence.
|
||||
type CheckoutRepository interface {
|
||||
// Create stores a new checkout record.
|
||||
Create(ctx context.Context, checkout *domain.Checkout) error
|
||||
|
||||
// Get retrieves a checkout by ID.
|
||||
Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error)
|
||||
|
||||
// GetByProjectBranch retrieves an active checkout for a project+branch.
|
||||
GetByProjectBranch(ctx context.Context, projectID domain.ProjectID, branch string) (*domain.Checkout, error)
|
||||
|
||||
// List returns checkouts matching the given options.
|
||||
List(ctx context.Context, opts domain.CheckoutListOptions) ([]*domain.Checkout, error)
|
||||
|
||||
// ListByProject returns all checkouts for a project.
|
||||
ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error)
|
||||
|
||||
// UpdateStatus updates the status of a checkout.
|
||||
UpdateStatus(ctx context.Context, id domain.CheckoutID, status domain.CheckoutStatus) error
|
||||
|
||||
// SetCheckedIn marks a checkout as checked in with timestamp.
|
||||
SetCheckedIn(ctx context.Context, id domain.CheckoutID, reviewTaskID string) error
|
||||
|
||||
// SetReviewTask sets the review task ID for a checkout.
|
||||
SetReviewTask(ctx context.Context, id domain.CheckoutID, taskID string) error
|
||||
|
||||
// CleanupExpired marks expired checkouts and returns their Gitea token IDs for revocation.
|
||||
CleanupExpired(ctx context.Context) ([]int64, error)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
32
internal/port/preview.go
Normal file
32
internal/port/preview.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Package port defines interfaces (ports) for external dependencies.
|
||||
package port
|
||||
|
||||
import "context"
|
||||
|
||||
// PreviewManager manages ephemeral preview URLs via K8s Service + Ingress.
|
||||
type PreviewManager interface {
|
||||
// CreatePreview creates a K8s Service + Ingress for the session preview.
|
||||
// The service routes traffic to the target pod on the specified port.
|
||||
CreatePreview(ctx context.Context, opts PreviewOptions) error
|
||||
|
||||
// DeletePreview removes the K8s Service + Ingress for a session preview.
|
||||
DeletePreview(ctx context.Context, sessionID string) error
|
||||
}
|
||||
|
||||
// PreviewOptions configures an ephemeral preview.
|
||||
type PreviewOptions struct {
|
||||
// SessionID is used as the resource name prefix (e.g., "session-{id}").
|
||||
SessionID string
|
||||
|
||||
// Namespace is the K8s namespace (typically "rdev").
|
||||
Namespace string
|
||||
|
||||
// PodName is the target pod to route traffic to.
|
||||
PodName string
|
||||
|
||||
// Host is the preview hostname (e.g., "abc123.preview.threesix.ai").
|
||||
Host string
|
||||
|
||||
// Port is the target port on the pod (default: 8080).
|
||||
Port int
|
||||
}
|
||||
29
internal/port/session_repository.go
Normal file
29
internal/port/session_repository.go
Normal file
@ -0,0 +1,29 @@
|
||||
// Package port defines interfaces (ports) for external dependencies.
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
)
|
||||
|
||||
// SessionRepository manages session persistence.
|
||||
type SessionRepository interface {
|
||||
// Create stores a new session record.
|
||||
Create(ctx context.Context, session *domain.Session) error
|
||||
|
||||
// Get retrieves a session by ID.
|
||||
Get(ctx context.Context, id domain.SessionID) (*domain.Session, error)
|
||||
|
||||
// GetActiveByProject retrieves the active session for a project (at most one).
|
||||
GetActiveByProject(ctx context.Context, projectID domain.ProjectID) (*domain.Session, error)
|
||||
|
||||
// ListByProject returns all sessions for a project, ordered by created_at DESC.
|
||||
ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error)
|
||||
|
||||
// SetEnded marks a session as ended with a timestamp.
|
||||
SetEnded(ctx context.Context, id domain.SessionID) error
|
||||
|
||||
// CleanupExpired marks expired sessions and returns them for preview teardown.
|
||||
CleanupExpired(ctx context.Context) ([]*domain.Session, error)
|
||||
}
|
||||
479
internal/service/checkout_service.go
Normal file
479
internal/service/checkout_service.go
Normal file
@ -0,0 +1,479 @@
|
||||
// Package service provides business logic services.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/logging"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
// CheckoutService orchestrates checkout/checkin operations.
|
||||
// It manages temporary git access tokens for local development.
|
||||
type CheckoutService struct {
|
||||
checkoutRepo port.CheckoutRepository
|
||||
gitRepo port.GitRepository
|
||||
workQueue port.WorkQueue
|
||||
projectRepo port.ProjectRepository
|
||||
|
||||
// giteaURL is the base Gitea URL for building clone URLs.
|
||||
giteaURL string
|
||||
|
||||
// defaultOwner is the default git org/user for repositories.
|
||||
defaultOwner string
|
||||
|
||||
// defaultExpiry is the default checkout token expiry duration.
|
||||
defaultExpiry time.Duration
|
||||
}
|
||||
|
||||
// CheckoutServiceConfig holds configuration for the checkout service.
|
||||
type CheckoutServiceConfig struct {
|
||||
GiteaURL string
|
||||
DefaultOwner string
|
||||
DefaultExpiry time.Duration
|
||||
}
|
||||
|
||||
// NewCheckoutService creates a new checkout service.
|
||||
func NewCheckoutService(
|
||||
checkoutRepo port.CheckoutRepository,
|
||||
gitRepo port.GitRepository,
|
||||
projectRepo port.ProjectRepository,
|
||||
cfg CheckoutServiceConfig,
|
||||
) *CheckoutService {
|
||||
expiry := cfg.DefaultExpiry
|
||||
if expiry == 0 {
|
||||
expiry = 24 * time.Hour
|
||||
}
|
||||
return &CheckoutService{
|
||||
checkoutRepo: checkoutRepo,
|
||||
gitRepo: gitRepo,
|
||||
projectRepo: projectRepo,
|
||||
giteaURL: cfg.GiteaURL,
|
||||
defaultOwner: cfg.DefaultOwner,
|
||||
defaultExpiry: expiry,
|
||||
}
|
||||
}
|
||||
|
||||
// WithWorkQueue adds a work queue for review task enqueueing.
|
||||
func (s *CheckoutService) WithWorkQueue(queue port.WorkQueue) *CheckoutService {
|
||||
s.workQueue = queue
|
||||
return s
|
||||
}
|
||||
|
||||
// CheckoutRequest contains parameters for checking out a project.
|
||||
type CheckoutRequest struct {
|
||||
// ProjectID is the project to checkout.
|
||||
ProjectID domain.ProjectID
|
||||
|
||||
// Branch is the branch to checkout. If empty and NewBranch is set, creates NewBranch from main.
|
||||
Branch string
|
||||
|
||||
// NewBranch is an optional new branch name to create.
|
||||
NewBranch string
|
||||
|
||||
// FromRef is the reference to create the new branch from (default: "main").
|
||||
FromRef string
|
||||
|
||||
// FeatureSlug is an optional SDLC feature to link.
|
||||
FeatureSlug string
|
||||
|
||||
// ExpiresIn is the optional token expiry duration (default: 24h).
|
||||
ExpiresIn time.Duration
|
||||
|
||||
// CheckedOutBy is the user/key creating the checkout.
|
||||
CheckedOutBy string
|
||||
}
|
||||
|
||||
// CheckoutResult contains the result of a checkout operation.
|
||||
type CheckoutResult struct {
|
||||
// Checkout is the created checkout record.
|
||||
Checkout *domain.Checkout
|
||||
|
||||
// AuthenticatedCloneURL is the clone URL with embedded token.
|
||||
// This is only available at creation time and should not be stored.
|
||||
AuthenticatedCloneURL string
|
||||
|
||||
// Instructions are human-readable instructions for using the checkout.
|
||||
Instructions string
|
||||
}
|
||||
|
||||
// Checkout creates a new checkout session with a temporary git token.
|
||||
func (s *CheckoutService) Checkout(ctx context.Context, req CheckoutRequest) (*CheckoutResult, error) {
|
||||
log := logging.FromContext(ctx).WithService("CheckoutService")
|
||||
|
||||
// Validate project exists
|
||||
project, err := s.projectRepo.Get(ctx, req.ProjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project: %w", err)
|
||||
}
|
||||
|
||||
// Determine branch
|
||||
branch := req.Branch
|
||||
if req.NewBranch != "" {
|
||||
branch = req.NewBranch
|
||||
}
|
||||
if branch == "" {
|
||||
return nil, fmt.Errorf("branch or new_branch is required")
|
||||
}
|
||||
|
||||
// Get repo info (owner/name from project)
|
||||
repoOwner := s.defaultOwner
|
||||
repoName := string(project.ID)
|
||||
|
||||
// Check if branch exists or needs to be created
|
||||
if req.NewBranch != "" {
|
||||
fromRef := req.FromRef
|
||||
if fromRef == "" {
|
||||
fromRef = "main"
|
||||
}
|
||||
|
||||
log.Info("creating new branch",
|
||||
logging.FieldProjectID, req.ProjectID,
|
||||
"branch", req.NewBranch,
|
||||
"from_ref", fromRef,
|
||||
)
|
||||
|
||||
_, err := s.gitRepo.CreateBranch(ctx, repoOwner, repoName, req.NewBranch, fromRef)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create branch: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Verify branch exists
|
||||
branches, err := s.gitRepo.ListBranches(ctx, repoOwner, repoName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list branches: %w", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, b := range branches {
|
||||
if b.Name == branch {
|
||||
found = true
|
||||
if b.Protected {
|
||||
return nil, domain.ErrBranchProtected
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, domain.ErrBranchNotFound
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing active checkout on this branch
|
||||
existing, err := s.checkoutRepo.GetByProjectBranch(ctx, req.ProjectID, branch)
|
||||
if err == nil && existing != nil {
|
||||
// Check if expired (cleanup job may not have run)
|
||||
if existing.IsExpired() {
|
||||
// Mark as expired and revoke token
|
||||
_ = s.checkoutRepo.UpdateStatus(ctx, existing.ID, domain.CheckoutStatusExpired)
|
||||
_ = s.gitRepo.DeleteAccessToken(ctx, existing.GiteaTokenID)
|
||||
} else {
|
||||
return nil, domain.ErrCheckoutAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiry
|
||||
expiry := s.defaultExpiry
|
||||
if req.ExpiresIn > 0 {
|
||||
expiry = req.ExpiresIn
|
||||
}
|
||||
expiresAt := time.Now().Add(expiry)
|
||||
|
||||
// Create Gitea access token
|
||||
tokenName := fmt.Sprintf("checkout-%s-%s-%d", repoName, branch, time.Now().Unix())
|
||||
token, err := s.gitRepo.CreateAccessToken(ctx, tokenName, []string{"write:repository"}, &expiresAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create access token: %w", err)
|
||||
}
|
||||
|
||||
// Build clone URLs (authenticated for return, base for storage)
|
||||
authCloneURL, baseCloneURL, err := s.buildCloneURLs(token.Token, repoOwner, repoName)
|
||||
if err != nil {
|
||||
// Clean up token on error
|
||||
_ = s.gitRepo.DeleteAccessToken(ctx, token.ID)
|
||||
return nil, fmt.Errorf("build clone url: %w", err)
|
||||
}
|
||||
|
||||
// Create checkout record (stores base URL without token)
|
||||
checkout := &domain.Checkout{
|
||||
ProjectID: req.ProjectID,
|
||||
Branch: branch,
|
||||
FeatureSlug: req.FeatureSlug,
|
||||
GiteaTokenID: token.ID,
|
||||
GiteaTokenName: token.Name,
|
||||
CloneURL: baseCloneURL, // Base URL only - no token
|
||||
CheckedOutBy: req.CheckedOutBy,
|
||||
CheckedOutAt: time.Now(),
|
||||
ExpiresAt: expiresAt,
|
||||
Status: domain.CheckoutStatusActive,
|
||||
}
|
||||
|
||||
if err := s.checkoutRepo.Create(ctx, checkout); err != nil {
|
||||
// Clean up token on error
|
||||
_ = s.gitRepo.DeleteAccessToken(ctx, token.ID)
|
||||
return nil, fmt.Errorf("create checkout record: %w", err)
|
||||
}
|
||||
|
||||
log.Info("checkout created",
|
||||
logging.FieldProjectID, req.ProjectID,
|
||||
"checkout_id", checkout.ID,
|
||||
"branch", branch,
|
||||
"expires_at", expiresAt,
|
||||
)
|
||||
|
||||
instructions := fmt.Sprintf(`Clone with:
|
||||
git clone %s
|
||||
cd %s
|
||||
git checkout %s
|
||||
|
||||
Work locally, then push and call checkin when ready.
|
||||
Token expires: %s`, authCloneURL, repoName, branch, expiresAt.Format(time.RFC3339))
|
||||
|
||||
return &CheckoutResult{
|
||||
Checkout: checkout,
|
||||
AuthenticatedCloneURL: authCloneURL, // Only returned once at creation
|
||||
Instructions: instructions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckinRequest contains parameters for checking in.
|
||||
type CheckinRequest struct {
|
||||
// CheckoutID is the checkout to complete.
|
||||
CheckoutID domain.CheckoutID
|
||||
|
||||
// SkipReview skips the automatic review (merge directly if desired).
|
||||
SkipReview bool
|
||||
|
||||
// AutoMerge attempts to merge to main if review passes.
|
||||
AutoMerge bool
|
||||
}
|
||||
|
||||
// CheckinResult contains the result of a checkin operation.
|
||||
type CheckinResult struct {
|
||||
// CheckoutID is the completed checkout ID.
|
||||
CheckoutID domain.CheckoutID
|
||||
|
||||
// ReviewTaskID is the ID of the queued review task (if review requested).
|
||||
ReviewTaskID string
|
||||
|
||||
// Status is the current checkout status.
|
||||
Status domain.CheckoutStatus
|
||||
}
|
||||
|
||||
// Checkin completes a checkout and optionally queues a review.
|
||||
func (s *CheckoutService) Checkin(ctx context.Context, req CheckinRequest) (*CheckinResult, error) {
|
||||
log := logging.FromContext(ctx).WithService("CheckoutService")
|
||||
|
||||
// Get checkout
|
||||
checkout, err := s.checkoutRepo.Get(ctx, req.CheckoutID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify active
|
||||
if checkout.Status != domain.CheckoutStatusActive {
|
||||
return nil, domain.ErrCheckoutNotActive
|
||||
}
|
||||
|
||||
// Revoke token immediately (security: token should not be usable after checkin)
|
||||
if err := s.gitRepo.DeleteAccessToken(ctx, checkout.GiteaTokenID); err != nil {
|
||||
log.Warn("failed to revoke checkout token",
|
||||
"checkout_id", checkout.ID,
|
||||
"token_id", checkout.GiteaTokenID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
// Continue anyway - token revocation failure shouldn't block checkin
|
||||
}
|
||||
|
||||
var reviewTaskID string
|
||||
|
||||
// Queue review task if requested
|
||||
if !req.SkipReview && s.workQueue != nil {
|
||||
task := &domain.WorkTask{
|
||||
ProjectID: string(checkout.ProjectID),
|
||||
Type: domain.WorkTaskType("review_checkout"),
|
||||
Spec: map[string]any{
|
||||
"checkout_id": string(checkout.ID),
|
||||
"branch": checkout.Branch,
|
||||
"feature_slug": checkout.FeatureSlug,
|
||||
"auto_merge": req.AutoMerge,
|
||||
},
|
||||
Priority: 5, // Normal priority
|
||||
MaxRetries: 1, // Reviews shouldn't retry automatically
|
||||
}
|
||||
|
||||
taskID, err := s.workQueue.Enqueue(ctx, task)
|
||||
if err != nil {
|
||||
log.Warn("failed to enqueue review task",
|
||||
"checkout_id", checkout.ID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
} else {
|
||||
reviewTaskID = taskID
|
||||
log.Info("review task queued",
|
||||
"checkout_id", checkout.ID,
|
||||
"task_id", taskID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark checked in
|
||||
if err := s.checkoutRepo.SetCheckedIn(ctx, checkout.ID, reviewTaskID); err != nil {
|
||||
return nil, fmt.Errorf("set checked in: %w", err)
|
||||
}
|
||||
|
||||
log.Info("checkout completed",
|
||||
logging.FieldProjectID, checkout.ProjectID,
|
||||
"checkout_id", checkout.ID,
|
||||
"branch", checkout.Branch,
|
||||
"review_queued", reviewTaskID != "",
|
||||
)
|
||||
|
||||
return &CheckinResult{
|
||||
CheckoutID: checkout.ID,
|
||||
ReviewTaskID: reviewTaskID,
|
||||
Status: domain.CheckoutStatusCheckedIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListBranches returns all branches for a project's repository.
|
||||
func (s *CheckoutService) ListBranches(ctx context.Context, projectID domain.ProjectID) ([]*domain.GitBranch, error) {
|
||||
// Get project to verify it exists
|
||||
project, err := s.projectRepo.Get(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project: %w", err)
|
||||
}
|
||||
|
||||
repoOwner := s.defaultOwner
|
||||
repoName := string(project.ID)
|
||||
|
||||
return s.gitRepo.ListBranches(ctx, repoOwner, repoName)
|
||||
}
|
||||
|
||||
// Get retrieves a checkout by ID.
|
||||
func (s *CheckoutService) Get(ctx context.Context, id domain.CheckoutID) (*domain.Checkout, error) {
|
||||
return s.checkoutRepo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// List returns checkouts for a project.
|
||||
func (s *CheckoutService) List(ctx context.Context, projectID domain.ProjectID) ([]*domain.Checkout, error) {
|
||||
return s.checkoutRepo.ListByProject(ctx, projectID)
|
||||
}
|
||||
|
||||
// Revoke manually revokes an active checkout.
|
||||
func (s *CheckoutService) Revoke(ctx context.Context, id domain.CheckoutID) error {
|
||||
log := logging.FromContext(ctx).WithService("CheckoutService")
|
||||
|
||||
checkout, err := s.checkoutRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if checkout.Status != domain.CheckoutStatusActive {
|
||||
return domain.ErrCheckoutNotActive
|
||||
}
|
||||
|
||||
// Revoke token
|
||||
if err := s.gitRepo.DeleteAccessToken(ctx, checkout.GiteaTokenID); err != nil {
|
||||
log.Warn("failed to revoke checkout token",
|
||||
"checkout_id", checkout.ID,
|
||||
"token_id", checkout.GiteaTokenID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Update status
|
||||
if err := s.checkoutRepo.UpdateStatus(ctx, id, domain.CheckoutStatusRevoked); err != nil {
|
||||
return fmt.Errorf("update status: %w", err)
|
||||
}
|
||||
|
||||
log.Info("checkout revoked",
|
||||
"checkout_id", id,
|
||||
logging.FieldProjectID, checkout.ProjectID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExpired marks expired checkouts and revokes their tokens.
|
||||
// Returns the number of checkouts cleaned up.
|
||||
func (s *CheckoutService) CleanupExpired(ctx context.Context) (int, error) {
|
||||
log := logging.FromContext(ctx).WithService("CheckoutService")
|
||||
|
||||
tokenIDs, err := s.checkoutRepo.CleanupExpired(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cleanup expired: %w", err)
|
||||
}
|
||||
|
||||
// Revoke tokens
|
||||
for _, tokenID := range tokenIDs {
|
||||
if err := s.gitRepo.DeleteAccessToken(ctx, tokenID); err != nil {
|
||||
log.Warn("failed to revoke expired token",
|
||||
"token_id", tokenID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tokenIDs) > 0 {
|
||||
log.Info("cleaned up expired checkouts", "count", len(tokenIDs))
|
||||
}
|
||||
|
||||
return len(tokenIDs), nil
|
||||
}
|
||||
|
||||
// buildCloneURLs constructs both authenticated and base HTTPS clone URLs.
|
||||
// Returns (authenticatedURL, baseURL, error).
|
||||
// The authenticated URL contains the token and should only be shown once.
|
||||
// The base URL is safe to store and display.
|
||||
func (s *CheckoutService) buildCloneURLs(token, owner, repo string) (authURL, baseURL string, err error) {
|
||||
// Parse base URL
|
||||
u, err := url.Parse(s.giteaURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse gitea url: %w", err)
|
||||
}
|
||||
|
||||
// Build base URL (no token): https://git.threesix.ai/owner/repo.git
|
||||
u.Path = fmt.Sprintf("/%s/%s.git", owner, repo)
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
baseURL = u.String()
|
||||
|
||||
// Build authenticated URL: https://<token>@git.threesix.ai/owner/repo.git
|
||||
u.User = url.User(token)
|
||||
authURL = u.String()
|
||||
|
||||
return authURL, baseURL, nil
|
||||
}
|
||||
|
||||
// SanitizeCloneURL removes the token from a clone URL for safe logging/display.
|
||||
func SanitizeCloneURL(cloneURL string) string {
|
||||
u, err := url.Parse(cloneURL)
|
||||
if err != nil {
|
||||
return cloneURL
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// ExtractRepoFromURL extracts owner/repo from a git URL.
|
||||
func ExtractRepoFromURL(gitURL string) (owner, repo string, err error) {
|
||||
u, err := url.Parse(gitURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Remove .git suffix and leading slash
|
||||
path := strings.TrimSuffix(strings.TrimPrefix(u.Path, "/"), ".git")
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmt.Errorf("invalid git url path: %s", path)
|
||||
}
|
||||
|
||||
return parts[0], parts[1], nil
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
383
internal/service/session_service.go
Normal file
383
internal/service/session_service.go
Normal file
@ -0,0 +1,383 @@
|
||||
// Package service provides business logic services.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
"github.com/orchard9/rdev/internal/logging"
|
||||
"github.com/orchard9/rdev/internal/port"
|
||||
)
|
||||
|
||||
// SessionService orchestrates interactive remote development sessions.
|
||||
// A session composes checkout (git token) + pod binding + ephemeral preview URL.
|
||||
type SessionService struct {
|
||||
sessionRepo port.SessionRepository
|
||||
checkoutSvc *CheckoutService
|
||||
projectRepo port.ProjectRepository
|
||||
previewMgr port.PreviewManager
|
||||
previewDomain string
|
||||
defaultExpiry time.Duration
|
||||
}
|
||||
|
||||
// SessionServiceConfig holds configuration for the session service.
|
||||
type SessionServiceConfig struct {
|
||||
// PreviewDomain is the base domain for preview URLs (e.g., "preview.threesix.ai").
|
||||
PreviewDomain string
|
||||
|
||||
// DefaultExpiry is the default session duration (default: 24h).
|
||||
DefaultExpiry time.Duration
|
||||
}
|
||||
|
||||
// NewSessionService creates a new session service.
|
||||
func NewSessionService(
|
||||
sessionRepo port.SessionRepository,
|
||||
checkoutSvc *CheckoutService,
|
||||
projectRepo port.ProjectRepository,
|
||||
previewMgr port.PreviewManager,
|
||||
cfg SessionServiceConfig,
|
||||
) *SessionService {
|
||||
expiry := cfg.DefaultExpiry
|
||||
if expiry == 0 {
|
||||
expiry = 24 * time.Hour
|
||||
}
|
||||
previewDomain := cfg.PreviewDomain
|
||||
if previewDomain == "" {
|
||||
previewDomain = "preview.threesix.ai"
|
||||
}
|
||||
return &SessionService{
|
||||
sessionRepo: sessionRepo,
|
||||
checkoutSvc: checkoutSvc,
|
||||
projectRepo: projectRepo,
|
||||
previewMgr: previewMgr,
|
||||
previewDomain: previewDomain,
|
||||
defaultExpiry: expiry,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSessionRequest contains parameters for creating a session.
|
||||
type CreateSessionRequest struct {
|
||||
ProjectID domain.ProjectID
|
||||
Branch string
|
||||
NewBranch string
|
||||
FromRef string
|
||||
FeatureSlug string
|
||||
ExpiresIn time.Duration
|
||||
PreviewPort int
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
// SessionResult contains the result of a session creation.
|
||||
type SessionResult struct {
|
||||
Session *domain.Session
|
||||
AuthenticatedCloneURL string
|
||||
Branch string
|
||||
Instructions string
|
||||
}
|
||||
|
||||
// CreateSession creates a new interactive development session.
|
||||
func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionRequest) (*SessionResult, error) {
|
||||
log := logging.FromContext(ctx).WithService("SessionService")
|
||||
|
||||
// Verify project exists and get pod info.
|
||||
project, err := s.projectRepo.Get(ctx, req.ProjectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get project: %w", err)
|
||||
}
|
||||
if project.PodName == "" {
|
||||
return nil, domain.ErrProjectNotRunning
|
||||
}
|
||||
|
||||
// Check no active session exists for this project.
|
||||
existing, err := s.sessionRepo.GetActiveByProject(ctx, req.ProjectID)
|
||||
if err == nil && existing != nil {
|
||||
if existing.IsExpired() {
|
||||
// Cleanup the expired session inline.
|
||||
_ = s.teardownSession(ctx, existing)
|
||||
} else {
|
||||
return nil, domain.ErrSessionExists
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiry.
|
||||
expiry := s.defaultExpiry
|
||||
if req.ExpiresIn > 0 {
|
||||
expiry = req.ExpiresIn
|
||||
}
|
||||
|
||||
// Create checkout (git token + branch).
|
||||
checkoutResult, err := s.checkoutSvc.Checkout(ctx, CheckoutRequest{
|
||||
ProjectID: req.ProjectID,
|
||||
Branch: req.Branch,
|
||||
NewBranch: req.NewBranch,
|
||||
FromRef: req.FromRef,
|
||||
FeatureSlug: req.FeatureSlug,
|
||||
ExpiresIn: expiry,
|
||||
CheckedOutBy: req.CreatedBy,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create checkout: %w", err)
|
||||
}
|
||||
|
||||
// Generate preview slug (short random hex).
|
||||
slug, err := generatePreviewSlug()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate preview slug: %w", err)
|
||||
}
|
||||
previewHost := slug + "." + s.previewDomain
|
||||
previewURL := "https://" + previewHost
|
||||
|
||||
// Determine preview port.
|
||||
previewPort := req.PreviewPort
|
||||
if previewPort == 0 {
|
||||
previewPort = 8080
|
||||
}
|
||||
|
||||
// Create session record first to get the ID.
|
||||
session := &domain.Session{
|
||||
ProjectID: req.ProjectID,
|
||||
CheckoutID: checkoutResult.Checkout.ID,
|
||||
PodName: project.PodName,
|
||||
PreviewURL: previewURL,
|
||||
PreviewHost: previewHost,
|
||||
CreatedBy: req.CreatedBy,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(expiry),
|
||||
Status: domain.SessionStatusActive,
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
||||
// Rollback: revoke checkout token.
|
||||
_ = s.checkoutSvc.Revoke(ctx, checkoutResult.Checkout.ID)
|
||||
return nil, fmt.Errorf("create session record: %w", err)
|
||||
}
|
||||
|
||||
// Create K8s preview (Service + Ingress).
|
||||
if err := s.previewMgr.CreatePreview(ctx, port.PreviewOptions{
|
||||
SessionID: string(session.ID),
|
||||
Namespace: "rdev",
|
||||
PodName: project.PodName,
|
||||
Host: previewHost,
|
||||
Port: previewPort,
|
||||
}); err != nil {
|
||||
// Rollback: mark session ended and revoke checkout.
|
||||
_ = s.sessionRepo.SetEnded(ctx, session.ID)
|
||||
_ = s.checkoutSvc.Revoke(ctx, checkoutResult.Checkout.ID)
|
||||
return nil, fmt.Errorf("create preview: %w", err)
|
||||
}
|
||||
|
||||
log.Info("session created",
|
||||
logging.FieldProjectID, req.ProjectID,
|
||||
"session_id", session.ID,
|
||||
"preview_url", previewURL,
|
||||
"pod", project.PodName,
|
||||
"branch", checkoutResult.Checkout.Branch,
|
||||
)
|
||||
|
||||
branch := checkoutResult.Checkout.Branch
|
||||
instructions := fmt.Sprintf(`Session started.
|
||||
|
||||
Preview URL: %s
|
||||
Clone URL: %s
|
||||
Branch: %s
|
||||
Pod: %s
|
||||
|
||||
Run commands via:
|
||||
POST /projects/%s/claude (Claude Code commands)
|
||||
POST /projects/%s/shell (shell commands)
|
||||
GET /projects/%s/stream (SSE output)
|
||||
|
||||
End session:
|
||||
POST /projects/%s/sessions/%s/checkin
|
||||
|
||||
Session expires: %s`,
|
||||
previewURL,
|
||||
checkoutResult.AuthenticatedCloneURL,
|
||||
branch,
|
||||
project.PodName,
|
||||
req.ProjectID, req.ProjectID, req.ProjectID,
|
||||
req.ProjectID, session.ID,
|
||||
session.ExpiresAt.Format(time.RFC3339),
|
||||
)
|
||||
|
||||
return &SessionResult{
|
||||
Session: session,
|
||||
AuthenticatedCloneURL: checkoutResult.AuthenticatedCloneURL,
|
||||
Branch: branch,
|
||||
Instructions: instructions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EndSessionRequest contains parameters for ending a session.
|
||||
type EndSessionRequest struct {
|
||||
SessionID domain.SessionID
|
||||
SkipReview bool
|
||||
AutoMerge bool
|
||||
}
|
||||
|
||||
// EndSessionResult contains the result of ending a session.
|
||||
type EndSessionResult struct {
|
||||
SessionID domain.SessionID
|
||||
ReviewTaskID string
|
||||
Status domain.SessionStatus
|
||||
}
|
||||
|
||||
// EndSession ends an active session, tearing down preview and completing checkout.
|
||||
func (s *SessionService) EndSession(ctx context.Context, req EndSessionRequest) (*EndSessionResult, error) {
|
||||
log := logging.FromContext(ctx).WithService("SessionService")
|
||||
|
||||
session, err := s.sessionRepo.Get(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if session.Status != domain.SessionStatusActive {
|
||||
return nil, domain.ErrSessionNotActive
|
||||
}
|
||||
|
||||
// Delete preview (Service + Ingress).
|
||||
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
|
||||
log.Warn("failed to delete preview",
|
||||
"session_id", session.ID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
// Continue — preview cleanup failure shouldn't block checkin.
|
||||
}
|
||||
|
||||
// Complete checkout (revoke token, queue review).
|
||||
var reviewTaskID string
|
||||
checkinResult, err := s.checkoutSvc.Checkin(ctx, CheckinRequest{
|
||||
CheckoutID: session.CheckoutID,
|
||||
SkipReview: req.SkipReview,
|
||||
AutoMerge: req.AutoMerge,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("failed to checkin checkout",
|
||||
"session_id", session.ID,
|
||||
"checkout_id", session.CheckoutID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
// Continue — we still want to mark the session ended.
|
||||
} else {
|
||||
reviewTaskID = checkinResult.ReviewTaskID
|
||||
}
|
||||
|
||||
// Mark session ended.
|
||||
if err := s.sessionRepo.SetEnded(ctx, session.ID); err != nil {
|
||||
return nil, fmt.Errorf("set session ended: %w", err)
|
||||
}
|
||||
|
||||
log.Info("session ended",
|
||||
logging.FieldProjectID, session.ProjectID,
|
||||
"session_id", session.ID,
|
||||
"review_queued", reviewTaskID != "",
|
||||
)
|
||||
|
||||
return &EndSessionResult{
|
||||
SessionID: session.ID,
|
||||
ReviewTaskID: reviewTaskID,
|
||||
Status: domain.SessionStatusEnded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get retrieves a session by ID.
|
||||
func (s *SessionService) Get(ctx context.Context, id domain.SessionID) (*domain.Session, error) {
|
||||
return s.sessionRepo.Get(ctx, id)
|
||||
}
|
||||
|
||||
// ListByProject returns all sessions for a project.
|
||||
func (s *SessionService) ListByProject(ctx context.Context, projectID domain.ProjectID) ([]*domain.Session, error) {
|
||||
return s.sessionRepo.ListByProject(ctx, projectID)
|
||||
}
|
||||
|
||||
// ForceEnd forcefully ends a session without checkout checkin (admin use).
|
||||
func (s *SessionService) ForceEnd(ctx context.Context, id domain.SessionID) error {
|
||||
log := logging.FromContext(ctx).WithService("SessionService")
|
||||
|
||||
session, err := s.sessionRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if session.Status != domain.SessionStatusActive {
|
||||
return domain.ErrSessionNotActive
|
||||
}
|
||||
|
||||
// Delete preview.
|
||||
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
|
||||
log.Warn("failed to delete preview on force-end",
|
||||
"session_id", session.ID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Revoke checkout token.
|
||||
if err := s.checkoutSvc.Revoke(ctx, session.CheckoutID); err != nil {
|
||||
log.Warn("failed to revoke checkout on force-end",
|
||||
"session_id", session.ID,
|
||||
"checkout_id", session.CheckoutID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Mark session ended.
|
||||
if err := s.sessionRepo.SetEnded(ctx, session.ID); err != nil {
|
||||
return fmt.Errorf("set session ended: %w", err)
|
||||
}
|
||||
|
||||
log.Info("session force-ended",
|
||||
logging.FieldProjectID, session.ProjectID,
|
||||
"session_id", session.ID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExpired finds and tears down expired sessions.
|
||||
// Returns the number of sessions cleaned up.
|
||||
func (s *SessionService) CleanupExpired(ctx context.Context) (int, error) {
|
||||
log := logging.FromContext(ctx).WithService("SessionService")
|
||||
|
||||
sessions, err := s.sessionRepo.CleanupExpired(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("cleanup expired sessions: %w", err)
|
||||
}
|
||||
|
||||
for _, session := range sessions {
|
||||
// Delete preview resources.
|
||||
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
|
||||
log.Warn("failed to delete preview for expired session",
|
||||
"session_id", session.ID,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
}
|
||||
|
||||
// Revoke checkout token (the checkout cleanup may also do this).
|
||||
_ = s.checkoutSvc.Revoke(ctx, session.CheckoutID)
|
||||
}
|
||||
|
||||
if len(sessions) > 0 {
|
||||
log.Info("cleaned up expired sessions", "count", len(sessions))
|
||||
}
|
||||
|
||||
return len(sessions), nil
|
||||
}
|
||||
|
||||
// teardownSession handles cleanup of an expired session found inline.
|
||||
func (s *SessionService) teardownSession(ctx context.Context, session *domain.Session) error {
|
||||
if err := s.previewMgr.DeletePreview(ctx, string(session.ID)); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sessionRepo.SetEnded(ctx, session.ID)
|
||||
}
|
||||
|
||||
// generatePreviewSlug generates a short random hex string for preview URLs.
|
||||
func generatePreviewSlug() (string, error) {
|
||||
b := make([]byte, 6) // 12 hex chars
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate random bytes: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
118
internal/worker/checkout_cleanup.go
Normal file
118
internal/worker/checkout_cleanup.go
Normal file
@ -0,0 +1,118 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/logging"
|
||||
)
|
||||
|
||||
// CheckoutCleanupService defines the interface for checkout cleanup operations.
|
||||
// This allows the worker to depend on the service interface rather than the concrete type.
|
||||
type CheckoutCleanupService interface {
|
||||
CleanupExpired(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// CheckoutCleanup runs periodic cleanup of expired checkouts.
|
||||
// Expired checkouts have their Gitea tokens revoked and status updated.
|
||||
type CheckoutCleanup struct {
|
||||
service CheckoutCleanupService
|
||||
cleanupInterval time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// CheckoutCleanupConfig holds configuration for checkout cleanup.
|
||||
type CheckoutCleanupConfig struct {
|
||||
// CleanupInterval is how often to run cleanup.
|
||||
// Default: 5 minutes.
|
||||
CleanupInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultCheckoutCleanupConfig returns sensible defaults.
|
||||
func DefaultCheckoutCleanupConfig() *CheckoutCleanupConfig {
|
||||
return &CheckoutCleanupConfig{
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// NewCheckoutCleanup creates a new checkout cleanup worker.
|
||||
func NewCheckoutCleanup(service CheckoutCleanupService, cfg *CheckoutCleanupConfig) *CheckoutCleanup {
|
||||
if cfg == nil {
|
||||
cfg = DefaultCheckoutCleanupConfig()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &CheckoutCleanup{
|
||||
service: service,
|
||||
cleanupInterval: cfg.CleanupInterval,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the cleanup loop.
|
||||
func (c *CheckoutCleanup) Start() {
|
||||
log := logging.FromContext(c.ctx).WithWorker("checkout-cleanup")
|
||||
log.Info("checkout cleanup started",
|
||||
"cleanup_interval", c.cleanupInterval,
|
||||
)
|
||||
|
||||
c.wg.Add(1)
|
||||
go c.cleanupLoop()
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the cleanup worker.
|
||||
func (c *CheckoutCleanup) Stop() {
|
||||
log := logging.FromContext(c.ctx).WithWorker("checkout-cleanup")
|
||||
log.Info("checkout cleanup stopping")
|
||||
c.cancel()
|
||||
c.wg.Wait()
|
||||
log.Info("checkout cleanup stopped")
|
||||
}
|
||||
|
||||
// cleanupLoop runs periodic cleanup.
|
||||
func (c *CheckoutCleanup) cleanupLoop() {
|
||||
defer c.wg.Done()
|
||||
|
||||
// Run immediately on start
|
||||
c.runCleanup()
|
||||
|
||||
ticker := time.NewTicker(c.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.runCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runCleanup marks expired checkouts and revokes their tokens.
|
||||
func (c *CheckoutCleanup) runCleanup() {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, TimeoutMaintenance)
|
||||
defer cancel()
|
||||
|
||||
log := logging.FromContext(ctx).WithWorker("checkout-cleanup")
|
||||
|
||||
cleaned, err := c.service.CleanupExpired(ctx)
|
||||
if err != nil {
|
||||
log.Error("failed to cleanup expired checkouts",
|
||||
logging.FieldError, err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if cleaned > 0 {
|
||||
log.Info("cleaned up expired checkouts",
|
||||
"count", cleaned,
|
||||
)
|
||||
}
|
||||
}
|
||||
214
internal/worker/resource_gc.go
Normal file
214
internal/worker/resource_gc.go
Normal file
@ -0,0 +1,214 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/logging"
|
||||
)
|
||||
|
||||
// resourceReconciler abstracts the K8s operations needed by the GC worker.
|
||||
// The concrete deployer.Deployer satisfies this interface implicitly.
|
||||
type resourceReconciler interface {
|
||||
ListProjectLabels(ctx context.Context) ([]string, error)
|
||||
GetOldestResourceTime(ctx context.Context, projectName string) (time.Time, bool, error)
|
||||
UndeployAll(ctx context.Context, projectName string) error
|
||||
}
|
||||
|
||||
// projectChecker determines whether a project exists in the database.
|
||||
type projectChecker interface {
|
||||
projectExists(ctx context.Context, projectID string) (bool, error)
|
||||
}
|
||||
|
||||
// dbProjectChecker checks project existence via SQL.
|
||||
type dbProjectChecker struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (c *dbProjectChecker) projectExists(ctx context.Context, projectID string) (bool, error) {
|
||||
var exists bool
|
||||
err := c.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM projects WHERE id = $1)", projectID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// ResourceGCConfig holds configuration for the GC worker.
|
||||
type ResourceGCConfig struct {
|
||||
// MinAge is the minimum resource age before deletion. Default: 1 hour.
|
||||
MinAge time.Duration
|
||||
// ReconcileInterval is how often to run reconciliation. Default: 15 minutes.
|
||||
ReconcileInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultResourceGCConfig returns sensible defaults.
|
||||
func DefaultResourceGCConfig() *ResourceGCConfig {
|
||||
return &ResourceGCConfig{
|
||||
MinAge: 1 * time.Hour,
|
||||
ReconcileInterval: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceGC periodically finds K8s resources whose project label doesn't
|
||||
// match any project in the database, and deletes them after a safety window.
|
||||
type ResourceGC struct {
|
||||
reconciler resourceReconciler
|
||||
checker projectChecker
|
||||
config *ResourceGCConfig
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewResourceGC creates a new resource GC worker.
|
||||
// The reconciler must implement ListProjectLabels, GetOldestResourceTime, and UndeployAll
|
||||
// (deployer.Deployer satisfies this). If cfg is nil, defaults are used.
|
||||
func NewResourceGC(reconciler resourceReconciler, db *sql.DB, cfg *ResourceGCConfig) *ResourceGC {
|
||||
if cfg == nil {
|
||||
cfg = DefaultResourceGCConfig()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &ResourceGC{
|
||||
reconciler: reconciler,
|
||||
checker: &dbProjectChecker{db: db},
|
||||
config: cfg,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// newResourceGCWithChecker creates a ResourceGC with a custom project checker (for testing).
|
||||
func newResourceGCWithChecker(reconciler resourceReconciler, checker projectChecker, cfg *ResourceGCConfig) *ResourceGC {
|
||||
if cfg == nil {
|
||||
cfg = DefaultResourceGCConfig()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &ResourceGC{
|
||||
reconciler: reconciler,
|
||||
checker: checker,
|
||||
config: cfg,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the reconciliation loop.
|
||||
func (g *ResourceGC) Start() {
|
||||
log := logging.FromContext(g.ctx).WithWorker("resource-gc")
|
||||
log.Info("resource GC started",
|
||||
"min_age", g.config.MinAge,
|
||||
"reconcile_interval", g.config.ReconcileInterval,
|
||||
)
|
||||
|
||||
g.wg.Add(1)
|
||||
go g.reconcileLoop()
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the GC worker.
|
||||
func (g *ResourceGC) Stop() {
|
||||
log := logging.FromContext(g.ctx).WithWorker("resource-gc")
|
||||
log.Info("resource GC stopping")
|
||||
g.cancel()
|
||||
g.wg.Wait()
|
||||
log.Info("resource GC stopped")
|
||||
}
|
||||
|
||||
func (g *ResourceGC) reconcileLoop() {
|
||||
defer g.wg.Done()
|
||||
|
||||
// Run immediately on start
|
||||
g.runReconciliation()
|
||||
|
||||
ticker := time.NewTicker(g.config.ReconcileInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-g.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
g.runReconciliation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *ResourceGC) runReconciliation() {
|
||||
ctx, cancel := context.WithTimeout(g.ctx, TimeoutWorkExecution)
|
||||
defer cancel()
|
||||
|
||||
log := logging.FromContext(ctx).WithWorker("resource-gc")
|
||||
|
||||
labels, err := g.reconciler.ListProjectLabels(ctx)
|
||||
if err != nil {
|
||||
log.Error("failed to list project labels", logging.FieldError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var deleted, skipped, errCount int
|
||||
var firstErr error
|
||||
for _, project := range labels {
|
||||
exists, err := g.checker.projectExists(ctx, project)
|
||||
if err != nil {
|
||||
log.Error("failed to check project existence",
|
||||
logging.FieldProjectID, project,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
// Project not in DB — check resource age before deleting
|
||||
oldest, found, err := g.reconciler.GetOldestResourceTime(ctx, project)
|
||||
if err != nil {
|
||||
log.Error("failed to get resource age",
|
||||
logging.FieldProjectID, project,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
age := time.Since(oldest)
|
||||
if age < g.config.MinAge {
|
||||
log.Info("skipping young orphan",
|
||||
logging.FieldProjectID, project,
|
||||
"age", age,
|
||||
"min_age", g.config.MinAge,
|
||||
)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info("deleting orphaned resources",
|
||||
logging.FieldProjectID, project,
|
||||
"age", age,
|
||||
)
|
||||
if err := g.reconciler.UndeployAll(ctx, project); err != nil {
|
||||
log.Error("failed to delete orphaned resources",
|
||||
logging.FieldProjectID, project,
|
||||
logging.FieldError, err,
|
||||
)
|
||||
errCount++
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
if deleted > 0 || skipped > 0 || errCount > 0 {
|
||||
log.Info("reconciliation complete",
|
||||
"deleted", deleted,
|
||||
"skipped", skipped,
|
||||
"errors", errCount,
|
||||
"total_labels", len(labels),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
189
internal/worker/resource_gc_test.go
Normal file
189
internal/worker/resource_gc_test.go
Normal file
@ -0,0 +1,189 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
|
||||
"github.com/orchard9/rdev/internal/adapter/deployer"
|
||||
)
|
||||
|
||||
// mockProjectChecker simulates DB project existence checks.
|
||||
type mockProjectChecker struct {
|
||||
projects map[string]bool
|
||||
}
|
||||
|
||||
func (m *mockProjectChecker) projectExists(_ context.Context, projectID string) (bool, error) {
|
||||
return m.projects[projectID], nil
|
||||
}
|
||||
|
||||
func newTestDeployerForGC(client *fake.Clientset) *deployer.Deployer {
|
||||
return deployer.NewDeployer(client, deployer.Config{Namespace: "projects"})
|
||||
}
|
||||
|
||||
func createDeployment(t *testing.T, client *fake.Clientset, name, project string, createdAt time.Time) {
|
||||
t.Helper()
|
||||
replicas := int32(1)
|
||||
_, err := client.AppsV1().Deployments("projects").Create(context.Background(), &appsv1.Deployment{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: name,
|
||||
Namespace: "projects",
|
||||
Labels: map[string]string{"app": name, "project": project},
|
||||
CreationTimestamp: metav1.NewTime(createdAt),
|
||||
},
|
||||
Spec: appsv1.DeploymentSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}},
|
||||
Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: name, Image: "test:latest"}}},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create deployment %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGC_DeletesOrphans(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployerForGC(client)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create resources for two projects, both old enough
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
createDeployment(t, client, "orphan-proj", "orphan-proj", oldTime)
|
||||
createDeployment(t, client, "valid-proj", "valid-proj", oldTime)
|
||||
|
||||
checker := &mockProjectChecker{
|
||||
projects: map[string]bool{
|
||||
"valid-proj": true, // exists in DB
|
||||
// orphan-proj is NOT in DB
|
||||
},
|
||||
}
|
||||
|
||||
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
|
||||
MinAge: 1 * time.Hour,
|
||||
ReconcileInterval: 1 * time.Hour, // won't tick during test
|
||||
})
|
||||
|
||||
gc.runReconciliation()
|
||||
|
||||
// Orphan should be deleted
|
||||
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(deps.Items) != 1 {
|
||||
t.Fatalf("expected 1 deployment, got %d", len(deps.Items))
|
||||
}
|
||||
if deps.Items[0].Name != "valid-proj" {
|
||||
t.Errorf("remaining deployment = %q, want %q", deps.Items[0].Name, "valid-proj")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGC_SkipsValidProjects(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployerForGC(client)
|
||||
ctx := context.Background()
|
||||
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
createDeployment(t, client, "proj-a", "proj-a", oldTime)
|
||||
createDeployment(t, client, "proj-b", "proj-b", oldTime)
|
||||
|
||||
checker := &mockProjectChecker{
|
||||
projects: map[string]bool{
|
||||
"proj-a": true,
|
||||
"proj-b": true,
|
||||
},
|
||||
}
|
||||
|
||||
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
|
||||
MinAge: 1 * time.Hour,
|
||||
ReconcileInterval: 1 * time.Hour,
|
||||
})
|
||||
|
||||
gc.runReconciliation()
|
||||
|
||||
// Both should remain
|
||||
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(deps.Items) != 2 {
|
||||
t.Errorf("expected 2 deployments, got %d", len(deps.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGC_SkipsYoungOrphans(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployerForGC(client)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a recent deployment (younger than minAge)
|
||||
recentTime := time.Now().Add(-5 * time.Minute)
|
||||
createDeployment(t, client, "new-orphan", "new-orphan", recentTime)
|
||||
|
||||
checker := &mockProjectChecker{
|
||||
projects: map[string]bool{}, // not in DB
|
||||
}
|
||||
|
||||
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
|
||||
MinAge: 1 * time.Hour,
|
||||
ReconcileInterval: 1 * time.Hour,
|
||||
})
|
||||
|
||||
gc.runReconciliation()
|
||||
|
||||
// Should NOT be deleted (too young)
|
||||
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(deps.Items) != 1 {
|
||||
t.Errorf("expected 1 deployment (young orphan preserved), got %d", len(deps.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGC_DeletesMonorepoComponents(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployerForGC(client)
|
||||
ctx := context.Background()
|
||||
|
||||
oldTime := time.Now().Add(-2 * time.Hour)
|
||||
createDeployment(t, client, "myapp", "myapp", oldTime)
|
||||
createDeployment(t, client, "myapp-studio-ui", "myapp", oldTime)
|
||||
createDeployment(t, client, "myapp-studio-api", "myapp", oldTime)
|
||||
|
||||
checker := &mockProjectChecker{
|
||||
projects: map[string]bool{}, // not in DB
|
||||
}
|
||||
|
||||
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
|
||||
MinAge: 1 * time.Hour,
|
||||
ReconcileInterval: 1 * time.Hour,
|
||||
})
|
||||
|
||||
gc.runReconciliation()
|
||||
|
||||
// All 3 should be deleted
|
||||
deps, _ := client.AppsV1().Deployments("projects").List(ctx, metav1.ListOptions{})
|
||||
if len(deps.Items) != 0 {
|
||||
t.Errorf("expected 0 deployments, got %d", len(deps.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGC_StartStop(t *testing.T) {
|
||||
client := fake.NewSimpleClientset()
|
||||
d := newTestDeployerForGC(client)
|
||||
|
||||
checker := &mockProjectChecker{projects: map[string]bool{}}
|
||||
|
||||
gc := newResourceGCWithChecker(d, checker, &ResourceGCConfig{
|
||||
MinAge: 1 * time.Hour,
|
||||
ReconcileInterval: 24 * time.Hour, // long interval so ticker won't fire
|
||||
})
|
||||
|
||||
gc.Start()
|
||||
|
||||
// Give the goroutine time to run initial reconciliation
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
gc.Stop() // should not hang
|
||||
}
|
||||
117
internal/worker/session_cleanup.go
Normal file
117
internal/worker/session_cleanup.go
Normal file
@ -0,0 +1,117 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/logging"
|
||||
)
|
||||
|
||||
// SessionCleanupService defines the interface for session cleanup operations.
|
||||
type SessionCleanupService interface {
|
||||
CleanupExpired(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// SessionCleanup runs periodic cleanup of expired sessions.
|
||||
// Expired sessions have their preview resources torn down and checkout tokens revoked.
|
||||
type SessionCleanup struct {
|
||||
service SessionCleanupService
|
||||
cleanupInterval time.Duration
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// SessionCleanupConfig holds configuration for session cleanup.
|
||||
type SessionCleanupConfig struct {
|
||||
// CleanupInterval is how often to run cleanup.
|
||||
// Default: 5 minutes.
|
||||
CleanupInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultSessionCleanupConfig returns sensible defaults.
|
||||
func DefaultSessionCleanupConfig() *SessionCleanupConfig {
|
||||
return &SessionCleanupConfig{
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSessionCleanup creates a new session cleanup worker.
|
||||
func NewSessionCleanup(service SessionCleanupService, cfg *SessionCleanupConfig) *SessionCleanup {
|
||||
if cfg == nil {
|
||||
cfg = DefaultSessionCleanupConfig()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &SessionCleanup{
|
||||
service: service,
|
||||
cleanupInterval: cfg.CleanupInterval,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the cleanup loop.
|
||||
func (c *SessionCleanup) Start() {
|
||||
log := logging.FromContext(c.ctx).WithWorker("session-cleanup")
|
||||
log.Info("session cleanup started",
|
||||
"cleanup_interval", c.cleanupInterval,
|
||||
)
|
||||
|
||||
c.wg.Add(1)
|
||||
go c.cleanupLoop()
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the cleanup worker.
|
||||
func (c *SessionCleanup) Stop() {
|
||||
log := logging.FromContext(c.ctx).WithWorker("session-cleanup")
|
||||
log.Info("session cleanup stopping")
|
||||
c.cancel()
|
||||
c.wg.Wait()
|
||||
log.Info("session cleanup stopped")
|
||||
}
|
||||
|
||||
// cleanupLoop runs periodic cleanup.
|
||||
func (c *SessionCleanup) cleanupLoop() {
|
||||
defer c.wg.Done()
|
||||
|
||||
// Run immediately on start.
|
||||
c.runCleanup()
|
||||
|
||||
ticker := time.NewTicker(c.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.runCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runCleanup tears down expired sessions.
|
||||
func (c *SessionCleanup) runCleanup() {
|
||||
ctx, cancel := context.WithTimeout(c.ctx, TimeoutMaintenance)
|
||||
defer cancel()
|
||||
|
||||
log := logging.FromContext(ctx).WithWorker("session-cleanup")
|
||||
|
||||
cleaned, err := c.service.CleanupExpired(ctx)
|
||||
if err != nil {
|
||||
log.Error("failed to cleanup expired sessions",
|
||||
logging.FieldError, err,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if cleaned > 0 {
|
||||
log.Info("cleaned up expired sessions",
|
||||
"count", cleaned,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user