diff --git a/ROADMAP2.md b/ROADMAP2.md index 018214c..a4aa3cb 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -520,8 +520,9 @@ and wired refs, one click or one CLI call. - [x] **Gateway traffic path** — `/g/:slug/*` mounted → api_key auth → rpm + monthly quota → `gateway/proxy.go` → usage_counters / metrics_timeseries / incident_events. -- [ ] **Preview environments runtime** — real build+deploy+subdomain+TTL - (bookkeeping only now). Pairs with clone + multi-domain. +- [x] **Preview environments runtime** — previews are real service clones: + branch override, queued deployment, node-domain hostnames, TTL sweeper, + promote = redeploy of the source at the preview branch. - [ ] **Environments** — env switcher, per-env vars/domains, promote. - [ ] **sqlc port** — ~140 raw `database/sql` sites → `sqlc/queries/`. - [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/ diff --git a/app/backend/internal/api/preview_environments.go b/app/backend/internal/api/preview_environments.go index 8d4e325..9924b9d 100644 --- a/app/backend/internal/api/preview_environments.go +++ b/app/backend/internal/api/preview_environments.go @@ -2,7 +2,12 @@ package api import ( "containr/internal/database" + "containr/internal/database/sqlcdb" + "containr/internal/deployment" + "containr/internal/deployqueue" + "context" "database/sql" + "encoding/json" "fmt" "net/http" "strings" @@ -26,6 +31,10 @@ type PreviewEnvironment struct { CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + // PreviewServiceID is the cloned service this preview actually deploys; + // its status is the source of truth while the preview is active. + PreviewServiceID *uuid.UUID `json:"preview_service_id,omitempty" db:"preview_service_id"` + // Related data Service *Service `json:"service,omitempty"` DeploymentID *uuid.UUID `json:"deployment_id,omitempty"` @@ -78,12 +87,12 @@ func handleGetPreviewEnvironments(c *gin.Context) { // Get preview environments for the project with service info rows, err := db.(*database.DB).Query( - `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, + `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number, pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at, s.id as service_id, s.name as service_name, s.type as service_type FROM preview_environments pe LEFT JOIN services s ON pe.service_id = s.id - WHERE pe.project_id = $1 + WHERE pe.project_id = $1 ORDER BY pe.created_at DESC`, projectID, ) @@ -101,7 +110,7 @@ func handleGetPreviewEnvironments(c *gin.Context) { var serviceType sql.NullString err := rows.Scan( - &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, + &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, &serviceID, &serviceName, &serviceType, ) @@ -215,87 +224,111 @@ func handleCreatePreviewEnvironment(c *gin.Context) { ttlHours = 24 // Default 24 hours } - // Create preview environment + // A preview is a real service: clone the source, pin it to the + // requested branch, swap its domains for a preview hostname, and run a + // normal deployment through the queue. Expiry sweeps the clone away. + envName := generatePreviewEnvironmentName(req.BranchName) + cloneName := previewServiceName(service.Name, req.BranchName) + cloneID, err := cloneServiceRow(db.(*database.DB), req.ServiceID, req.ProjectID, cloneName, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service for preview"}) + return + } + + // The clone's branch override is what makes it a preview — everything + // else (env vars, volumes, builder, resources) stays identical. + if _, err := db.(*database.DB).Exec( + `UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2`, + req.BranchName, cloneID, + ); err != nil { + _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview service"}) + return + } + // Cloned service_domains would collide with the parent's Traefik + // router — previews get their own hostname or none. + if _, err := db.(*database.DB).Exec( + `DELETE FROM service_domains WHERE service_id = $1`, cloneID, + ); err != nil { + _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to configure preview domains"}) + return + } + previewHost := resolvePreviewHost(db.(*database.DB), cloneID, envName) + if previewHost != "" { + _, _ = db.(*database.DB).Exec( + `INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls')`, + cloneID, previewHost, + ) + } + + expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour) env := PreviewEnvironment{ ID: uuid.New(), ProjectID: req.ProjectID, ServiceID: req.ServiceID, BranchName: req.BranchName, PRNumber: req.PRNumber, - Environment: generatePreviewEnvironmentName(req.BranchName), + Environment: envName, Status: "building", - ExpiresAt: &[]time.Time{time.Now().Add(time.Duration(ttlHours) * time.Hour)}[0], + ExpiresAt: &expiresAt, CreatedAt: time.Now(), UpdatedAt: time.Now(), } + if previewHost != "" { + env.URL = "https://" + previewHost + } - // Insert preview environment into database _, err = db.(*database.DB).Exec( - `INSERT INTO preview_environments - (id, project_id, service_id, branch_name, pr_number, environment, + `INSERT INTO preview_environments + (id, project_id, service_id, preview_service_id, branch_name, pr_number, environment, status, url, expires_at, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, - env.ID, env.ProjectID, env.ServiceID, env.BranchName, env.PRNumber, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + env.ID, env.ProjectID, env.ServiceID, cloneID, env.BranchName, env.PRNumber, env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt, ) - if err != nil { + _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"}) return } deploymentID := uuid.New() - sanitizedBranch := strings.ReplaceAll(req.BranchName, "/", "-") - sanitizedBranch = strings.ReplaceAll(sanitizedBranch, "_", "-") - version := fmt.Sprintf("preview-%s-%d", sanitizedBranch, time.Now().Unix()) - startedAt := time.Now().UTC() - completedAt := startedAt - - _, deployErr := db.(*database.DB).Exec( + _, err = db.(*database.DB).Exec( `INSERT INTO deployments - (id, service_id, version, commit_hash, status, started_at, completed_at, deployment_log, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())`, - deploymentID, - req.ServiceID, - version, - req.BranchName, - "running", - startedAt, - completedAt, - fmt.Sprintf("Preview environment %s activated for branch %s", env.Environment, req.BranchName), + (id, service_id, version, commit_hash, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`, + deploymentID, cloneID, "preview-"+env.Environment, req.BranchName, ) - if deployErr != nil { - _, _ = db.(*database.DB).Exec( - `UPDATE preview_environments - SET status = 'failed', updated_at = NOW() - WHERE id = $1`, - env.ID, - ) + if err != nil { + _, _ = db.(*database.DB).Exec(`DELETE FROM preview_environments WHERE id = $1`, env.ID) + _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"}) return } - - if strings.TrimSpace(env.URL) == "" { - env.URL = fmt.Sprintf("https://%s.preview.containr.local", env.Environment) - } - env.Status = "running" - env.UpdatedAt = time.Now().UTC() env.DeploymentID = &deploymentID - _, err = db.(*database.DB).Exec( - `UPDATE preview_environments - SET status = $1, url = $2, updated_at = $3 - WHERE id = $4`, - env.Status, - env.URL, - env.UpdatedAt, - env.ID, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to finalize preview environment"}) - return + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + if engine, ok := engineValue.(*deployment.DeploymentEngine); ok { + cloneService, loadErr := loadServiceForDeploy(db.(*database.DB), cloneID) + if loadErr == nil { + engineInstance := engine + d := DeploymentModel{ID: deploymentID, ServiceID: cloneID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()} + getDeployQueue(c).Enqueue(cloneID, deployqueue.Job{ + DeploymentID: deploymentID, + Run: func(jctx context.Context) { + runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, cloneService, CreateDeploymentRequest{Branch: req.BranchName, Trigger: "preview"}, userID.(string)) + }, + }) + } + } } + LogAuditWithRequest(c, "service", req.ServiceID.String(), "preview.create", map[string]interface{}{ + "preview_environment_id": env.ID.String(), + "preview_service_id": cloneID.String(), + "branch": req.BranchName, + }) c.JSON(http.StatusCreated, gin.H{"preview_environment": env}) } @@ -321,7 +354,7 @@ func handleGetPreviewEnvironment(c *gin.Context) { var serviceName sql.NullString var serviceType sql.NullString err = db.(*database.DB).QueryRow( - `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, + `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.pr_number, pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at, s.id as service_id, s.name as service_name, s.type as service_type FROM preview_environments pe @@ -331,7 +364,7 @@ func handleGetPreviewEnvironment(c *gin.Context) { OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2))`, envID, userID, contextIsAdmin(c), ).Scan( - &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber, + &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.PRNumber, &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, &serviceID, &serviceName, &serviceType, ) @@ -480,22 +513,19 @@ func handleDeletePreviewEnvironment(c *gin.Context) { return } - cleanupID := uuid.New() - cleanupStartedAt := time.Now().UTC() - cleanupCompletedAt := cleanupStartedAt - - _, _ = db.(*database.DB).Exec( - `INSERT INTO deployments - (id, service_id, version, status, started_at, completed_at, deployment_log, created_at, updated_at) - VALUES ($1, (SELECT service_id FROM preview_environments WHERE id = $2), $3, $4, $5, $6, $7, NOW(), NOW())`, - cleanupID, - envID, - "preview-cleanup", - "rolled_back", - cleanupStartedAt, - cleanupCompletedAt, - "Preview environment resources cleaned up", - ) + // Tear down the clone's runtime and row — the preview is the clone. + var cloneID *uuid.UUID + _ = db.(*database.DB).QueryRow( + `SELECT preview_service_id FROM preview_environments WHERE id = $1`, envID, + ).Scan(&cloneID) + if cloneID != nil { + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + if engine, ok := engineValue.(*deployment.DeploymentEngine); ok { + removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *cloneID) + } + } + _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *cloneID) + } // Delete preview environment _, err = db.(*database.DB).Exec( @@ -542,13 +572,13 @@ func handlePromotePreviewEnvironment(c *gin.Context) { // Get preview environment details var env PreviewEnvironment err = db.(*database.DB).QueryRow( - `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment, pe.status + `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment, pe.status FROM preview_environments pe JOIN projects p ON pe.project_id = p.id WHERE pe.id = $1 AND (p.owner_id = $2 OR $3::bool)`, envID, userID, contextIsAdmin(c), ).Scan( - &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, &env.Status, + &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment, &env.Status, ) if err != nil { @@ -562,38 +592,53 @@ func handlePromotePreviewEnvironment(c *gin.Context) { return } + // Promote is a real redeploy: the source service builds the preview's + // branch onto its own deployment track. deploymentID := uuid.New() promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix()) _, err = db.(*database.DB).Exec( - `INSERT INTO deployments (id, service_id, version, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, NOW(), NOW())`, - deploymentID, env.ServiceID, promotionVersion, "pending", + `INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`, + deploymentID, env.ServiceID, promotionVersion, env.BranchName, ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"}) return } - if _, err := db.(*database.DB).Exec( - `UPDATE services - SET environment = $1, updated_at = NOW() - WHERE id = $2`, - req.TargetEnvironment, env.ServiceID, - ); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service target environment"}) + sourceService, loadErr := loadServiceForDeploy(db.(*database.DB), env.ServiceID) + if loadErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load promotion target service"}) return } + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + if engine, ok := engineValue.(*deployment.DeploymentEngine); ok { + engineInstance := engine + d := DeploymentModel{ID: deploymentID, ServiceID: env.ServiceID, Status: "pending", CreatedAt: time.Now(), UpdatedAt: time.Now()} + getDeployQueue(c).Enqueue(env.ServiceID, deployqueue.Job{ + DeploymentID: deploymentID, + Run: func(jctx context.Context) { + runDeploymentAndSync(jctx, db.(*database.DB), engineInstance, &d, sourceService, CreateDeploymentRequest{Branch: env.BranchName, Trigger: "promote"}, userID.(string)) + }, + }) + } + } - previewStatus := "stopped" - if req.CreateBackup { - previewStatus = "expired" + // Promotion retires the preview — the clone's containers and row go. + if env.PreviewServiceID != nil { + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + if engine, ok := engineValue.(*deployment.DeploymentEngine); ok { + removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID) + } + } + _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID) } if _, err := db.(*database.DB).Exec( `UPDATE preview_environments - SET status = $1, updated_at = NOW() - WHERE id = $2`, - previewStatus, env.ID, + SET status = 'stopped', updated_at = NOW() + WHERE id = $1`, + env.ID, ); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"}) return @@ -608,7 +653,7 @@ func handlePromotePreviewEnvironment(c *gin.Context) { "create_backup": req.CreateBackup, "deployment_id": deploymentID, "status": "queued", - "preview_status": previewStatus, + "preview_status": "stopped", }, }) } @@ -639,7 +684,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { // Find expired preview environments for user's projects rows, err := db.(*database.DB).Query( - `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.environment + `SELECT pe.id, pe.project_id, pe.service_id, pe.preview_service_id, pe.branch_name, pe.environment FROM preview_environments pe JOIN projects p ON pe.project_id = p.id WHERE (p.owner_id = $1 OR $2::bool) AND pe.expires_at < NOW() AND pe.status != 'expired'`, @@ -655,7 +700,7 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { for rows.Next() { var env PreviewEnvironment err := rows.Scan( - &env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.Environment, + &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment, ) if err != nil { continue @@ -663,10 +708,9 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { expiredEnvs = append(expiredEnvs, env) } - // Mark expired environments as expired and trigger cleanup + // Mark expired environments as expired and tear down their clones cleanupCount := 0 for _, env := range expiredEnvs { - // Update status to expired _, err := db.(*database.DB).Exec( "UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1", env.ID, @@ -674,7 +718,14 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { if err != nil { continue } - + if env.PreviewServiceID != nil { + if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { + if engine, ok := engineValue.(*deployment.DeploymentEngine); ok { + removeServiceRuntime(c.Request.Context(), db.(*database.DB), engine, *env.PreviewServiceID) + } + } + _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID) + } cleanupCount++ } @@ -684,3 +735,157 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { "expired_environments": expiredEnvs, }) } + +// previewServiceName builds the clone's service name — deterministic enough +// to spot in lists, short enough for dnsLabel-derived hostnames. +func previewServiceName(serviceName, branch string) string { + label := dnsLabel(serviceName + "-" + branch) + if label == "" { + return "preview-" + uuid.NewString()[:8] + } + return label + "-preview" +} + +// resolvePreviewHost picks a preview hostname under the clone's node default +// domain — same source as serviceAutoDomains. Returns "" when no node +// carries a default domain; the preview still runs, reachable internally. +func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) string { + label := dnsLabel(envName) + if label == "" { + return "" + } + var nodeID sql.NullString + var spread bool + if err := db.QueryRow( + `SELECT node_id, COALESCE(spread, false) FROM services WHERE id = $1`, cloneID, + ).Scan(&nodeID, &spread); err != nil { + return "" + } + q := sqlcdb.New(db.DB) + ctx := context.Background() + var base string + switch { + case nodeID.Valid && nodeID.String != "": + if a, err := q.GetAgent(ctx, nodeID.String); err == nil { + base = a.DefaultDomain + } + case spread: + if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage(tagsJSON(servicePlacementTags(db, cloneID)))); err == nil && len(bases) > 0 { + base = bases[0] + } + default: + // Local preview: borrow the first node default domain if any — + // still better than no hostname for a Traefik-fronted local host. + if bases, err := q.ListSchedulableAgentDomains(ctx, json.RawMessage("[]")); err == nil && len(bases) > 0 { + base = bases[0] + } + } + if base == "" { + return "" + } + return label + "." + base +} + +// loadServiceForDeploy hydrates the Service fields runDeploymentAndSync +// needs — mirrors the loader in handleDeployService. +func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) { + var service Service + err := db.QueryRow( + `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, + s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + COALESCE(s.replicas, 1), COALESCE(s.port, 0), + COALESCE(s.domain, ''), COALESCE(s.healthcheck_path, ''), + COALESCE(s.restart_policy, 'unless-stopped'), + COALESCE(s.builder, 'auto'), COALESCE(s.cpu_reserve, ''), + COALESCE(s.memory_reserve, ''), COALESCE(s.static_build_cmd, ''), + COALESCE(s.static_dir, ''), + s.created_at, s.updated_at + FROM services s + WHERE s.id = $1`, + serviceID, + ).Scan( + &service.ID, &service.ProjectID, &service.Name, &service.Type, &service.Status, + &service.Image, &service.Command, &service.Environment, &service.GitRepo, + &service.GitBranch, &service.BuildPath, &service.CPU, &service.Memory, + &service.Replicas, &service.Port, &service.Domain, &service.HealthCheckPath, + &service.RestartPolicy, &service.Builder, &service.CPUReserve, + &service.MemoryReserve, &service.StaticBuildCmd, &service.StaticDir, + &service.CreatedAt, &service.UpdatedAt, + ) + return service, err +} + +// StartPreviewSweeper expires preview environments past their TTL — tears +// down each clone's runtime and row, marks the preview expired. +func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) { + if db == nil || engine == nil { + return + } + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sweepExpiredPreviews(ctx, db, engine) + syncPreviewStatuses(ctx, db) + } + } + }() +} + +// sweepExpiredPreviews retires previews whose TTL elapsed. +func sweepExpiredPreviews(ctx context.Context, db *database.DB, engine *deployment.DeploymentEngine) { + rows, err := db.QueryContext(ctx, + `SELECT id, preview_service_id FROM preview_environments + WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped')`) + if err != nil { + return + } + var ids []uuid.UUID + var clones []uuid.UUID + for rows.Next() { + var id uuid.UUID + var cloneID *uuid.UUID + if err := rows.Scan(&id, &cloneID); err == nil { + ids = append(ids, id) + if cloneID != nil { + clones = append(clones, *cloneID) + } + } + } + rows.Close() + for _, cloneID := range clones { + removeServiceRuntime(ctx, db, engine, cloneID) + _, _ = db.ExecContext(ctx, `DELETE FROM services WHERE id = $1`, cloneID) + } + for _, id := range ids { + _, _ = db.ExecContext(ctx, + `UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1`, id) + } +} + +// syncPreviewStatuses folds the clone's live service status into the +// preview row so list/detail endpoints reflect the real deploy state. +func syncPreviewStatuses(ctx context.Context, db *database.DB) { + _, _ = db.ExecContext(ctx, ` + UPDATE preview_environments pe + SET status = derived.st, updated_at = NOW() + FROM ( + SELECT pe2.id, + CASE + WHEN s.status IN ('running', 'deployed') THEN 'running' + WHEN s.status IN ('failed', 'error') THEN 'failed' + WHEN s.status IN ('building', 'deploying', 'queued', 'pending', 'cloning') THEN 'building' + ELSE pe2.status + END AS st + FROM preview_environments pe2 + JOIN services s ON pe2.preview_service_id = s.id + ) derived + WHERE pe.id = derived.id + AND pe.status IN ('building', 'running', 'failed') + AND pe.status <> derived.st + `) +} diff --git a/app/backend/internal/api/preview_environments_test.go b/app/backend/internal/api/preview_environments_test.go new file mode 100644 index 0000000..e2b09ba --- /dev/null +++ b/app/backend/internal/api/preview_environments_test.go @@ -0,0 +1,30 @@ +package api + +import ( + "strings" + "testing" +) + +func TestPreviewServiceName(t *testing.T) { + cases := []struct { + svc, branch string + want string + }{ + {"whoami-web", "feature-x", "whoami-web-feature-x-preview"}, + {"My App", "feat/JARV-123_foo", "my-app-feat-jarv-123-foo-preview"}, + {"svc", "", "svc-preview"}, + } + for _, tc := range cases { + got := previewServiceName(tc.svc, tc.branch) + if got != tc.want { + t.Fatalf("previewServiceName(%q,%q) = %q, want %q", tc.svc, tc.branch, got, tc.want) + } + if len(got) > 63 { + t.Fatalf("name exceeds DNS label limit: %q", got) + } + } + // A name that sanitizes to nothing must still be usable. + if got := previewServiceName("!!!", "%%%"); !strings.HasPrefix(got, "preview-") { + t.Fatalf("expected fallback name, got %q", got) + } +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index e1f119f..b875b79 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -111,6 +111,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Sleep sweeper — scales idle services to zero and prunes wake containers. StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port) + StartPreviewSweeper(context.Background(), db, deploymentEngine) // Audit retention — prunes rows past AUDIT_RETENTION_DAYS (default 90). StartAuditRetention(context.Background(), db) diff --git a/app/backend/internal/api/service_ops.go b/app/backend/internal/api/service_ops.go index 3a720fb..4819e0f 100644 --- a/app/backend/internal/api/service_ops.go +++ b/app/backend/internal/api/service_ops.go @@ -67,22 +67,45 @@ func handleCloneService(c *gin.Context) { if req.Environment != "" { environment = req.Environment } - if environment == "" { - environment = "production" - } - environmentID, err := getProjectEnvironmentID(db.(*database.DB), targetProjectID, environment) + + newID, err := cloneServiceRow(db.(*database.DB), source.ID, targetProjectID, name, environment) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve target environment"}) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"}) return } - maintenance, basicAuth := serviceAccess(db.(*database.DB), source.ID) + LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name}) + c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"}) +} + +// cloneServiceRow copies a service's full configuration — source, build +// settings, volumes, access gates, domains, and variables — under a new id +// in the target project. The clone starts stopped. An empty environment +// inherits the source's. Used by the clone endpoint and preview +// environments, which additionally override git_branch and domains. +func cloneServiceRow(db *database.DB, sourceID uuid.UUID, targetProjectID uuid.UUID, name, environment string) (uuid.UUID, error) { + var sourceEnv string + if err := db.QueryRow(`SELECT COALESCE(environment, '') FROM services WHERE id = $1`, sourceID).Scan(&sourceEnv); err != nil { + return uuid.Nil, err + } + if environment == "" { + environment = sourceEnv + } + if environment == "" { + environment = "production" + } + environmentID, err := getProjectEnvironmentID(db, targetProjectID, environment) + if err != nil { + return uuid.Nil, err + } + + maintenance, basicAuth := serviceAccess(db, sourceID) newID := uuid.New() now := time.Now() // string() so pq sends text — []byte would go as bytea and fail ::jsonb. - volumesJSON := string(loadServiceVolumesJSON(db.(*database.DB), source.ID)) + volumesJSON := string(loadServiceVolumesJSON(db, sourceID)) - _, err = db.(*database.DB).Exec( + _, err = db.Exec( `INSERT INTO services (id, project_id, name, environment_id, service_type, source_type, source_url, image_name, build_command, start_command, type, status, image, command, environment, @@ -98,35 +121,30 @@ func handleCloneService(c *gin.Context) { $9, $9 FROM services WHERE id = $10`, newID, targetProjectID, name, environmentID, environment, volumesJSON, - maintenance, basicAuth, now, source.ID, + maintenance, basicAuth, now, sourceID, ) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service"}) - return + return uuid.Nil, err } - if _, err := db.(*database.DB).Exec( + if _, err := db.Exec( `INSERT INTO service_domains (service_id, domain, is_default, cert_type) SELECT $1, domain, is_default, cert_type FROM service_domains WHERE service_id = $2`, - newID, source.ID, + newID, sourceID, ); err != nil { - _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service domains"}) - return + _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID) + return uuid.Nil, err } // Variable values are copied as stored — ciphertext stays ciphertext. - if _, err := db.(*database.DB).Exec( + if _, err := db.Exec( `INSERT INTO environment_variables (id, service_id, key, value, is_secret) SELECT gen_random_uuid(), $1, key, value, is_secret FROM environment_variables WHERE service_id = $2`, - newID, source.ID, + newID, sourceID, ); err != nil { - _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, newID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to clone service variables"}) - return + _, _ = db.Exec(`DELETE FROM services WHERE id = $1`, newID) + return uuid.Nil, err } - - LogAuditWithRequest(c, "service", source.ID.String(), "service.clone", map[string]interface{}{"cloned_to": newID.String(), "name": name}) - c.JSON(http.StatusCreated, gin.H{"service_id": newID, "name": name, "message": "Service cloned"}) + return newID, nil } type MoveServiceRequest struct { diff --git a/app/backend/migrations_goose/20261115000000_preview_environments_runtime.sql b/app/backend/migrations_goose/20261115000000_preview_environments_runtime.sql new file mode 100644 index 0000000..ac3be81 --- /dev/null +++ b/app/backend/migrations_goose/20261115000000_preview_environments_runtime.sql @@ -0,0 +1,11 @@ +-- +goose Up +ALTER TABLE public.preview_environments + ADD COLUMN IF NOT EXISTS preview_service_id uuid REFERENCES public.services(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_preview_environments_expiry + ON public.preview_environments (expires_at) + WHERE status NOT IN ('expired', 'stopped'); + +-- +goose Down +DROP INDEX IF EXISTS public.idx_preview_environments_expiry; +ALTER TABLE public.preview_environments DROP COLUMN IF EXISTS preview_service_id; diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 677823c..3279427 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -12495,6 +12495,11 @@ export interface components { project_id?: string; /** Format: uuid */ service_id?: string; + /** + * Format: uuid + * @description The cloned service the preview actually deploys; its live status drives the preview status. + */ + preview_service_id?: string; branch_name?: string; pr_number?: number; /** @description Generated name, e.g. preview-{branch}-{ts} */ diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2e90cbe..b395369 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -9564,6 +9564,10 @@ components: service_id: type: string format: uuid + preview_service_id: + type: string + format: uuid + description: The cloned service the preview actually deploys; its live status drives the preview status. branch_name: type: string pr_number: