diff --git a/app/backend/internal/api/domains_test.go b/app/backend/internal/api/domains_test.go index d191342..7868366 100644 --- a/app/backend/internal/api/domains_test.go +++ b/app/backend/internal/api/domains_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "strings" + "time" "testing" "containr/internal/database" @@ -223,3 +224,117 @@ func TestCloneServiceRowLive(t *testing.T) { t.Fatalf("vars not cloned: %v %q", err, varVal) } } + +// Live preview-environment test: generated queries must preserve the +// access-check join, the status-sync CASE, and the sweep listing. +func TestPreviewEnvironmentsLiveInvariants(t *testing.T) { + dsn := testDSNString(t) + db, err := database.NewConnection(dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer db.Close() + ctx := context.Background() + q := sqlcdb.New(db.DB) + + userID := uuid.New() + projectID := uuid.New() + envID := uuid.New() + srcID := uuid.New() + cloneID := uuid.New() + previewID := uuid.New() + suffix := strings.ReplaceAll(previewID.String()[:8], "-", "") + must := func(err error) { + t.Helper() + if err != nil { + t.Fatalf("setup: %v", err) + } + } + _, err = db.Exec(`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 'test')`, + userID, "prev-"+suffix+"@test.local") + must(err) + _, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`, + projectID, "prev-"+suffix, userID) + must(err) + _, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`, + envID, projectID) + must(err) + for _, p := range [][3]interface{}{{srcID, "src-" + suffix, "running"}, {cloneID, "clone-" + suffix, "running"}} { + _, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status) + VALUES ($1, $2, $3, $4, 'web', 'image', $5)`, + p[0], p[1], projectID, envID, p[2]) + must(err) + } + defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }() + + must(q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{ + ID: previewID, + ProjectID: projectID, + ServiceID: srcID, + PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true}, + BranchName: "feat-" + suffix, + Environment: "preview-feat-" + suffix, + Status: "building", + ExpiresAt: time.Now().Add(-time.Hour), // already past TTL → sweepable + })) + + // List + service join. + rows, err := q.ListPreviewEnvironmentsForProject(ctx, projectID) + if err != nil || len(rows) != 1 { + t.Fatalf("list: %v %d", err, len(rows)) + } + if rows[0].ID != previewID || !rows[0].SvcID.Valid || rows[0].ServiceName.String != "src-"+suffix { + t.Fatalf("list row: %+v", rows[0]) + } + + // Access-check join: owner reads; a stranger does not. + if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{ + ID: previewID, OwnerID: userID}); err != nil { + t.Fatalf("owner read: %v", err) + } + if _, err := q.GetPreviewEnvironment(ctx, sqlcdb.GetPreviewEnvironmentParams{ + ID: previewID, OwnerID: uuid.New()}); err != nil { + t.Fatalf("approved project should be public-read: %v", err) + } + + // Status sync folds clone's 'running' into the preview row. + if err := q.SyncPreviewStatuses(ctx); err != nil { + t.Fatalf("sync: %v", err) + } + var status string + if err := db.QueryRow(`SELECT status FROM preview_environments WHERE id = $1`, previewID).Scan(&status); err != nil { + t.Fatalf("read status: %v", err) + } + if status != "running" { + t.Fatalf("status after sync = %q, want running", status) + } + + // TTL sweep picks it up (expired at insert), marks expired. + sweep, err := q.ListSweepablePreviews(ctx) + if err != nil { + t.Fatalf("sweep list: %v", err) + } + found := false + for _, s := range sweep { + if s.ID == previewID && s.PreviewServiceID.UUID == cloneID { + found = true + } + } + if !found { + t.Fatalf("sweepable list missing preview: %+v", sweep) + } + must(q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{Status: "expired", ID: previewID})) + sweep, _ = q.ListSweepablePreviews(ctx) + for _, s := range sweep { + if s.ID == previewID { + t.Fatal("expired preview still sweepable") + } + } + + // Owner lookup and delete. + owner, err := q.GetPreviewEnvironmentOwner(ctx, previewID) + if err != nil || owner != userID { + t.Fatalf("owner: %v %v", owner, err) + } + must(q.DeletePreviewEnvironment(ctx, previewID)) +} diff --git a/app/backend/internal/api/preview_environments.go b/app/backend/internal/api/preview_environments.go index 9924b9d..e47b82b 100644 --- a/app/backend/internal/api/preview_environments.go +++ b/app/backend/internal/api/preview_environments.go @@ -40,6 +40,68 @@ type PreviewEnvironment struct { DeploymentID *uuid.UUID `json:"deployment_id,omitempty"` } +// previewRowShape is the common column set the generated preview rows +// carry — ListPreviewEnvironmentsForProjectRow and GetPreviewEnvironmentRow +// share it. +type previewRowShape struct { + ID uuid.UUID + ProjectID uuid.UUID + ServiceID uuid.UUID + PreviewServiceID uuid.NullUUID + BranchName string + PrNumber sql.NullInt32 + Environment string + Status string + Url sql.NullString + ExpiresAt time.Time + CreatedAt sql.NullTime + UpdatedAt sql.NullTime + SvcID uuid.NullUUID + ServiceName sql.NullString + ServiceType sql.NullString +} + +func previewFromRow(r previewRowShape) PreviewEnvironment { + env := PreviewEnvironment{ + ID: r.ID, + ProjectID: r.ProjectID, + ServiceID: r.ServiceID, + BranchName: r.BranchName, + Environment: r.Environment, + Status: r.Status, + URL: r.Url.String, + CreatedAt: r.CreatedAt.Time, + UpdatedAt: r.UpdatedAt.Time, + } + if r.PreviewServiceID.Valid { + id := r.PreviewServiceID.UUID + env.PreviewServiceID = &id + } + if r.PrNumber.Valid { + n := int(r.PrNumber.Int32) + env.PRNumber = &n + } + if !r.ExpiresAt.IsZero() { + t := r.ExpiresAt + env.ExpiresAt = &t + } + if r.SvcID.Valid { + env.Service = &Service{ + ID: r.SvcID.UUID, + Name: r.ServiceName.String, + Type: r.ServiceType.String, + } + } + return env +} + +func prNumberSQL(n *int) sql.NullInt32 { + if n == nil { + return sql.NullInt32{} + } + return sql.NullInt32{Int32: int32(*n), Valid: true} +} + // CreatePreviewEnvironmentRequest represents a request to create a preview environment type CreatePreviewEnvironmentRequest struct { ProjectID uuid.UUID `json:"project_id"` @@ -86,51 +148,21 @@ 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.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 - ORDER BY pe.created_at DESC`, - projectID, - ) + rows, err := sqlcdb.New(db.(*database.DB).DB).ListPreviewEnvironmentsForProject(c.Request.Context(), projectID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve preview environments"}) return } - defer rows.Close() - var environments []PreviewEnvironment - for rows.Next() { - var env PreviewEnvironment - var serviceID sql.NullString - var serviceName sql.NullString - var serviceType sql.NullString - - err := rows.Scan( - &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, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"}) - return - } - - if serviceID.Valid { - parsedServiceID, parseErr := uuid.Parse(serviceID.String) - if parseErr == nil { - env.Service = &Service{ - ID: parsedServiceID, - Name: serviceName.String, - Type: serviceType.String, - } - } - } - - environments = append(environments, env) + environments := make([]PreviewEnvironment, 0, len(rows)) + for _, r := range rows { + environments = append(environments, previewFromRow(previewRowShape{ + ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID, + PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName, + PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status, + Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType, + })) } c.JSON(http.StatusOK, gin.H{"preview_environments": environments}) @@ -171,48 +203,37 @@ func handleCreatePreviewEnvironment(c *gin.Context) { return } - // Check if project exists and user has access - var project Project - err = db.(*database.DB).QueryRow( - "SELECT id, name, owner_id FROM projects WHERE id = $1", - req.ProjectID, - ).Scan(&project.ID, &project.Name, &project.OwnerID) + ctx := c.Request.Context() + q := sqlcdb.New(db.(*database.DB).DB) + // Check if project exists and user has access + project, err := q.GetProjectBrief(ctx, req.ProjectID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } // Check if user owns the project - if project.OwnerID != userID.(string) && !contextIsAdmin(c) { + if project.OwnerID.String() != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } // Check if service exists and belongs to the project - var service Service - err = db.(*database.DB).QueryRow( - "SELECT id, name, COALESCE(type, service_type, '') FROM services WHERE id = $1 AND project_id = $2", - req.ServiceID, req.ProjectID, - ).Scan(&service.ID, &service.Name, &service.Type) - + service, err := q.GetServiceTypeBrief(ctx, sqlcdb.GetServiceTypeBriefParams{ + ID: req.ServiceID, ProjectID: req.ProjectID}) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found or doesn't belong to this project"}) return } // Check if preview environment already exists for this branch and service - var count int - err = db.(*database.DB).QueryRow( - "SELECT COUNT(*) FROM preview_environments WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped')", - req.ServiceID, req.BranchName, - ).Scan(&count) - + count, err := q.CountActivePreviewsForBranch(ctx, sqlcdb.CountActivePreviewsForBranchParams{ + ServiceID: req.ServiceID, BranchName: req.BranchName}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check existing preview environment"}) return } - if count > 0 { c.JSON(http.StatusConflict, gin.H{"error": "Preview environment already exists for this branch and service"}) return @@ -237,29 +258,23 @@ func handleCreatePreviewEnvironment(c *gin.Context) { // 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) + if err := q.UpdateServicePreviewBranch(ctx, sqlcdb.UpdateServicePreviewBranchParams{ + GitBranch: sql.NullString{String: req.BranchName, Valid: true}, ID: cloneID}); err != nil { + _ = q.DeleteServiceByID(ctx, 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) + if err := q.DeleteAllServiceDomains(ctx, cloneID); err != nil { + _ = q.DeleteServiceByID(ctx, 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, - ) + _ = q.CreatePreviewDomain(ctx, sqlcdb.CreatePreviewDomainParams{ + ServiceID: cloneID, Domain: previewHost}) } expiresAt := time.Now().Add(time.Duration(ttlHours) * time.Hour) @@ -279,30 +294,36 @@ func handleCreatePreviewEnvironment(c *gin.Context) { env.URL = "https://" + previewHost } - _, err = db.(*database.DB).Exec( - `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, $12)`, - env.ID, env.ProjectID, env.ServiceID, cloneID, env.BranchName, env.PRNumber, - env.Environment, env.Status, env.URL, env.ExpiresAt, env.CreatedAt, env.UpdatedAt, - ) + err = q.CreatePreviewEnvironment(ctx, sqlcdb.CreatePreviewEnvironmentParams{ + ID: env.ID, + ProjectID: env.ProjectID, + ServiceID: env.ServiceID, + PreviewServiceID: uuid.NullUUID{UUID: cloneID, Valid: true}, + BranchName: env.BranchName, + PrNumber: prNumberSQL(env.PRNumber), + Environment: env.Environment, + Status: env.Status, + Url: sql.NullString{String: env.URL, Valid: env.URL != ""}, + ExpiresAt: *env.ExpiresAt, + CreatedAt: sql.NullTime{Time: env.CreatedAt, Valid: true}, + UpdatedAt: sql.NullTime{Time: env.UpdatedAt, Valid: true}, + }) if err != nil { - _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, cloneID) + _ = q.DeleteServiceByID(ctx, cloneID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create preview environment"}) return } deploymentID := uuid.New() - _, err = db.(*database.DB).Exec( - `INSERT INTO deployments - (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, - ) + err = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{ + ID: deploymentID, + ServiceID: cloneID, + Version: "preview-" + env.Environment, + CommitHash: sql.NullString{String: req.BranchName, Valid: true}, + }) 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) + _ = q.DeletePreviewEnvironment(ctx, env.ID) + _ = q.DeleteServiceByID(ctx, cloneID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to provision preview deployment"}) return } @@ -349,43 +370,20 @@ func handleGetPreviewEnvironment(c *gin.Context) { // Public when the parent project is approved; otherwise owner/member/admin. userID := optionalUserUUID(c) - var env PreviewEnvironment - var serviceID sql.NullString - var serviceName sql.NullString - var serviceType sql.NullString - err = db.(*database.DB).QueryRow( - `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 - JOIN projects p ON pe.project_id = p.id - WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool - 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.PreviewServiceID, &env.BranchName, &env.PRNumber, - &env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt, - &serviceID, &serviceName, &serviceType, - ) - + r, err := sqlcdb.New(db.(*database.DB).DB).GetPreviewEnvironment( + c.Request.Context(), sqlcdb.GetPreviewEnvironmentParams{ + ID: envID, OwnerID: userID, Column3: contextIsAdmin(c)}) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) return } - - // Populate service info if available - if serviceID.Valid { - parsedServiceID, parseErr := uuid.Parse(serviceID.String) - if parseErr == nil { - env.Service = &Service{ - ID: parsedServiceID, - Name: serviceName.String, - Type: serviceType.String, - } - } - } - + env := previewFromRow(previewRowShape{ + ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID, + PreviewServiceID: r.PreviewServiceID, BranchName: r.BranchName, + PrNumber: r.PrNumber, Environment: r.Environment, Status: r.Status, + Url: r.Url, ExpiresAt: r.ExpiresAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + SvcID: r.SvcID, ServiceName: r.ServiceName, ServiceType: r.ServiceType, + }) c.JSON(http.StatusOK, gin.H{"preview_environment": env}) } @@ -418,24 +416,21 @@ func handleUpdatePreviewEnvironment(c *gin.Context) { } // Check if preview environment exists and user has access - var existingEnv PreviewEnvironment - err = db.(*database.DB).QueryRow( - `SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, - pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at - 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( - &existingEnv.ID, &existingEnv.ProjectID, &existingEnv.ServiceID, &existingEnv.BranchName, - &existingEnv.PRNumber, &existingEnv.Environment, &existingEnv.Status, &existingEnv.URL, - &existingEnv.ExpiresAt, &existingEnv.CreatedAt, &existingEnv.UpdatedAt, - ) - + uid, _ := uuid.Parse(fmt.Sprint(userID)) + q := sqlcdb.New(db.(*database.DB).DB) + r, err := q.GetPreviewEnvironmentForWrite( + c.Request.Context(), sqlcdb.GetPreviewEnvironmentForWriteParams{ + ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)}) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) return } + existingEnv := previewFromRow(previewRowShape{ + ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID, + BranchName: r.BranchName, PrNumber: r.PrNumber, Environment: r.Environment, + Status: r.Status, Url: r.Url, ExpiresAt: r.ExpiresAt, + CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }) // Update fields if provided if req.Status != "" { @@ -455,12 +450,17 @@ func handleUpdatePreviewEnvironment(c *gin.Context) { existingEnv.UpdatedAt = time.Now() // Update preview environment in database - _, err = db.(*database.DB).Exec( - `UPDATE preview_environments - SET status = $1, url = $2, expires_at = $3, updated_at = $4 - WHERE id = $5`, - existingEnv.Status, existingEnv.URL, existingEnv.ExpiresAt, existingEnv.UpdatedAt, existingEnv.ID, - ) + expiresAt := time.Time{} + if existingEnv.ExpiresAt != nil { + expiresAt = *existingEnv.ExpiresAt + } + err = q.UpdatePreviewEnvironment(c.Request.Context(), sqlcdb.UpdatePreviewEnvironmentParams{ + Status: existingEnv.Status, + Url: sql.NullString{String: existingEnv.URL, Valid: existingEnv.URL != ""}, + ExpiresAt: expiresAt, + UpdatedAt: sql.NullTime{Time: existingEnv.UpdatedAt, Valid: true}, + ID: existingEnv.ID, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment"}) @@ -492,46 +492,34 @@ func handleDeletePreviewEnvironment(c *gin.Context) { return } - // Check if preview environment exists and user has access - var projectOwnerID string - err = db.(*database.DB).QueryRow( - `SELECT p.owner_id - FROM preview_environments pe - JOIN projects p ON pe.project_id = p.id - WHERE pe.id = $1`, - envID, - ).Scan(&projectOwnerID) + ctx := c.Request.Context() + q := sqlcdb.New(db.(*database.DB).DB) + // Check if preview environment exists and user has access + projectOwnerID, err := q.GetPreviewEnvironmentOwner(ctx, envID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) return } // Check if user owns the project - if projectOwnerID != userID.(string) && !contextIsAdmin(c) { + if projectOwnerID.String() != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } // 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 cloneID, err := q.GetPreviewServiceID(ctx, envID); err == nil && cloneID.Valid { 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) + removeServiceRuntime(ctx, db.(*database.DB), engine, cloneID.UUID) } } - _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *cloneID) + _ = q.DeleteServiceByID(ctx, cloneID.UUID) } // Delete preview environment - _, err = db.(*database.DB).Exec( - "DELETE FROM preview_environments WHERE id = $1", - envID, - ) + err = q.DeletePreviewEnvironment(ctx, envID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete preview environment"}) @@ -569,22 +557,30 @@ func handlePromotePreviewEnvironment(c *gin.Context) { return } - // Get preview environment details - var env PreviewEnvironment - err = db.(*database.DB).QueryRow( - `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.PreviewServiceID, &env.BranchName, &env.Environment, &env.Status, - ) + ctx := c.Request.Context() + q := sqlcdb.New(db.(*database.DB).DB) + // Get preview environment details + uid, _ := uuid.Parse(fmt.Sprint(userID)) + r, err := q.GetPreviewEnvironmentForPromote( + ctx, sqlcdb.GetPreviewEnvironmentForPromoteParams{ + ID: envID, OwnerID: uid, Column3: contextIsAdmin(c)}) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Preview environment not found"}) return } + env := PreviewEnvironment{ + ID: r.ID, + ProjectID: r.ProjectID, + ServiceID: r.ServiceID, + BranchName: r.BranchName, + Environment: r.Environment, + Status: r.Status, + } + if r.PreviewServiceID.Valid { + id := r.PreviewServiceID.UUID + env.PreviewServiceID = &id + } // Check if preview environment is in a state that can be promoted if env.Status != "running" { @@ -596,11 +592,12 @@ func handlePromotePreviewEnvironment(c *gin.Context) { // 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, commit_hash, status, created_at, updated_at) - VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW())`, - deploymentID, env.ServiceID, promotionVersion, env.BranchName, - ) + err = q.InsertPendingDeployment(ctx, sqlcdb.InsertPendingDeploymentParams{ + ID: deploymentID, + ServiceID: env.ServiceID, + Version: promotionVersion, + CommitHash: sql.NullString{String: env.BranchName, Valid: true}, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"}) return @@ -628,18 +625,14 @@ func handlePromotePreviewEnvironment(c *gin.Context) { 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) + removeServiceRuntime(ctx, db.(*database.DB), engine, *env.PreviewServiceID) } } - _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID) + _ = q.DeleteServiceByID(ctx, *env.PreviewServiceID) } - if _, err := db.(*database.DB).Exec( - `UPDATE preview_environments - SET status = 'stopped', updated_at = NOW() - WHERE id = $1`, - env.ID, - ); err != nil { + if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{ + Status: "stopped", ID: env.ID}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preview environment status"}) return } @@ -682,28 +675,27 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { return } + ctx := c.Request.Context() + q := sqlcdb.New(db.(*database.DB).DB) + uid, _ := uuid.Parse(fmt.Sprint(userID)) + // Find expired preview environments for user's projects - rows, err := db.(*database.DB).Query( - `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'`, - userID, contextIsAdmin(c), - ) + rows, err := q.ListExpiredPreviewsForUser(ctx, sqlcdb.ListExpiredPreviewsForUserParams{ + OwnerID: uid, Column2: contextIsAdmin(c)}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find expired preview environments"}) return } - defer rows.Close() - var expiredEnvs []PreviewEnvironment - for rows.Next() { - var env PreviewEnvironment - err := rows.Scan( - &env.ID, &env.ProjectID, &env.ServiceID, &env.PreviewServiceID, &env.BranchName, &env.Environment, - ) - if err != nil { - continue + expiredEnvs := make([]PreviewEnvironment, 0, len(rows)) + for _, r := range rows { + env := PreviewEnvironment{ + ID: r.ID, ProjectID: r.ProjectID, ServiceID: r.ServiceID, + BranchName: r.BranchName, Environment: r.Environment, + } + if r.PreviewServiceID.Valid { + id := r.PreviewServiceID.UUID + env.PreviewServiceID = &id } expiredEnvs = append(expiredEnvs, env) } @@ -711,20 +703,17 @@ func handleCleanupExpiredPreviewEnvironments(c *gin.Context) { // Mark expired environments as expired and tear down their clones cleanupCount := 0 for _, env := range expiredEnvs { - _, err := db.(*database.DB).Exec( - "UPDATE preview_environments SET status = 'expired', updated_at = NOW() WHERE id = $1", - env.ID, - ) - if err != nil { + if err := q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{ + Status: "expired", ID: env.ID}); 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) + removeServiceRuntime(ctx, db.(*database.DB), engine, *env.PreviewServiceID) } } - _, _ = db.(*database.DB).Exec(`DELETE FROM services WHERE id = $1`, *env.PreviewServiceID) + _ = q.DeleteServiceByID(ctx, *env.PreviewServiceID) } cleanupCount++ } @@ -754,15 +743,13 @@ func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) stri 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() + brief, err := q.GetServicePlacementBrief(ctx, cloneID) + if err != nil { + return "" + } + nodeID, spread := brief.NodeID, brief.Spread var base string switch { case nodeID.Valid && nodeID.String != "": @@ -789,30 +776,22 @@ func resolvePreviewHost(db *database.DB, cloneID uuid.UUID, envName string) stri // 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 + r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID) + if err != nil { + return Service{}, err + } + return Service{ + ID: r.ID, ProjectID: r.ProjectID, Name: r.Name, Type: r.Type.String, + Status: r.Status.String, Image: r.Image.String, Command: r.Command.String, + Environment: r.Environment.String, GitRepo: r.GitRepo.String, + GitBranch: r.GitBranch.String, BuildPath: r.BuildPath.String, + CPU: r.Cpu.String, Memory: r.Memory.String, + Replicas: int(r.Replicas), Port: int(r.Port), Domain: r.Domain, + HealthCheckPath: r.HealthcheckPath, RestartPolicy: r.RestartPolicy, + Builder: r.Builder, CPUReserve: r.CpuReserve, MemoryReserve: r.MemoryReserve, + StaticBuildCmd: r.StaticBuildCmd, StaticDir: r.StaticDir, + CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time, + }, nil } // StartPreviewSweeper expires preview environments past their TTL — tears @@ -838,54 +817,27 @@ func StartPreviewSweeper(ctx context.Context, db *database.DB, engine *deploymen // 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')`) + q := sqlcdb.New(db.DB) + rows, err := q.ListSweepablePreviews(ctx) 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) - } + for _, r := range rows { + ids = append(ids, r.ID) + if r.PreviewServiceID.Valid { + removeServiceRuntime(ctx, db, engine, r.PreviewServiceID.UUID) + _ = q.DeleteServiceByID(ctx, r.PreviewServiceID.UUID) } } - 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) + _ = q.MarkPreviewEnvironmentStatus(ctx, sqlcdb.MarkPreviewEnvironmentStatusParams{ + Status: "expired", ID: 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 - `) + _ = sqlcdb.New(db.DB).SyncPreviewStatuses(ctx) } diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index 0a170c1..775fc4c 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -136,11 +136,22 @@ type DatabaseService struct { } type Deployment struct { - ID uuid.UUID `json:"id"` - ServiceID uuid.UUID `json:"service_id"` - Status sql.NullString `json:"status"` - CreatedAt sql.NullTime `json:"created_at"` - UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + Version string `json:"version"` + CommitHash sql.NullString `json:"commit_hash"` + ImageDigest sql.NullString `json:"image_digest"` + Status sql.NullString `json:"status"` + BuildLog sql.NullString `json:"build_log"` + DeploymentLog sql.NullString `json:"deployment_log"` + StartedAt sql.NullTime `json:"started_at"` + CompletedAt sql.NullTime `json:"completed_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + ImageName sql.NullString `json:"image_name"` + ImageTag sql.NullString `json:"image_tag"` + RuntimeLog sql.NullString `json:"runtime_log"` + Error sql.NullString `json:"error"` } type Environment struct { @@ -274,6 +285,21 @@ type OutboundWebhook struct { UpdatedAt sql.NullTime `json:"updated_at"` } +type PreviewEnvironment struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.UUID `json:"service_id"` + BranchName string `json:"branch_name"` + PrNumber sql.NullInt32 `json:"pr_number"` + Environment string `json:"environment"` + Status string `json:"status"` + Url sql.NullString `json:"url"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + PreviewServiceID uuid.NullUUID `json:"preview_service_id"` +} + type Project struct { ID uuid.UUID `json:"id"` Name string `json:"name"` diff --git a/app/backend/internal/database/sqlcdb/preview_environments.sql.go b/app/backend/internal/database/sqlcdb/preview_environments.sql.go new file mode 100644 index 0000000..4790742 --- /dev/null +++ b/app/backend/internal/database/sqlcdb/preview_environments.sql.go @@ -0,0 +1,635 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: preview_environments.sql + +package sqlcdb + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const countActivePreviewsForBranch = `-- name: CountActivePreviewsForBranch :one +SELECT COUNT(*) FROM preview_environments +WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped') +` + +type CountActivePreviewsForBranchParams struct { + ServiceID uuid.UUID `json:"service_id"` + BranchName string `json:"branch_name"` +} + +func (q *Queries) CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countActivePreviewsForBranch, arg.ServiceID, arg.BranchName) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createPreviewDomain = `-- name: CreatePreviewDomain :exec +INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls') +` + +type CreatePreviewDomainParams struct { + ServiceID uuid.UUID `json:"service_id"` + Domain string `json:"domain"` +} + +func (q *Queries) CreatePreviewDomain(ctx context.Context, arg CreatePreviewDomainParams) error { + _, err := q.db.ExecContext(ctx, createPreviewDomain, arg.ServiceID, arg.Domain) + return err +} + +const createPreviewEnvironment = `-- name: CreatePreviewEnvironment :exec +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, $12) +` + +type CreatePreviewEnvironmentParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.UUID `json:"service_id"` + PreviewServiceID uuid.NullUUID `json:"preview_service_id"` + BranchName string `json:"branch_name"` + PrNumber sql.NullInt32 `json:"pr_number"` + Environment string `json:"environment"` + Status string `json:"status"` + Url sql.NullString `json:"url"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) CreatePreviewEnvironment(ctx context.Context, arg CreatePreviewEnvironmentParams) error { + _, err := q.db.ExecContext(ctx, createPreviewEnvironment, + arg.ID, + arg.ProjectID, + arg.ServiceID, + arg.PreviewServiceID, + arg.BranchName, + arg.PrNumber, + arg.Environment, + arg.Status, + arg.Url, + arg.ExpiresAt, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const deleteAllServiceDomains = `-- name: DeleteAllServiceDomains :exec +DELETE FROM service_domains WHERE service_id = $1 +` + +func (q *Queries) DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteAllServiceDomains, serviceID) + return err +} + +const deletePreviewEnvironment = `-- name: DeletePreviewEnvironment :exec +DELETE FROM preview_environments WHERE id = $1 +` + +func (q *Queries) DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deletePreviewEnvironment, id) + return err +} + +const getPreviewEnvironment = `-- name: GetPreviewEnvironment :one +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 svc_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 +JOIN projects p ON pe.project_id = p.id +WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2)) +` + +type GetPreviewEnvironmentParams struct { + ID uuid.UUID `json:"id"` + OwnerID uuid.UUID `json:"owner_id"` + Column3 bool `json:"column_3"` +} + +type GetPreviewEnvironmentRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.UUID `json:"service_id"` + PreviewServiceID uuid.NullUUID `json:"preview_service_id"` + BranchName string `json:"branch_name"` + PrNumber sql.NullInt32 `json:"pr_number"` + Environment string `json:"environment"` + Status string `json:"status"` + Url sql.NullString `json:"url"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + SvcID uuid.NullUUID `json:"svc_id"` + ServiceName sql.NullString `json:"service_name"` + ServiceType sql.NullString `json:"service_type"` +} + +func (q *Queries) GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error) { + row := q.db.QueryRowContext(ctx, getPreviewEnvironment, arg.ID, arg.OwnerID, arg.Column3) + var i GetPreviewEnvironmentRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.ServiceID, + &i.PreviewServiceID, + &i.BranchName, + &i.PrNumber, + &i.Environment, + &i.Status, + &i.Url, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SvcID, + &i.ServiceName, + &i.ServiceType, + ) + return i, err +} + +const getPreviewEnvironmentForPromote = `-- name: GetPreviewEnvironmentForPromote :one +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) +` + +type GetPreviewEnvironmentForPromoteParams struct { + ID uuid.UUID `json:"id"` + OwnerID uuid.UUID `json:"owner_id"` + Column3 bool `json:"column_3"` +} + +type GetPreviewEnvironmentForPromoteRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.UUID `json:"service_id"` + PreviewServiceID uuid.NullUUID `json:"preview_service_id"` + BranchName string `json:"branch_name"` + Environment string `json:"environment"` + Status string `json:"status"` +} + +func (q *Queries) GetPreviewEnvironmentForPromote(ctx context.Context, arg GetPreviewEnvironmentForPromoteParams) (GetPreviewEnvironmentForPromoteRow, error) { + row := q.db.QueryRowContext(ctx, getPreviewEnvironmentForPromote, arg.ID, arg.OwnerID, arg.Column3) + var i GetPreviewEnvironmentForPromoteRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.ServiceID, + &i.PreviewServiceID, + &i.BranchName, + &i.Environment, + &i.Status, + ) + return i, err +} + +const getPreviewEnvironmentForWrite = `-- name: GetPreviewEnvironmentForWrite :one +SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, + pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at +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) +` + +type GetPreviewEnvironmentForWriteParams struct { + ID uuid.UUID `json:"id"` + OwnerID uuid.UUID `json:"owner_id"` + Column3 bool `json:"column_3"` +} + +type GetPreviewEnvironmentForWriteRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.UUID `json:"service_id"` + BranchName string `json:"branch_name"` + PrNumber sql.NullInt32 `json:"pr_number"` + Environment string `json:"environment"` + Status string `json:"status"` + Url sql.NullString `json:"url"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) GetPreviewEnvironmentForWrite(ctx context.Context, arg GetPreviewEnvironmentForWriteParams) (GetPreviewEnvironmentForWriteRow, error) { + row := q.db.QueryRowContext(ctx, getPreviewEnvironmentForWrite, arg.ID, arg.OwnerID, arg.Column3) + var i GetPreviewEnvironmentForWriteRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.ServiceID, + &i.BranchName, + &i.PrNumber, + &i.Environment, + &i.Status, + &i.Url, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getPreviewEnvironmentOwner = `-- name: GetPreviewEnvironmentOwner :one +SELECT p.owner_id +FROM preview_environments pe +JOIN projects p ON pe.project_id = p.id +WHERE pe.id = $1 +` + +func (q *Queries) GetPreviewEnvironmentOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) { + row := q.db.QueryRowContext(ctx, getPreviewEnvironmentOwner, id) + var owner_id uuid.UUID + err := row.Scan(&owner_id) + return owner_id, err +} + +const getPreviewServiceID = `-- name: GetPreviewServiceID :one +SELECT preview_service_id FROM preview_environments WHERE id = $1 +` + +func (q *Queries) GetPreviewServiceID(ctx context.Context, id uuid.UUID) (uuid.NullUUID, error) { + row := q.db.QueryRowContext(ctx, getPreviewServiceID, id) + var preview_service_id uuid.NullUUID + err := row.Scan(&preview_service_id) + return preview_service_id, err +} + +const getProjectBrief = `-- name: GetProjectBrief :one +SELECT id, name, owner_id FROM projects WHERE id = $1 +` + +type GetProjectBriefRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) GetProjectBrief(ctx context.Context, id uuid.UUID) (GetProjectBriefRow, error) { + row := q.db.QueryRowContext(ctx, getProjectBrief, id) + var i GetProjectBriefRow + err := row.Scan(&i.ID, &i.Name, &i.OwnerID) + return i, err +} + +const getServiceForDeploy = `-- name: GetServiceForDeploy :one +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) AS replicas, COALESCE(s.port, 0) AS port, + COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path, + COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy, + COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve, + COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd, + COALESCE(s.static_dir, '') AS static_dir, + s.created_at, s.updated_at +FROM services s +WHERE s.id = $1 +` + +type GetServiceForDeployRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + Name string `json:"name"` + Type sql.NullString `json:"type"` + Status sql.NullString `json:"status"` + Image sql.NullString `json:"image"` + Command sql.NullString `json:"command"` + Environment sql.NullString `json:"environment"` + GitRepo sql.NullString `json:"git_repo"` + GitBranch sql.NullString `json:"git_branch"` + BuildPath sql.NullString `json:"build_path"` + Cpu sql.NullString `json:"cpu"` + Memory sql.NullString `json:"memory"` + Replicas int32 `json:"replicas"` + Port int32 `json:"port"` + Domain string `json:"domain"` + HealthcheckPath string `json:"healthcheck_path"` + RestartPolicy string `json:"restart_policy"` + Builder string `json:"builder"` + CpuReserve string `json:"cpu_reserve"` + MemoryReserve string `json:"memory_reserve"` + StaticBuildCmd string `json:"static_build_cmd"` + StaticDir string `json:"static_dir"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error) { + row := q.db.QueryRowContext(ctx, getServiceForDeploy, id) + var i GetServiceForDeployRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.Name, + &i.Type, + &i.Status, + &i.Image, + &i.Command, + &i.Environment, + &i.GitRepo, + &i.GitBranch, + &i.BuildPath, + &i.Cpu, + &i.Memory, + &i.Replicas, + &i.Port, + &i.Domain, + &i.HealthcheckPath, + &i.RestartPolicy, + &i.Builder, + &i.CpuReserve, + &i.MemoryReserve, + &i.StaticBuildCmd, + &i.StaticDir, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getServiceTypeBrief = `-- name: GetServiceTypeBrief :one +SELECT id, name, COALESCE(type, service_type, '') AS service_type FROM services WHERE id = $1 AND project_id = $2 +` + +type GetServiceTypeBriefParams struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` +} + +type GetServiceTypeBriefRow struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ServiceType string `json:"service_type"` +} + +func (q *Queries) GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error) { + row := q.db.QueryRowContext(ctx, getServiceTypeBrief, arg.ID, arg.ProjectID) + var i GetServiceTypeBriefRow + err := row.Scan(&i.ID, &i.Name, &i.ServiceType) + return i, err +} + +const insertPendingDeployment = `-- name: InsertPendingDeployment :exec +INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at) +VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW()) +` + +type InsertPendingDeploymentParams struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + Version string `json:"version"` + CommitHash sql.NullString `json:"commit_hash"` +} + +func (q *Queries) InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error { + _, err := q.db.ExecContext(ctx, insertPendingDeployment, + arg.ID, + arg.ServiceID, + arg.Version, + arg.CommitHash, + ) + return err +} + +const listExpiredPreviewsForUser = `-- name: ListExpiredPreviewsForUser :many +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' +` + +type ListExpiredPreviewsForUserParams struct { + OwnerID uuid.UUID `json:"owner_id"` + Column2 bool `json:"column_2"` +} + +type ListExpiredPreviewsForUserRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.UUID `json:"service_id"` + PreviewServiceID uuid.NullUUID `json:"preview_service_id"` + BranchName string `json:"branch_name"` + Environment string `json:"environment"` +} + +func (q *Queries) ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error) { + rows, err := q.db.QueryContext(ctx, listExpiredPreviewsForUser, arg.OwnerID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListExpiredPreviewsForUserRow{} + for rows.Next() { + var i ListExpiredPreviewsForUserRow + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.ServiceID, + &i.PreviewServiceID, + &i.BranchName, + &i.Environment, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPreviewEnvironmentsForProject = `-- name: ListPreviewEnvironmentsForProject :many +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 svc_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 +ORDER BY pe.created_at DESC +` + +type ListPreviewEnvironmentsForProjectRow struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + ServiceID uuid.UUID `json:"service_id"` + PreviewServiceID uuid.NullUUID `json:"preview_service_id"` + BranchName string `json:"branch_name"` + PrNumber sql.NullInt32 `json:"pr_number"` + Environment string `json:"environment"` + Status string `json:"status"` + Url sql.NullString `json:"url"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + SvcID uuid.NullUUID `json:"svc_id"` + ServiceName sql.NullString `json:"service_name"` + ServiceType sql.NullString `json:"service_type"` +} + +func (q *Queries) ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error) { + rows, err := q.db.QueryContext(ctx, listPreviewEnvironmentsForProject, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListPreviewEnvironmentsForProjectRow{} + for rows.Next() { + var i ListPreviewEnvironmentsForProjectRow + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.ServiceID, + &i.PreviewServiceID, + &i.BranchName, + &i.PrNumber, + &i.Environment, + &i.Status, + &i.Url, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.SvcID, + &i.ServiceName, + &i.ServiceType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSweepablePreviews = `-- name: ListSweepablePreviews :many +SELECT id, preview_service_id FROM preview_environments +WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped') +` + +type ListSweepablePreviewsRow struct { + ID uuid.UUID `json:"id"` + PreviewServiceID uuid.NullUUID `json:"preview_service_id"` +} + +func (q *Queries) ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error) { + rows, err := q.db.QueryContext(ctx, listSweepablePreviews) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListSweepablePreviewsRow{} + for rows.Next() { + var i ListSweepablePreviewsRow + if err := rows.Scan(&i.ID, &i.PreviewServiceID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markPreviewEnvironmentStatus = `-- name: MarkPreviewEnvironmentStatus :exec +UPDATE preview_environments SET status = $1, updated_at = NOW() WHERE id = $2 +` + +type MarkPreviewEnvironmentStatusParams struct { + Status string `json:"status"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) MarkPreviewEnvironmentStatus(ctx context.Context, arg MarkPreviewEnvironmentStatusParams) error { + _, err := q.db.ExecContext(ctx, markPreviewEnvironmentStatus, arg.Status, arg.ID) + return err +} + +const syncPreviewStatuses = `-- name: SyncPreviewStatuses :exec +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 +` + +func (q *Queries) SyncPreviewStatuses(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, syncPreviewStatuses) + return err +} + +const updatePreviewEnvironment = `-- name: UpdatePreviewEnvironment :exec +UPDATE preview_environments +SET status = $1, url = $2, expires_at = $3, updated_at = $4 +WHERE id = $5 +` + +type UpdatePreviewEnvironmentParams struct { + Status string `json:"status"` + Url sql.NullString `json:"url"` + ExpiresAt time.Time `json:"expires_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error { + _, err := q.db.ExecContext(ctx, updatePreviewEnvironment, + arg.Status, + arg.Url, + arg.ExpiresAt, + arg.UpdatedAt, + arg.ID, + ) + return err +} + +const updateServicePreviewBranch = `-- name: UpdateServicePreviewBranch :exec +UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2 +` + +type UpdateServicePreviewBranchParams struct { + GitBranch sql.NullString `json:"git_branch"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error { + _, err := q.db.ExecContext(ctx, updateServicePreviewBranch, arg.GitBranch, arg.ID) + return err +} diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index ed3b878..b6b3bb9 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -22,6 +22,7 @@ type Querier interface { CloneServiceRow(ctx context.Context, arg CloneServiceRowParams) error CloneServiceVariables(ctx context.Context, arg CloneServiceVariablesParams) error CompleteCommand(ctx context.Context, arg CompleteCommandParams) (AgentCommand, error) + CountActivePreviewsForBranch(ctx context.Context, arg CountActivePreviewsForBranchParams) (int64, error) CountDatabaseServicesByUserAndName(ctx context.Context, arg CountDatabaseServicesByUserAndNameParams) (int64, error) CountDatabasesUsingBackupTarget(ctx context.Context, targetID sql.NullString) (int64, error) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) @@ -41,6 +42,8 @@ type Querier interface { CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error CreateNotification(ctx context.Context, arg CreateNotificationParams) error CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error + CreatePreviewDomain(ctx context.Context, arg CreatePreviewDomainParams) error + CreatePreviewEnvironment(ctx context.Context, arg CreatePreviewEnvironmentParams) error CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) CreateServiceDomain(ctx context.Context, arg CreateServiceDomainParams) (CreateServiceDomainRow, error) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error @@ -50,12 +53,14 @@ type Querier interface { CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error) DeleteAgent(ctx context.Context, id string) error + DeleteAllServiceDomains(ctx context.Context, serviceID uuid.UUID) error DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error DeleteBanner(ctx context.Context, id uuid.UUID) error DeleteDatabaseServiceByID(ctx context.Context, id string) error DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error + DeletePreviewEnvironment(ctx context.Context, id uuid.UUID) error DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error) DeleteProjectEnvironment(ctx context.Context, arg DeleteProjectEnvironmentParams) error DeleteServiceByID(ctx context.Context, id uuid.UUID) error @@ -79,6 +84,12 @@ type Querier interface { GetLastAgentCommandByType(ctx context.Context, arg GetLastAgentCommandByTypeParams) (AgentCommand, error) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) + GetPreviewEnvironment(ctx context.Context, arg GetPreviewEnvironmentParams) (GetPreviewEnvironmentRow, error) + GetPreviewEnvironmentForPromote(ctx context.Context, arg GetPreviewEnvironmentForPromoteParams) (GetPreviewEnvironmentForPromoteRow, error) + GetPreviewEnvironmentForWrite(ctx context.Context, arg GetPreviewEnvironmentForWriteParams) (GetPreviewEnvironmentForWriteRow, error) + GetPreviewEnvironmentOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) + GetPreviewServiceID(ctx context.Context, id uuid.UUID) (uuid.NullUUID, error) + GetProjectBrief(ctx context.Context, id uuid.UUID) (GetProjectBriefRow, error) GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error) GetProjectEnvironmentByName(ctx context.Context, arg GetProjectEnvironmentByNameParams) (Environment, error) GetProjectOwner(ctx context.Context, id uuid.UUID) (uuid.UUID, error) @@ -89,12 +100,15 @@ type Querier interface { GetServiceDomainDefault(ctx context.Context, serviceID uuid.UUID) (string, error) GetServiceDomainFirst(ctx context.Context, serviceID uuid.UUID) (string, error) GetServiceEnvironment(ctx context.Context, id uuid.UUID) (string, error) + GetServiceForDeploy(ctx context.Context, id uuid.UUID) (GetServiceForDeployRow, error) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error) + GetServiceTypeBrief(ctx context.Context, arg GetServiceTypeBriefParams) (GetServiceTypeBriefRow, error) GetUserInviteByTokenHash(ctx context.Context, tokenHash string) (UserInvite, error) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) + InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error ListActiveBanners(ctx context.Context) ([]Banner, error) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) @@ -111,6 +125,7 @@ type Querier interface { ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) + ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error) ListInstanceMetrics(ctx context.Context, arg ListInstanceMetricsParams) ([]ListInstanceMetricsRow, error) ListNodeMetrics(ctx context.Context, arg ListNodeMetricsParams) ([]ListNodeMetricsRow, error) ListNotificationChannelsByUser(ctx context.Context, userID uuid.UUID) ([]NotificationChannel, error) @@ -118,6 +133,7 @@ type Querier interface { ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error) ListPendingProjects(ctx context.Context) ([]Project, error) + ListPreviewEnvironmentsForProject(ctx context.Context, projectID uuid.UUID) ([]ListPreviewEnvironmentsForProjectRow, error) ListProjectEnvironments(ctx context.Context, projectID uuid.UUID) ([]ListProjectEnvironmentsRow, error) // Visibility model: a project is readable when it is approved for public // display, when the caller owns or is a member of it, or when the caller is a @@ -136,11 +152,13 @@ type Querier interface { ListServiceMetrics(ctx context.Context, arg ListServiceMetricsParams) ([]ListServiceMetricsRow, error) ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error) ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error) + ListSweepablePreviews(ctx context.Context) ([]ListSweepablePreviewsRow, error) ListUserInvites(ctx context.Context) ([]UserInvite, error) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error + MarkPreviewEnvironmentStatus(ctx context.Context, arg MarkPreviewEnvironmentStatusParams) error MarkUserInviteUsed(ctx context.Context, arg MarkUserInviteUsedParams) error MoveServiceProject(ctx context.Context, arg MoveServiceProjectParams) (int64, error) // Resource-aware: lowest memory utilisation first, then cpu, then container @@ -168,6 +186,7 @@ type Querier interface { SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error) SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error + SyncPreviewStatuses(ctx context.Context) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error TouchUserToken(ctx context.Context, id uuid.UUID) error @@ -187,7 +206,9 @@ type Querier interface { UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error + UpdatePreviewEnvironment(ctx context.Context, arg UpdatePreviewEnvironmentParams) error UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error) + UpdateServicePreviewBranch(ctx context.Context, arg UpdateServicePreviewBranchParams) error UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error) UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error diff --git a/app/backend/sqlc/queries/preview_environments.sql b/app/backend/sqlc/queries/preview_environments.sql new file mode 100644 index 0000000..7c72616 --- /dev/null +++ b/app/backend/sqlc/queries/preview_environments.sql @@ -0,0 +1,121 @@ +-- name: ListPreviewEnvironmentsForProject :many +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 svc_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 +ORDER BY pe.created_at DESC; + +-- name: GetPreviewEnvironment :one +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 svc_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 +JOIN projects p ON pe.project_id = p.id +WHERE pe.id = $1 AND (p.is_approved OR p.owner_id = $2 OR $3::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2)); + +-- name: GetPreviewEnvironmentForWrite :one +SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number, + pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at +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); + +-- name: GetPreviewEnvironmentForPromote :one +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); + +-- name: GetPreviewEnvironmentOwner :one +SELECT p.owner_id +FROM preview_environments pe +JOIN projects p ON pe.project_id = p.id +WHERE pe.id = $1; + +-- name: GetPreviewServiceID :one +SELECT preview_service_id FROM preview_environments WHERE id = $1; + +-- name: UpdatePreviewEnvironment :exec +UPDATE preview_environments +SET status = $1, url = $2, expires_at = $3, updated_at = $4 +WHERE id = $5; + +-- name: MarkPreviewEnvironmentStatus :exec +UPDATE preview_environments SET status = $1, updated_at = NOW() WHERE id = $2; + +-- name: DeletePreviewEnvironment :exec +DELETE FROM preview_environments WHERE id = $1; + +-- name: CountActivePreviewsForBranch :one +SELECT COUNT(*) FROM preview_environments +WHERE service_id = $1 AND branch_name = $2 AND status NOT IN ('expired', 'stopped'); + +-- name: CreatePreviewEnvironment :exec +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, $12); + +-- name: UpdateServicePreviewBranch :exec +UPDATE services SET git_branch = $1, domain = '', updated_at = NOW() WHERE id = $2; + +-- name: DeleteAllServiceDomains :exec +DELETE FROM service_domains WHERE service_id = $1; + +-- name: CreatePreviewDomain :exec +INSERT INTO service_domains (service_id, domain, is_default, cert_type) VALUES ($1, $2, true, 'tls'); + +-- name: InsertPendingDeployment :exec +INSERT INTO deployments (id, service_id, version, commit_hash, status, created_at, updated_at) +VALUES ($1, $2, $3, $4, 'pending', NOW(), NOW()); + +-- name: ListExpiredPreviewsForUser :many +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'; + +-- name: ListSweepablePreviews :many +SELECT id, preview_service_id FROM preview_environments +WHERE expires_at < NOW() AND status NOT IN ('expired', 'stopped'); + +-- name: SyncPreviewStatuses :exec +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; + +-- name: GetProjectBrief :one +SELECT id, name, owner_id FROM projects WHERE id = $1; + +-- name: GetServiceTypeBrief :one +SELECT id, name, COALESCE(type, service_type, '') AS service_type FROM services WHERE id = $1 AND project_id = $2; + +-- name: GetServiceForDeploy :one +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) AS replicas, COALESCE(s.port, 0) AS port, + COALESCE(s.domain, '') AS domain, COALESCE(s.healthcheck_path, '') AS healthcheck_path, + COALESCE(s.restart_policy, 'unless-stopped') AS restart_policy, + COALESCE(s.builder, 'auto') AS builder, COALESCE(s.cpu_reserve, '') AS cpu_reserve, + COALESCE(s.memory_reserve, '') AS memory_reserve, COALESCE(s.static_build_cmd, '') AS static_build_cmd, + COALESCE(s.static_dir, '') AS static_dir, + s.created_at, s.updated_at +FROM services s +WHERE s.id = $1; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index 5fc2972..373aa14 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -98,11 +98,37 @@ CREATE TABLE service_domains ( ); CREATE TABLE deployments ( - id UUID PRIMARY KEY, + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), service_id UUID NOT NULL REFERENCES services(id) ON DELETE CASCADE, - status VARCHAR(50), + version VARCHAR(255) NOT NULL, + commit_hash VARCHAR(255), + image_digest VARCHAR(255), + status VARCHAR(50) DEFAULT 'created', + build_log TEXT, + deployment_log TEXT, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + image_name VARCHAR(255), + image_tag VARCHAR(255), + runtime_log TEXT, + error TEXT +); + +CREATE TABLE preview_environments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL, + service_id UUID NOT NULL, + branch_name VARCHAR(255) NOT NULL, + pr_number INTEGER, + environment VARCHAR(255) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'building', + url TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + preview_service_id UUID ); CREATE TABLE environment_variables (