From cca20f7eae4b8eda3a5610ad856a4273822a86cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Dvo=C5=99=C3=A1k?= <150935816+Dvorinka@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:01:40 +0200 Subject: [PATCH] refactor(db): port deployments to sqlc (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tranche 4: all 32 raw sites in deployments.go — paginated service list, the accessible-feed joins (owner/member/admin/approved), service+owner load, deployment insert, status transitions (fail/complete/set), the 10-field progress sync, cancel access check, rollback reads/inserts, and the published_port reuse read. The shared serviceForDeployFromRow mapper now feeds both the preview and deployments loaders. Live test covers the full lifecycle: insert, list, access join, feed predicate, queued → deployed sync, failed transition. Generated with [Devin](https://devin.ai) Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/backend/internal/api/deployments.go | 548 +++++++++--------- app/backend/internal/api/domains_test.go | 108 +++- .../internal/api/preview_environments.go | 16 +- .../database/sqlcdb/deployments.sql.go | 536 +++++++++++++++++ .../internal/database/sqlcdb/querier.go | 15 + app/backend/sqlc/queries/deployments.sql | 105 ++++ 6 files changed, 1043 insertions(+), 285 deletions(-) create mode 100644 app/backend/internal/database/sqlcdb/deployments.sql.go create mode 100644 app/backend/sqlc/queries/deployments.sql diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index 37a4fac..691d78d 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -2,12 +2,14 @@ package api import ( "containr/internal/database" + "containr/internal/database/sqlcdb" "containr/internal/deployment" "containr/internal/deployqueue" "containr/internal/docker" "containr/internal/source" "containr/internal/types" "context" + "database/sql" "errors" "fmt" "log/slog" @@ -37,6 +39,72 @@ type DeploymentModel struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } +// deploymentRowShape carries the deployment columns shared by the +// generated list/detail/rollback row types. +type deploymentRowShape struct { + ID uuid.UUID + ServiceID uuid.UUID + CommitHash sql.NullString + Status sql.NullString + ImageName sql.NullString + ImageTag sql.NullString + BuildLog sql.NullString + RuntimeLog sql.NullString + Error sql.NullString + StartedAt sql.NullTime + CompletedAt sql.NullTime + CreatedAt sql.NullTime + UpdatedAt sql.NullTime +} + +func strPtrNS(v sql.NullString) *string { + if !v.Valid { + return nil + } + s := v.String + return &s +} + +func ptrStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +func timePtrNT(v sql.NullTime) *time.Time { + if !v.Valid { + return nil + } + t := v.Time + return &t +} + +func ntPtr(t *time.Time) sql.NullTime { + if t == nil { + return sql.NullTime{} + } + return sql.NullTime{Time: *t, Valid: true} +} + +func deploymentModelFrom(r deploymentRowShape) DeploymentModel { + return DeploymentModel{ + ID: r.ID, + ServiceID: r.ServiceID, + CommitHash: strPtrNS(r.CommitHash), + Status: r.Status.String, + ImageName: r.ImageName.String, + ImageTag: r.ImageTag.String, + BuildLog: r.BuildLog.String, + RuntimeLog: r.RuntimeLog.String, + Error: strPtrNS(r.Error), + StartedAt: timePtrNT(r.StartedAt), + CompletedAt: timePtrNT(r.CompletedAt), + CreatedAt: r.CreatedAt.Time, + UpdatedAt: r.UpdatedAt.Time, + } +} + type CreateDeploymentRequest struct { CommitHash string `json:"commit_hash"` Branch string `json:"branch"` @@ -84,44 +152,29 @@ func handleGetDeployments(c *gin.Context) { offset, limit := pageWindow(c, 50, 100) - var total int - _ = db.(*database.DB).QueryRow( - `SELECT COUNT(*) FROM deployments WHERE service_id = $1`, serviceID, - ).Scan(&total) + q := sqlcdb.New(db.(*database.DB).DB) + total, _ := q.CountDeploymentsForService(c.Request.Context(), serviceID) - rows, err := db.(*database.DB).Query( - `SELECT id, service_id, commit_hash, status, image_name, image_tag, - build_log, runtime_log, error, started_at, completed_at, created_at, updated_at - FROM deployments - WHERE service_id = $1 - ORDER BY created_at DESC - LIMIT $2 OFFSET $3`, - serviceID, limit, offset, - ) + rows, err := q.ListDeploymentsForService(c.Request.Context(), sqlcdb.ListDeploymentsForServiceParams{ + ServiceID: serviceID, Limit: int32(limit), Offset: int32(offset)}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) return } - defer rows.Close() - var deployments []DeploymentModel - for rows.Next() { - var d DeploymentModel - err := rows.Scan( - &d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag, - &d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt, - &d.CreatedAt, &d.UpdatedAt, - ) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"}) - return - } - deployments = append(deployments, d) + deployments := make([]DeploymentModel, 0, len(rows)) + for _, r := range rows { + deployments = append(deployments, deploymentModelFrom(deploymentRowShape{ + ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status, + ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog, + RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt, + CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + })) } c.JSON(http.StatusOK, gin.H{ "deployments": deployments, - "pagination": paginationMeta(offset, limit, total), + "pagination": paginationMeta(offset, limit, int(total)), }) } @@ -152,51 +205,33 @@ func handleGetRecentDeployments(c *gin.Context) { offset, limit := pageWindow(c, 10, 50) - var total int - if err := db.(*database.DB).QueryRow( - `SELECT COUNT(*) - FROM deployments d - JOIN services s ON s.id = d.service_id - JOIN projects p ON p.id = s.project_id - WHERE p.is_approved OR p.owner_id = $1 OR $2::bool - OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1)`, - userID, isAdmin, - ).Scan(&total); err != nil { + q := sqlcdb.New(db.(*database.DB).DB) + total, err := q.CountAccessibleDeployments(c.Request.Context(), sqlcdb.CountAccessibleDeploymentsParams{ + OwnerID: userID, Column2: isAdmin}) + if err != nil { total = 0 } - rows, err := db.(*database.DB).Query( - `SELECT d.id, d.service_id, s.name, p.name, d.status, - COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at - FROM deployments d - JOIN services s ON s.id = d.service_id - JOIN projects p ON p.id = s.project_id - WHERE p.is_approved OR p.owner_id = $1 OR $2::bool - OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) - ORDER BY d.created_at DESC - LIMIT $3 OFFSET $4`, - userID, isAdmin, limit, offset, - ) + rows, err := q.ListRecentAccessibleDeployments(c.Request.Context(), sqlcdb.ListRecentAccessibleDeploymentsParams{ + OwnerID: userID, Column2: isAdmin, Limit: int32(limit), Offset: int32(offset)}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) return } - defer rows.Close() - deployments := []RecentDeployment{} - for rows.Next() { - var d RecentDeployment - if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName, - &d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"}) - return - } - deployments = append(deployments, d) + deployments := make([]RecentDeployment, 0, len(rows)) + for _, r := range rows { + deployments = append(deployments, RecentDeployment{ + ID: r.ID, ServiceID: r.ServiceID, ServiceName: r.ServiceName, + ProjectName: r.ProjectName, Status: r.Status.String, ImageName: r.ImageName, + StartedAt: timePtrNT(r.StartedAt), CompletedAt: timePtrNT(r.CompletedAt), + CreatedAt: r.CreatedAt.Time, + }) } c.JSON(http.StatusOK, gin.H{ "deployments": deployments, - "pagination": paginationMeta(offset, limit, total), + "pagination": paginationMeta(offset, limit, int(total)), }) } @@ -230,38 +265,27 @@ func handleCreateDeployment(c *gin.Context) { return } - var service Service - var projectOwner string - err = db.(*database.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, p.owner_id - FROM services s - JOIN projects p ON s.project_id = p.id - 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, &projectOwner, - ) - + ctx := c.Request.Context() + q := sqlcdb.New(db.(*database.DB).DB) + sr, err := q.GetServiceForDeployWithOwner(ctx, serviceID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) return } + service := serviceForDeployFromRow(sqlcdb.GetServiceForDeployRow{ + ID: sr.ID, ProjectID: sr.ProjectID, Name: sr.Name, Type: sr.Type, + Status: sr.Status, Image: sr.Image, Command: sr.Command, + Environment: sr.Environment, GitRepo: sr.GitRepo, GitBranch: sr.GitBranch, + BuildPath: sr.BuildPath, Cpu: sr.Cpu, Memory: sr.Memory, + Replicas: sr.Replicas, Port: sr.Port, Domain: sr.Domain, + HealthcheckPath: sr.HealthcheckPath, RestartPolicy: sr.RestartPolicy, + Builder: sr.Builder, CpuReserve: sr.CpuReserve, MemoryReserve: sr.MemoryReserve, + StaticBuildCmd: sr.StaticBuildCmd, StaticDir: sr.StaticDir, + CreatedAt: sr.CreatedAt, UpdatedAt: sr.UpdatedAt, + }) + projectOwner := sr.OwnerID - if projectOwner != userID.(string) { + if projectOwner.String() != userID.(string) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -287,12 +311,17 @@ func handleCreateDeployment(c *gin.Context) { UpdatedAt: now, } - _, err = db.(*database.DB).Exec( - `INSERT INTO deployments - (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, - ) + err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{ + ID: d.ID, + ServiceID: d.ServiceID, + Version: fmt.Sprintf("v%d", now.Unix()), + CommitHash: sql.NullString{String: ptrStr(d.CommitHash), Valid: d.CommitHash != nil}, + Status: sql.NullString{String: d.Status, Valid: true}, + ImageName: sql.NullString{String: d.ImageName, Valid: true}, + ImageTag: sql.NullString{String: d.ImageTag, Valid: true}, + CreatedAt: sql.NullTime{Time: d.CreatedAt, Valid: true}, + UpdatedAt: sql.NullTime{Time: d.UpdatedAt, Valid: true}, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment"}) @@ -303,20 +332,20 @@ func handleCreateDeployment(c *gin.Context) { if !exists || engine == nil { unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server." completedAt := time.Now() - _, _ = db.(*database.DB).Exec( - `UPDATE deployments - SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 - WHERE id = $3`, - unavailableErr, completedAt, d.ID, - ) + _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{ + Error: sql.NullString{String: unavailableErr, Valid: true}, + CompletedAt: sql.NullTime{Time: completedAt, Valid: true}, + ID: d.ID, + }) d.Status = "failed" d.Error = &unavailableErr d.CompletedAt = &completedAt } else { - _, err = db.(*database.DB).Exec( - `UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, - time.Now(), serviceID, - ) + err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "building", Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: serviceID, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"}) return @@ -331,14 +360,17 @@ func handleCreateDeployment(c *gin.Context) { }) if pos > 0 { d.Status = "queued" - _, _ = db.(*database.DB).Exec( - `UPDATE deployments SET status = 'queued', updated_at = $1 WHERE id = $2`, - time.Now(), d.ID, - ) - _, _ = db.(*database.DB).Exec( - `UPDATE services SET status = 'queued', updated_at = $1 WHERE id = $2`, - time.Now(), serviceID, - ) + now := time.Now() + _ = q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{ + Status: sql.NullString{String: "queued", Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + ID: d.ID, + }) + _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "queued", Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + ID: serviceID, + }) } } @@ -380,6 +412,7 @@ func runDeploymentAndSyncWithImage( ) { ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute) defer cancel() + q := sqlcdb.New(db.DB) // The checkout and any shipped artifacts die with this deployment. var artifactIDs []string @@ -392,10 +425,11 @@ func runDeploymentAndSyncWithImage( }() failDeploy := func(msg string) { failedAt := time.Now() - _, _ = db.Exec( - `UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, - msg, failedAt, dbDeployment.ID, - ) + _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{ + Error: sql.NullString{String: msg, Valid: true}, + CompletedAt: sql.NullTime{Time: failedAt, Valid: true}, + ID: dbDeployment.ID, + }) } sourcePath := strings.TrimSpace(service.BuildPath) @@ -423,11 +457,10 @@ func runDeploymentAndSyncWithImage( replicas = 1 } - var publishedPort int32 // Best effort: reuse the host port from the last live deployment so public // URLs survive redeploys. Column exists post-migration; older DBs get // ephemeral ports. - _ = db.QueryRow(`SELECT published_port FROM services WHERE id = $1`, service.ID).Scan(&publishedPort) + publishedPort, _ := q.GetServicePublishedPort(ctx, service.ID) publicPort := int32(service.Port) if service.Builder == "static" && publicPort == 0 { @@ -451,7 +484,9 @@ func runDeploymentAndSyncWithImage( // remote builds package it into a context artifact the node builds. if service.GitRepo != "" && imageOverride == "" { var ownerID string - _ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID) + if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil { + ownerID = o.String() + } branch := req.Branch if branch == "" { branch = service.GitBranch @@ -535,7 +570,9 @@ func runDeploymentAndSyncWithImage( } } else { var ownerID string - _ = db.QueryRow(`SELECT owner_id FROM projects WHERE id = $1`, service.ProjectID).Scan(&ownerID) + if o, err := q.GetProjectOwner(ctx, service.ProjectID); err == nil { + ownerID = o.String() + } auth := registryAuthFor(db, ownerID, image) deployReq.BuildConfig = &deployment.BuildConfig{ BuildType: "prebuilt", @@ -605,10 +642,11 @@ func runDeploymentAndSyncWithImage( if msg := capacityCheck(parentCtx, db, engine.DockerClient(), service, replicas); msg != "" { failedAt := time.Now() - _, _ = db.Exec( - `UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, - msg, failedAt, dbDeployment.ID, - ) + _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{ + Error: sql.NullString{String: msg, Valid: true}, + CompletedAt: sql.NullTime{Time: failedAt, Valid: true}, + ID: dbDeployment.ID, + }) return } @@ -616,16 +654,16 @@ func runDeploymentAndSyncWithImage( if err != nil { failedAt := time.Now() failure := "Failed to start deployment engine: " + err.Error() - _, _ = db.Exec( - `UPDATE deployments - SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 - WHERE id = $3`, - failure, failedAt, dbDeployment.ID, - ) - _, _ = db.Exec( - `UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, - failedAt, service.ID, - ) + _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{ + Error: sql.NullString{String: failure, Valid: true}, + CompletedAt: sql.NullTime{Time: failedAt, Valid: true}, + ID: dbDeployment.ID, + }) + _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "failed", Valid: true}, + UpdatedAt: sql.NullTime{Time: failedAt, Valid: true}, + ID: service.ID, + }) return } @@ -640,16 +678,17 @@ func runDeploymentAndSyncWithImage( if errors.Is(ctx.Err(), context.Canceled) { finalStatus, finalErr = "cancelled", "Deployment cancelled" } - _, _ = db.Exec( - `UPDATE deployments - SET status = $1, error = $2, completed_at = $3, updated_at = $3 - WHERE id = $4`, - finalStatus, finalErr, failedAt, dbDeployment.ID, - ) - _, _ = db.Exec( - `UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, - finalStatus, failedAt, service.ID, - ) + _ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{ + Status: sql.NullString{String: finalStatus, Valid: true}, + Error: sql.NullString{String: finalErr, Valid: true}, + CompletedAt: sql.NullTime{Time: failedAt, Valid: true}, + ID: dbDeployment.ID, + }) + _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: finalStatus, Valid: true}, + UpdatedAt: sql.NullTime{Time: failedAt, Valid: true}, + ID: service.ID, + }) return case <-syncTicker.C: current, getErr := engine.GetDeployment(engineDeployment.ID) @@ -660,49 +699,36 @@ func runDeploymentAndSyncWithImage( dbStatus := mapEngineStatusToDBStatus(current.Status) imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag) - var dbError interface{} - if current.Error != "" { - dbError = current.Error - } - - _, _ = db.Exec( - `UPDATE deployments - SET status = $1, - image_name = $2, - image_tag = $3, - build_log = $4, - runtime_log = $5, - error = $6, - started_at = $7, - completed_at = $8, - updated_at = $9 - WHERE id = $10`, - dbStatus, - imageName, - imageTag, - current.BuildLog, - current.DeployLog, - dbError, - current.StartedAt, - current.CompletedAt, - time.Now(), - dbDeployment.ID, - ) + now := time.Now() + _ = q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{ + Status: sql.NullString{String: dbStatus, Valid: true}, + ImageName: sql.NullString{String: imageName, Valid: true}, + ImageTag: sql.NullString{String: imageTag, Valid: true}, + BuildLog: sql.NullString{String: current.BuildLog, Valid: true}, + RuntimeLog: sql.NullString{String: current.DeployLog, Valid: true}, + Error: sql.NullString{String: current.Error, Valid: current.Error != ""}, + StartedAt: ntPtr(current.StartedAt), + CompletedAt: ntPtr(current.CompletedAt), + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + ID: dbDeployment.ID, + }) switch dbStatus { case "deployed": - _, _ = db.Exec( - `UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, - time.Now(), service.ID, - ) + _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "running", Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + ID: service.ID, + }) insertUserNotification(db, userID, "deployment", "Deployment succeeded", fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String()) return case "failed": - _, _ = db.Exec( - `UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, - time.Now(), service.ID, - ) + _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "failed", Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + ID: service.ID, + }) body := fmt.Sprintf("Service %s failed to deploy.", service.Name) if current.Error != "" { body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error) @@ -754,21 +780,16 @@ func handleCancelDeployment(c *gin.Context) { return } - var status, owner string - var serviceID uuid.UUID - err = db.(*database.DB).QueryRow( - `SELECT d.status, d.service_id, p.owner_id - FROM deployments d - JOIN services s ON d.service_id = s.id - JOIN projects p ON s.project_id = p.id - WHERE d.id = $1`, - deploymentID, - ).Scan(&status, &serviceID, &owner) + ctx := c.Request.Context() + q := sqlcdb.New(db.(*database.DB).DB) + access, err := q.GetDeploymentAccess(ctx, deploymentID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found", "code": "NOT_FOUND"}) return } - if owner != userID.(string) && !contextIsAdmin(c) { + serviceID := access.ServiceID + status := access.Status.String + if access.OwnerID.String() != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied", "code": "FORBIDDEN"}) return } @@ -782,14 +803,17 @@ func handleCancelDeployment(c *gin.Context) { switch getDeployQueue(c).Cancel(serviceID, deploymentID) { case deployqueue.WasQueued: now := time.Now() - _, _ = db.(*database.DB).Exec( - `UPDATE deployments SET status = 'cancelled', error = 'Deployment cancelled', completed_at = $1, updated_at = $1 WHERE id = $2`, - now, deploymentID, - ) - _, _ = db.(*database.DB).Exec( - `UPDATE services SET status = 'cancelled', updated_at = $1 WHERE id = $2`, - now, serviceID, - ) + _ = q.CompleteDeployment(ctx, sqlcdb.CompleteDeploymentParams{ + Status: sql.NullString{String: "cancelled", Valid: true}, + Error: sql.NullString{String: "Deployment cancelled", Valid: true}, + CompletedAt: sql.NullTime{Time: now, Valid: true}, + ID: deploymentID, + }) + _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "cancelled", Valid: true}, + UpdatedAt: sql.NullTime{Time: now, Valid: true}, + ID: serviceID, + }) c.JSON(http.StatusOK, gin.H{"status": "cancelled"}) case deployqueue.WasActive: c.JSON(http.StatusAccepted, gin.H{"status": "cancelling"}) @@ -832,27 +856,18 @@ func handleGetDeployment(c *gin.Context) { return } - var d DeploymentModel - var projectID uuid.UUID - err = db.(*database.DB).QueryRow( - `SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, - d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, - d.created_at, d.updated_at, p.id - FROM deployments d - JOIN services s ON d.service_id = s.id - JOIN projects p ON s.project_id = p.id - WHERE d.id = $1`, - deploymentID, - ).Scan( - &d.ID, &d.ServiceID, &d.CommitHash, &d.Status, &d.ImageName, &d.ImageTag, - &d.BuildLog, &d.RuntimeLog, &d.Error, &d.StartedAt, &d.CompletedAt, - &d.CreatedAt, &d.UpdatedAt, &projectID, - ) - + r, err := sqlcdb.New(db.(*database.DB).DB).GetDeploymentWithProject(c.Request.Context(), deploymentID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) return } + projectID := r.ProjectID + d := deploymentModelFrom(deploymentRowShape{ + ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status, + ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog, + RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt, + CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }) if _, allowed := projectReadAccess(c, db.(*database.DB), projectID); !allowed { c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) @@ -882,33 +897,23 @@ func handleRollbackDeployment(c *gin.Context) { return } - var targetDeployment DeploymentModel - var serviceID uuid.UUID - var ownerCheck string - - err = db.(*database.DB).QueryRow( - `SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, - d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, - d.created_at, d.updated_at, p.owner_id - FROM deployments d - JOIN services s ON d.service_id = s.id - JOIN projects p ON s.project_id = p.id - WHERE d.id = $1`, - deploymentID, - ).Scan( - &targetDeployment.ID, &serviceID, &targetDeployment.CommitHash, &targetDeployment.Status, - &targetDeployment.ImageName, &targetDeployment.ImageTag, &targetDeployment.BuildLog, - &targetDeployment.RuntimeLog, &targetDeployment.Error, &targetDeployment.StartedAt, - &targetDeployment.CompletedAt, &targetDeployment.CreatedAt, &targetDeployment.UpdatedAt, - &ownerCheck, - ) - + ctx := c.Request.Context() + q := sqlcdb.New(db.(*database.DB).DB) + r, err := q.GetDeploymentForRollback(ctx, deploymentID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Deployment not found"}) return } + serviceID := r.ServiceID + targetDeployment := deploymentModelFrom(deploymentRowShape{ + ID: r.ID, ServiceID: r.ServiceID, CommitHash: r.CommitHash, Status: r.Status, + ImageName: r.ImageName, ImageTag: r.ImageTag, BuildLog: r.BuildLog, + RuntimeLog: r.RuntimeLog, Error: r.Error, StartedAt: r.StartedAt, + CompletedAt: r.CompletedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + }) + ownerCheck := r.OwnerID - if ownerCheck != userID.(string) && !contextIsAdmin(c) { + if ownerCheck.String() != userID.(string) && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) return } @@ -931,23 +936,28 @@ func handleRollbackDeployment(c *gin.Context) { UpdatedAt: now, } - _, err = db.(*database.DB).Exec( - `INSERT INTO deployments - (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status, - rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt, - ) + err = q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{ + ID: rollback.ID, + ServiceID: rollback.ServiceID, + Version: fmt.Sprintf("rollback-%d", now.Unix()), + CommitHash: sql.NullString{String: ptrStr(rollback.CommitHash), Valid: rollback.CommitHash != nil}, + Status: sql.NullString{String: rollback.Status, Valid: true}, + ImageName: sql.NullString{String: rollback.ImageName, Valid: true}, + ImageTag: sql.NullString{String: rollback.ImageTag, Valid: true}, + CreatedAt: sql.NullTime{Time: rollback.CreatedAt, Valid: true}, + UpdatedAt: sql.NullTime{Time: rollback.UpdatedAt, Valid: true}, + }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create rollback deployment"}) return } - _, err = db.(*database.DB).Exec( - `UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, - time.Now(), serviceID, - ) + err = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "building", Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: serviceID, + }) // Real rollback: redeploy the target deployment's image. engineValue, _ := c.Get("deployment_engine") @@ -963,36 +973,18 @@ func handleRollbackDeployment(c *gin.Context) { if engine == nil { reason = "Deployment engine unavailable. Docker may not be configured on this server." } - _, _ = db.(*database.DB).Exec( - `UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, - reason, completedAt, rollbackID, - ) - _, _ = db.(*database.DB).Exec( - `UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, - completedAt, serviceID, - ) + _ = q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{ + Error: sql.NullString{String: reason, Valid: true}, + CompletedAt: sql.NullTime{Time: completedAt, Valid: true}, + ID: rollbackID, + }) + _ = q.SetServiceStatus(ctx, sqlcdb.SetServiceStatusParams{ + Status: sql.NullString{String: "failed", Valid: true}, + UpdatedAt: sql.NullTime{Time: completedAt, Valid: true}, + ID: serviceID, + }) } else { - var service Service - _ = db.(*database.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, - ) + service, _ := loadServiceForDeploy(db.(*database.DB), serviceID) rollbackReq := CreateDeploymentRequest{ CommitHash: func() string { diff --git a/app/backend/internal/api/domains_test.go b/app/backend/internal/api/domains_test.go index 7868366..08e6ded 100644 --- a/app/backend/internal/api/domains_test.go +++ b/app/backend/internal/api/domains_test.go @@ -2,10 +2,11 @@ package api import ( "context" + "database/sql" "os" "strings" - "time" "testing" + "time" "containr/internal/database" "containr/internal/database/sqlcdb" @@ -338,3 +339,108 @@ func TestPreviewEnvironmentsLiveInvariants(t *testing.T) { } must(q.DeletePreviewEnvironment(ctx, previewID)) } + +// Live deployment lifecycle: generated queries preserve list ordering, +// the access join, and the status-transition writes. +func TestDeploymentsLiveInvariants(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() + serviceID := uuid.New() + deployID := uuid.New() + suffix := strings.ReplaceAll(deployID.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, "dep-"+suffix+"@test.local") + must(err) + _, err = db.Exec(`INSERT INTO projects (id, name, owner_id, is_approved) VALUES ($1, $2, $3, true)`, + projectID, "dep-"+suffix, userID) + must(err) + _, err = db.Exec(`INSERT INTO environments (id, name, project_id) VALUES ($1, 'production', $2)`, + envID, projectID) + must(err) + _, err = db.Exec(`INSERT INTO services (id, name, project_id, environment_id, service_type, source_type, status) + VALUES ($1, $2, $3, $4, 'web', 'image', 'running')`, + serviceID, "dep-"+suffix, projectID, envID) + must(err) + defer func() { _, _ = db.Exec(`DELETE FROM users WHERE id = $1`, userID) }() + + must(q.InsertDeployment(ctx, sqlcdb.InsertDeploymentParams{ + ID: deployID, + ServiceID: serviceID, + Version: "v1-" + suffix, + Status: sql.NullString{String: "pending", Valid: true}, + })) + total, err := q.CountDeploymentsForService(ctx, serviceID) + if err != nil || total != 1 { + t.Fatalf("count: %v %d", err, total) + } + rows, err := q.ListDeploymentsForService(ctx, sqlcdb.ListDeploymentsForServiceParams{ + ServiceID: serviceID, Limit: 10, Offset: 0}) + if err != nil || len(rows) != 1 || rows[0].ID != deployID { + t.Fatalf("list: %v %+v", err, rows) + } + + // Access join returns owner. + access, err := q.GetDeploymentAccess(ctx, deployID) + if err != nil || access.OwnerID != userID || access.ServiceID != serviceID { + t.Fatalf("access: %v %+v", err, access) + } + + // Recent feed honors the access predicate. + feed, err := q.ListRecentAccessibleDeployments(ctx, sqlcdb.ListRecentAccessibleDeploymentsParams{ + OwnerID: userID, Limit: 10, Offset: 0}) + if err != nil { + t.Fatalf("feed: %v", err) + } + found := false + for _, d := range feed { + if d.ID == deployID && d.ServiceName == "dep-"+suffix { + found = true + } + } + if !found { + t.Fatalf("feed missing deployment: %+v", feed) + } + + // Status transitions. + must(q.SetDeploymentStatus(ctx, sqlcdb.SetDeploymentStatusParams{ + Status: sql.NullString{String: "queued", Valid: true}, UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, ID: deployID})) + must(q.SyncDeploymentProgress(ctx, sqlcdb.SyncDeploymentProgressParams{ + Status: sql.NullString{String: "deployed", Valid: true}, + ImageName: sql.NullString{String: "nginx", Valid: true}, + ImageTag: sql.NullString{String: "alpine", Valid: true}, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: deployID, + })) + r, err := q.GetDeploymentWithProject(ctx, deployID) + if err != nil { + t.Fatalf("get: %v", err) + } + if r.Status.String != "deployed" || r.ImageName.String != "nginx" || r.ProjectID != projectID { + t.Fatalf("post-sync row: %+v", r) + } + must(q.FailDeployment(ctx, sqlcdb.FailDeploymentParams{ + Error: sql.NullString{String: "boom", Valid: true}, + CompletedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: deployID, + })) + r, _ = q.GetDeploymentWithProject(ctx, deployID) + if r.Status.String != "failed" || r.Error.String != "boom" { + t.Fatalf("post-fail row: %+v", r) + } +} diff --git a/app/backend/internal/api/preview_environments.go b/app/backend/internal/api/preview_environments.go index e47b82b..2ef107a 100644 --- a/app/backend/internal/api/preview_environments.go +++ b/app/backend/internal/api/preview_environments.go @@ -775,11 +775,7 @@ 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) { - r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID) - if err != nil { - return Service{}, err - } +func serviceForDeployFromRow(r sqlcdb.GetServiceForDeployRow) Service { 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, @@ -791,7 +787,15 @@ func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) Builder: r.Builder, CPUReserve: r.CpuReserve, MemoryReserve: r.MemoryReserve, StaticBuildCmd: r.StaticBuildCmd, StaticDir: r.StaticDir, CreatedAt: r.CreatedAt.Time, UpdatedAt: r.UpdatedAt.Time, - }, nil + } +} + +func loadServiceForDeploy(db *database.DB, serviceID uuid.UUID) (Service, error) { + r, err := sqlcdb.New(db.DB).GetServiceForDeploy(context.Background(), serviceID) + if err != nil { + return Service{}, err + } + return serviceForDeployFromRow(r), nil } // StartPreviewSweeper expires preview environments past their TTL — tears diff --git a/app/backend/internal/database/sqlcdb/deployments.sql.go b/app/backend/internal/database/sqlcdb/deployments.sql.go new file mode 100644 index 0000000..a8ae9ae --- /dev/null +++ b/app/backend/internal/database/sqlcdb/deployments.sql.go @@ -0,0 +1,536 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: deployments.sql + +package sqlcdb + +import ( + "context" + "database/sql" + + "github.com/google/uuid" +) + +const completeDeployment = `-- name: CompleteDeployment :exec +UPDATE deployments +SET status = $1, error = $2, completed_at = $3, updated_at = $3 +WHERE id = $4 +` + +type CompleteDeploymentParams struct { + Status sql.NullString `json:"status"` + Error sql.NullString `json:"error"` + CompletedAt sql.NullTime `json:"completed_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error { + _, err := q.db.ExecContext(ctx, completeDeployment, + arg.Status, + arg.Error, + arg.CompletedAt, + arg.ID, + ) + return err +} + +const countAccessibleDeployments = `-- name: CountAccessibleDeployments :one +SELECT COUNT(*) +FROM deployments d +JOIN services s ON s.id = d.service_id +JOIN projects p ON p.id = s.project_id +WHERE p.is_approved OR p.owner_id = $1 OR $2::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) +` + +type CountAccessibleDeploymentsParams struct { + OwnerID uuid.UUID `json:"owner_id"` + Column2 bool `json:"column_2"` +} + +func (q *Queries) CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countAccessibleDeployments, arg.OwnerID, arg.Column2) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countDeploymentsForService = `-- name: CountDeploymentsForService :one +SELECT COUNT(*) FROM deployments WHERE service_id = $1 +` + +func (q *Queries) CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error) { + row := q.db.QueryRowContext(ctx, countDeploymentsForService, serviceID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const failDeployment = `-- name: FailDeployment :exec +UPDATE deployments +SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 +WHERE id = $3 +` + +type FailDeploymentParams struct { + Error sql.NullString `json:"error"` + CompletedAt sql.NullTime `json:"completed_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) FailDeployment(ctx context.Context, arg FailDeploymentParams) error { + _, err := q.db.ExecContext(ctx, failDeployment, arg.Error, arg.CompletedAt, arg.ID) + return err +} + +const getDeploymentAccess = `-- name: GetDeploymentAccess :one +SELECT d.status, d.service_id, p.owner_id +FROM deployments d +JOIN services s ON d.service_id = s.id +JOIN projects p ON s.project_id = p.id +WHERE d.id = $1 +` + +type GetDeploymentAccessRow struct { + Status sql.NullString `json:"status"` + ServiceID uuid.UUID `json:"service_id"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error) { + row := q.db.QueryRowContext(ctx, getDeploymentAccess, id) + var i GetDeploymentAccessRow + err := row.Scan(&i.Status, &i.ServiceID, &i.OwnerID) + return i, err +} + +const getDeploymentForRollback = `-- name: GetDeploymentForRollback :one +SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, + d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, + d.created_at, d.updated_at, p.owner_id +FROM deployments d +JOIN services s ON d.service_id = s.id +JOIN projects p ON s.project_id = p.id +WHERE d.id = $1 +` + +type GetDeploymentForRollbackRow struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + CommitHash sql.NullString `json:"commit_hash"` + Status sql.NullString `json:"status"` + ImageName sql.NullString `json:"image_name"` + ImageTag sql.NullString `json:"image_tag"` + BuildLog sql.NullString `json:"build_log"` + RuntimeLog sql.NullString `json:"runtime_log"` + Error sql.NullString `json:"error"` + StartedAt sql.NullTime `json:"started_at"` + CompletedAt sql.NullTime `json:"completed_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error) { + row := q.db.QueryRowContext(ctx, getDeploymentForRollback, id) + var i GetDeploymentForRollbackRow + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.CommitHash, + &i.Status, + &i.ImageName, + &i.ImageTag, + &i.BuildLog, + &i.RuntimeLog, + &i.Error, + &i.StartedAt, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + ) + return i, err +} + +const getDeploymentWithProject = `-- name: GetDeploymentWithProject :one +SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, + d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, + d.created_at, d.updated_at, p.id AS project_id +FROM deployments d +JOIN services s ON d.service_id = s.id +JOIN projects p ON s.project_id = p.id +WHERE d.id = $1 +` + +type GetDeploymentWithProjectRow struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + CommitHash sql.NullString `json:"commit_hash"` + Status sql.NullString `json:"status"` + ImageName sql.NullString `json:"image_name"` + ImageTag sql.NullString `json:"image_tag"` + BuildLog sql.NullString `json:"build_log"` + RuntimeLog sql.NullString `json:"runtime_log"` + Error sql.NullString `json:"error"` + StartedAt sql.NullTime `json:"started_at"` + CompletedAt sql.NullTime `json:"completed_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + ProjectID uuid.UUID `json:"project_id"` +} + +func (q *Queries) GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error) { + row := q.db.QueryRowContext(ctx, getDeploymentWithProject, id) + var i GetDeploymentWithProjectRow + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.CommitHash, + &i.Status, + &i.ImageName, + &i.ImageTag, + &i.BuildLog, + &i.RuntimeLog, + &i.Error, + &i.StartedAt, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ProjectID, + ) + return i, err +} + +const getServiceForDeployWithOwner = `-- name: GetServiceForDeployWithOwner :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, p.owner_id +FROM services s +JOIN projects p ON s.project_id = p.id +WHERE s.id = $1 +` + +type GetServiceForDeployWithOwnerRow 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"` + OwnerID uuid.UUID `json:"owner_id"` +} + +func (q *Queries) GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error) { + row := q.db.QueryRowContext(ctx, getServiceForDeployWithOwner, id) + var i GetServiceForDeployWithOwnerRow + 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, + &i.OwnerID, + ) + return i, err +} + +const getServicePublishedPort = `-- name: GetServicePublishedPort :one +SELECT published_port FROM services WHERE id = $1 +` + +func (q *Queries) GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, error) { + row := q.db.QueryRowContext(ctx, getServicePublishedPort, id) + var published_port int32 + err := row.Scan(&published_port) + return published_port, err +} + +const insertDeployment = `-- name: InsertDeployment :exec +INSERT INTO deployments + (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +` + +type InsertDeploymentParams struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + Version string `json:"version"` + CommitHash sql.NullString `json:"commit_hash"` + Status sql.NullString `json:"status"` + ImageName sql.NullString `json:"image_name"` + ImageTag sql.NullString `json:"image_tag"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error { + _, err := q.db.ExecContext(ctx, insertDeployment, + arg.ID, + arg.ServiceID, + arg.Version, + arg.CommitHash, + arg.Status, + arg.ImageName, + arg.ImageTag, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const listDeploymentsForService = `-- name: ListDeploymentsForService :many +SELECT id, service_id, commit_hash, status, image_name, image_tag, + build_log, runtime_log, error, started_at, completed_at, created_at, updated_at +FROM deployments +WHERE service_id = $1 +ORDER BY created_at DESC +LIMIT $2 OFFSET $3 +` + +type ListDeploymentsForServiceParams struct { + ServiceID uuid.UUID `json:"service_id"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListDeploymentsForServiceRow struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + CommitHash sql.NullString `json:"commit_hash"` + Status sql.NullString `json:"status"` + ImageName sql.NullString `json:"image_name"` + ImageTag sql.NullString `json:"image_tag"` + BuildLog sql.NullString `json:"build_log"` + RuntimeLog sql.NullString `json:"runtime_log"` + Error sql.NullString `json:"error"` + StartedAt sql.NullTime `json:"started_at"` + CompletedAt sql.NullTime `json:"completed_at"` + CreatedAt sql.NullTime `json:"created_at"` + UpdatedAt sql.NullTime `json:"updated_at"` +} + +func (q *Queries) ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error) { + rows, err := q.db.QueryContext(ctx, listDeploymentsForService, arg.ServiceID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListDeploymentsForServiceRow{} + for rows.Next() { + var i ListDeploymentsForServiceRow + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.CommitHash, + &i.Status, + &i.ImageName, + &i.ImageTag, + &i.BuildLog, + &i.RuntimeLog, + &i.Error, + &i.StartedAt, + &i.CompletedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); 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 listRecentAccessibleDeployments = `-- name: ListRecentAccessibleDeployments :many +SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status, + COALESCE(d.image_name, '') AS image_name, d.started_at, d.completed_at, d.created_at +FROM deployments d +JOIN services s ON s.id = d.service_id +JOIN projects p ON p.id = s.project_id +WHERE p.is_approved OR p.owner_id = $1 OR $2::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) +ORDER BY d.created_at DESC +LIMIT $3 OFFSET $4 +` + +type ListRecentAccessibleDeploymentsParams struct { + OwnerID uuid.UUID `json:"owner_id"` + Column2 bool `json:"column_2"` + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListRecentAccessibleDeploymentsRow struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + ServiceName string `json:"service_name"` + ProjectName string `json:"project_name"` + Status sql.NullString `json:"status"` + ImageName string `json:"image_name"` + StartedAt sql.NullTime `json:"started_at"` + CompletedAt sql.NullTime `json:"completed_at"` + CreatedAt sql.NullTime `json:"created_at"` +} + +func (q *Queries) ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error) { + rows, err := q.db.QueryContext(ctx, listRecentAccessibleDeployments, + arg.OwnerID, + arg.Column2, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListRecentAccessibleDeploymentsRow{} + for rows.Next() { + var i ListRecentAccessibleDeploymentsRow + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.ServiceName, + &i.ProjectName, + &i.Status, + &i.ImageName, + &i.StartedAt, + &i.CompletedAt, + &i.CreatedAt, + ); 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 setDeploymentStatus = `-- name: SetDeploymentStatus :exec +UPDATE deployments SET status = $1, updated_at = $2 WHERE id = $3 +` + +type SetDeploymentStatusParams struct { + Status sql.NullString `json:"status"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error { + _, err := q.db.ExecContext(ctx, setDeploymentStatus, arg.Status, arg.UpdatedAt, arg.ID) + return err +} + +const setServiceStatus = `-- name: SetServiceStatus :exec +UPDATE services SET status = $1, updated_at = $2 WHERE id = $3 +` + +type SetServiceStatusParams struct { + Status sql.NullString `json:"status"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error { + _, err := q.db.ExecContext(ctx, setServiceStatus, arg.Status, arg.UpdatedAt, arg.ID) + return err +} + +const syncDeploymentProgress = `-- name: SyncDeploymentProgress :exec +UPDATE deployments +SET status = $1, + image_name = $2, + image_tag = $3, + build_log = $4, + runtime_log = $5, + error = $6, + started_at = $7, + completed_at = $8, + updated_at = $9 +WHERE id = $10 +` + +type SyncDeploymentProgressParams struct { + Status sql.NullString `json:"status"` + ImageName sql.NullString `json:"image_name"` + ImageTag sql.NullString `json:"image_tag"` + BuildLog sql.NullString `json:"build_log"` + RuntimeLog sql.NullString `json:"runtime_log"` + Error sql.NullString `json:"error"` + StartedAt sql.NullTime `json:"started_at"` + CompletedAt sql.NullTime `json:"completed_at"` + UpdatedAt sql.NullTime `json:"updated_at"` + ID uuid.UUID `json:"id"` +} + +func (q *Queries) SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error { + _, err := q.db.ExecContext(ctx, syncDeploymentProgress, + arg.Status, + arg.ImageName, + arg.ImageTag, + arg.BuildLog, + arg.RuntimeLog, + arg.Error, + arg.StartedAt, + arg.CompletedAt, + arg.UpdatedAt, + arg.ID, + ) + return err +} diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index b6b3bb9..1de92c7 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -22,9 +22,12 @@ 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) + CompleteDeployment(ctx context.Context, arg CompleteDeploymentParams) error + CountAccessibleDeployments(ctx context.Context, arg CountAccessibleDeploymentsParams) (int64, 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) + CountDeploymentsForService(ctx context.Context, serviceID uuid.UUID) (int64, error) CountEnvironmentServices(ctx context.Context, environmentID uuid.UUID) (int64, error) CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error) CountServiceDomains(ctx context.Context, serviceID uuid.UUID) (int64, error) @@ -68,6 +71,7 @@ type Querier interface { DeleteServiceDomain(ctx context.Context, arg DeleteServiceDomainParams) (int64, error) DeleteUserInvite(ctx context.Context, id uuid.UUID) error DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error) + FailDeployment(ctx context.Context, arg FailDeploymentParams) error GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) GetAgent(ctx context.Context, id string) (NodeAgent, error) GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error) @@ -81,6 +85,9 @@ type Querier interface { GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error) GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error) GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error) + GetDeploymentAccess(ctx context.Context, id uuid.UUID) (GetDeploymentAccessRow, error) + GetDeploymentForRollback(ctx context.Context, id uuid.UUID) (GetDeploymentForRollbackRow, error) + GetDeploymentWithProject(ctx context.Context, id uuid.UUID) (GetDeploymentWithProjectRow, error) 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) @@ -101,12 +108,15 @@ type Querier interface { 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) + GetServiceForDeployWithOwner(ctx context.Context, id uuid.UUID) (GetServiceForDeployWithOwnerRow, error) GetServicePlacementBrief(ctx context.Context, id uuid.UUID) (GetServicePlacementBriefRow, error) + GetServicePublishedPort(ctx context.Context, id uuid.UUID) (int32, 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 + InsertDeployment(ctx context.Context, arg InsertDeploymentParams) error InsertNotificationChannel(ctx context.Context, arg InsertNotificationChannelParams) (NotificationChannel, error) InsertPendingDeployment(ctx context.Context, arg InsertPendingDeploymentParams) error InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error @@ -123,6 +133,7 @@ type Querier interface { ListDatabaseBackupsByDatabaseAndUser(ctx context.Context, arg ListDatabaseBackupsByDatabaseAndUserParams) ([]DatabaseBackup, error) ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error) ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error) + ListDeploymentsForService(ctx context.Context, arg ListDeploymentsForServiceParams) ([]ListDeploymentsForServiceRow, error) ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) ListExpiredPreviewsForUser(ctx context.Context, arg ListExpiredPreviewsForUserParams) ([]ListExpiredPreviewsForUserRow, error) @@ -139,6 +150,7 @@ type Querier interface { // display, when the caller owns or is a member of it, or when the caller is a // platform admin. Anonymous callers pass user_id = uuid.Nil, is_admin = false. ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error) + ListRecentAccessibleDeployments(ctx context.Context, arg ListRecentAccessibleDeploymentsParams) ([]ListRecentAccessibleDeploymentsRow, error) // Base domains of every online, schedulable agent carrying the required // placement tags — auto-domain candidates for spread services. ListSchedulableAgentDomains(ctx context.Context, dollar_1 json.RawMessage) ([]string, error) @@ -183,9 +195,12 @@ type Querier interface { SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error + SetDeploymentStatus(ctx context.Context, arg SetDeploymentStatusParams) error SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error) SetServiceDomainDefault(ctx context.Context, arg SetServiceDomainDefaultParams) (int64, error) SetServiceLegacyDomain(ctx context.Context, arg SetServiceLegacyDomainParams) error + SetServiceStatus(ctx context.Context, arg SetServiceStatusParams) error + SyncDeploymentProgress(ctx context.Context, arg SyncDeploymentProgressParams) error SyncPreviewStatuses(ctx context.Context) error TouchAgentAuthToken(ctx context.Context, tokenHash string) error TouchServiceDomainChecked(ctx context.Context, id uuid.UUID) error diff --git a/app/backend/sqlc/queries/deployments.sql b/app/backend/sqlc/queries/deployments.sql new file mode 100644 index 0000000..bdc093f --- /dev/null +++ b/app/backend/sqlc/queries/deployments.sql @@ -0,0 +1,105 @@ +-- name: CountDeploymentsForService :one +SELECT COUNT(*) FROM deployments WHERE service_id = $1; + +-- name: ListDeploymentsForService :many +SELECT id, service_id, commit_hash, status, image_name, image_tag, + build_log, runtime_log, error, started_at, completed_at, created_at, updated_at +FROM deployments +WHERE service_id = $1 +ORDER BY created_at DESC +LIMIT $2 OFFSET $3; + +-- name: CountAccessibleDeployments :one +SELECT COUNT(*) +FROM deployments d +JOIN services s ON s.id = d.service_id +JOIN projects p ON p.id = s.project_id +WHERE p.is_approved OR p.owner_id = $1 OR $2::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1); + +-- name: ListRecentAccessibleDeployments :many +SELECT d.id, d.service_id, s.name AS service_name, p.name AS project_name, d.status, + COALESCE(d.image_name, '') AS image_name, d.started_at, d.completed_at, d.created_at +FROM deployments d +JOIN services s ON s.id = d.service_id +JOIN projects p ON p.id = s.project_id +WHERE p.is_approved OR p.owner_id = $1 OR $2::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1) +ORDER BY d.created_at DESC +LIMIT $3 OFFSET $4; + +-- name: GetServiceForDeployWithOwner :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, p.owner_id +FROM services s +JOIN projects p ON s.project_id = p.id +WHERE s.id = $1; + +-- name: InsertDeployment :exec +INSERT INTO deployments + (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9); + +-- name: FailDeployment :exec +UPDATE deployments +SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 +WHERE id = $3; + +-- name: CompleteDeployment :exec +UPDATE deployments +SET status = $1, error = $2, completed_at = $3, updated_at = $3 +WHERE id = $4; + +-- name: SetDeploymentStatus :exec +UPDATE deployments SET status = $1, updated_at = $2 WHERE id = $3; + +-- name: SetServiceStatus :exec +UPDATE services SET status = $1, updated_at = $2 WHERE id = $3; + +-- name: SyncDeploymentProgress :exec +UPDATE deployments +SET status = $1, + image_name = $2, + image_tag = $3, + build_log = $4, + runtime_log = $5, + error = $6, + started_at = $7, + completed_at = $8, + updated_at = $9 +WHERE id = $10; + +-- name: GetDeploymentAccess :one +SELECT d.status, d.service_id, p.owner_id +FROM deployments d +JOIN services s ON d.service_id = s.id +JOIN projects p ON s.project_id = p.id +WHERE d.id = $1; + +-- name: GetDeploymentWithProject :one +SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, + d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, + d.created_at, d.updated_at, p.id AS project_id +FROM deployments d +JOIN services s ON d.service_id = s.id +JOIN projects p ON s.project_id = p.id +WHERE d.id = $1; + +-- name: GetDeploymentForRollback :one +SELECT d.id, d.service_id, d.commit_hash, d.status, d.image_name, d.image_tag, + d.build_log, d.runtime_log, d.error, d.started_at, d.completed_at, + d.created_at, d.updated_at, p.owner_id +FROM deployments d +JOIN services s ON d.service_id = s.id +JOIN projects p ON s.project_id = p.id +WHERE d.id = $1; + +-- name: GetServicePublishedPort :one +SELECT published_port FROM services WHERE id = $1;