diff --git a/packages/api/internal/cache/templates/cache.go b/packages/api/internal/cache/templates/cache.go index b3f8a87df4..beb48e94ee 100644 --- a/packages/api/internal/cache/templates/cache.go +++ b/packages/api/internal/cache/templates/cache.go @@ -175,7 +175,7 @@ func (c *TemplateCache) fetchTemplateWithBuild(templateID string, tag *string) f } build := &result.EnvBuild - template := result.Env + template := result.ActiveEnv clusterID := clusters.WithClusterFallback(template.ClusterID) tagValue := sharedUtils.DerefOrDefault(tag, id.DefaultTag) diff --git a/packages/api/internal/cache/templates/template_build.go b/packages/api/internal/cache/templates/template_build.go index 31589aab75..c1c185fb93 100644 --- a/packages/api/internal/cache/templates/template_build.go +++ b/packages/api/internal/cache/templates/template_build.go @@ -84,12 +84,12 @@ func (c *TemplatesBuildCache) fetchFromDB(templateID string, buildID uuid.UUID) } return TemplateBuildInfo{ - TeamID: result.Env.TeamID, - TemplateID: result.Env.ID, + TeamID: result.ActiveEnv.TeamID, + TemplateID: result.ActiveEnv.ID, BuildStatus: result.EnvBuild.StatusGroup, Reason: result.EnvBuild.Reason, Version: result.EnvBuild.Version, - ClusterID: clusters.WithClusterFallback(result.Env.ClusterID), + ClusterID: clusters.WithClusterFallback(result.ActiveEnv.ClusterID), NodeID: result.EnvBuild.ClusterNodeID, }, nil } diff --git a/packages/api/internal/handlers/deprecated_template_request_build.go b/packages/api/internal/handlers/deprecated_template_request_build.go index 844ed2cca7..bcd6bb69cc 100644 --- a/packages/api/internal/handlers/deprecated_template_request_build.go +++ b/packages/api/internal/handlers/deprecated_template_request_build.go @@ -107,7 +107,7 @@ func (a *APIStore) PostTemplatesTemplateID(c *gin.Context, rawTemplateID api.Tem return } - templateDB, err := a.sqlcDB.GetTemplateByID(ctx, templateID) + templateDB, err := a.sqlcDB.GetTemplateById(ctx, templateID) switch { case err == nil: if templateDB.TeamID != team.ID { diff --git a/packages/api/internal/handlers/deprecated_template_start_build.go b/packages/api/internal/handlers/deprecated_template_start_build.go index 0b9cacef56..034445242e 100644 --- a/packages/api/internal/handlers/deprecated_template_start_build.go +++ b/packages/api/internal/handlers/deprecated_template_start_build.go @@ -120,7 +120,7 @@ func (a *APIStore) PostTemplatesTemplateIDBuildsBuildID(c *gin.Context, template var team *types.Team // Check if the user has access to the template for _, t := range teams { - if t.Team.ID == templateBuildDB.Env.TeamID { + if t.Team.ID == templateBuildDB.ActiveEnv.TeamID { team = t.Team break diff --git a/packages/api/internal/handlers/sandbox_kill.go b/packages/api/internal/handlers/sandbox_kill.go index 8c214fc166..b30ac6b5a9 100644 --- a/packages/api/internal/handlers/sandbox_kill.go +++ b/packages/api/internal/handlers/sandbox_kill.go @@ -14,7 +14,6 @@ import ( "github.com/e2b-dev/infra/packages/api/internal/sandbox" "github.com/e2b-dev/infra/packages/api/internal/utils" "github.com/e2b-dev/infra/packages/auth/pkg/auth" - "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) @@ -25,10 +24,7 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID return err } - aliasKeys, dbErr := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ - TeamID: teamID, - TemplateID: snapshot.TemplateID, - }) + aliasKeys, dbErr := a.softDeleteTemplate(ctx, teamID, snapshot.TemplateID) if dbErr != nil { return fmt.Errorf("error deleting template from db: %w", dbErr) } diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index 3680c8db64..ca4738e06a 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -6,16 +6,54 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" "github.com/e2b-dev/infra/packages/api/internal/api" "github.com/e2b-dev/infra/packages/api/internal/sandbox" + "github.com/e2b-dev/infra/packages/db/pkg/dberrors" "github.com/e2b-dev/infra/packages/db/queries" "github.com/e2b-dev/infra/packages/shared/pkg/id" "github.com/e2b-dev/infra/packages/shared/pkg/logger" "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" ) +// softDeleteTemplate soft-deletes the env, releases its aliases, and clears its +// active build rows, in a transaction. The env-locking soft-delete runs first so +// a concurrent build registration commits before the alias/active-build cleanup, +// whose fresh per-statement snapshots then see those rows. Returns the released +// alias cache keys. +func (a *APIStore) softDeleteTemplate(ctx context.Context, teamID uuid.UUID, templateID string) ([]string, error) { + txDB, tx, err := a.sqlcDB.WithTx(ctx) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + + if _, err := txDB.SoftDeleteTemplate(ctx, queries.SoftDeleteTemplateParams{TemplateID: templateID, TeamID: teamID}); err != nil { + if dberrors.IsNotFoundError(err) { + return nil, nil // already deleted or not owned by the team + } + + return nil, fmt.Errorf("soft delete template: %w", err) + } + + aliasKeys, err := txDB.ReleaseTemplateAliases(ctx, templateID) + if err != nil { + return nil, fmt.Errorf("release aliases: %w", err) + } + + if err := txDB.DeleteActiveTemplateBuilds(ctx, templateID); err != nil { + return nil, fmt.Errorf("delete active builds: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return aliasKeys, nil +} + // DeleteTemplatesTemplateID serves to delete a template (e.g. in CLI) func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID api.TemplateID) { ctx := c.Request.Context() @@ -80,15 +118,7 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a return } - // Delete the template from DB (cascades to env_build_assignments, env_aliases, snapshot_templates). - // Returns alias cache keys captured before cascade deletion for cache invalidation. - // Build artifacts are intentionally NOT deleted from storage here because builds are layered diffs - // that may be referenced by other builds' header mappings. - // [ENG-3477] a future GC mechanism will handle orphaned storage. - aliasKeys, err := a.sqlcDB.DeleteTemplate(ctx, queries.DeleteTemplateParams{ - TemplateID: templateID, - TeamID: team.ID, - }) + aliasKeys, err := a.softDeleteTemplate(ctx, team.ID, templateID) if err != nil { telemetry.ReportCriticalError(ctx, "error when deleting template from db", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when deleting template") diff --git a/packages/api/internal/handlers/template_layer_files_upload.go b/packages/api/internal/handlers/template_layer_files_upload.go index 637d28a9f3..a583cafd11 100644 --- a/packages/api/internal/handlers/template_layer_files_upload.go +++ b/packages/api/internal/handlers/template_layer_files_upload.go @@ -26,8 +26,8 @@ func (a *APIStore) GetTemplatesTemplateIDFilesHash(c *gin.Context, templateID ap return } - // Check if the user has access to the template - templateDB, err := a.sqlcDB.GetTemplateByID(ctx, templateID) + // Resolve via the active-envs view so a soft-deleted template is not found. + templateDB, err := a.sqlcDB.GetTemplateById(ctx, templateID) if err != nil { a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("Error when getting template: %s", err)) telemetry.ReportCriticalError(ctx, "error when getting env", err, telemetry.WithTemplateID(templateID)) diff --git a/packages/api/internal/handlers/template_start_build_v2.go b/packages/api/internal/handlers/template_start_build_v2.go index b9b31095a3..f671ce6935 100644 --- a/packages/api/internal/handlers/template_start_build_v2.go +++ b/packages/api/internal/handlers/template_start_build_v2.go @@ -71,7 +71,7 @@ func (a *APIStore) PostV2TemplatesTemplateIDBuildsBuildID(c *gin.Context, templa return } - dbTeamID := templateBuildDB.Env.TeamID.String() + dbTeamID := templateBuildDB.ActiveEnv.TeamID.String() team, apiErr := a.GetTeam(ctx, c, &dbTeamID) if apiErr != nil { a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg) @@ -80,7 +80,7 @@ func (a *APIStore) PostV2TemplatesTemplateIDBuildsBuildID(c *gin.Context, templa return } - if team.ID != templateBuildDB.Env.TeamID { + if team.ID != templateBuildDB.ActiveEnv.TeamID { a.sendAPIStoreError(c, http.StatusForbidden, "User does not have access to the template") telemetry.ReportCriticalError(ctx, "user does not have access to the template", err, telemetry.WithTemplateID(templateID)) diff --git a/packages/api/internal/handlers/template_tags.go b/packages/api/internal/handlers/template_tags.go index 1e43e6bc7f..68883acedc 100644 --- a/packages/api/internal/handlers/template_tags.go +++ b/packages/api/internal/handlers/template_tags.go @@ -103,7 +103,7 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { return } - template := result.Env + template := result.ActiveEnv buildID := result.EnvBuild.ID telemetry.SetAttributes(ctx, @@ -129,7 +129,7 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { // Create the tag assignments for _, tag := range tags { - err = client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ + rows, err := client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ TemplateID: template.ID, BuildID: buildID, Tag: tag, @@ -138,6 +138,11 @@ func (a *APIStore) PostTemplatesTags(c *gin.Context) { telemetry.ReportCriticalError(ctx, "error when creating tag assignment", err) a.sendAPIStoreError(c, http.StatusInternalServerError, "Error creating tag assignment") + return + } + if rows == 0 { + a.sendAPIStoreError(c, http.StatusNotFound, fmt.Sprintf("Template '%s' not found", template.ID)) + return } } diff --git a/packages/api/internal/handlers/templates_list.go b/packages/api/internal/handlers/templates_list.go index 0e35b136fc..e9ad345870 100644 --- a/packages/api/internal/handlers/templates_list.go +++ b/packages/api/internal/handlers/templates_list.go @@ -69,19 +69,19 @@ func (a *APIStore) GetTemplates(c *gin.Context, params api.GetTemplatesParams) { } templates = append(templates, &api.Template{ - TemplateID: item.Env.ID, + TemplateID: item.ActiveEnv.ID, BuildID: item.BuildID.String(), CpuCount: api.CPUCount(item.BuildVcpu), MemoryMB: api.MemoryMB(item.BuildRamMb), DiskSizeMB: api.DiskSizeMB(diskMB), - Public: item.Env.Public, + Public: item.ActiveEnv.Public, Aliases: item.Aliases, Names: item.Names, - CreatedAt: item.Env.CreatedAt, - UpdatedAt: item.Env.UpdatedAt, - LastSpawnedAt: item.Env.LastSpawnedAt, - SpawnCount: item.Env.SpawnCount, - BuildCount: item.Env.BuildCount, + CreatedAt: item.ActiveEnv.CreatedAt, + UpdatedAt: item.ActiveEnv.UpdatedAt, + LastSpawnedAt: item.ActiveEnv.LastSpawnedAt, + SpawnCount: item.ActiveEnv.SpawnCount, + BuildCount: item.ActiveEnv.BuildCount, BuildStatus: getCorrespondingTemplateBuildStatus(ctx, item.BuildStatus), CreatedBy: createdBy, EnvdVersion: envdVersion, diff --git a/packages/api/internal/orchestrator/snapshot_template.go b/packages/api/internal/orchestrator/snapshot_template.go index fab4f45d0d..7c648e2eb4 100644 --- a/packages/api/internal/orchestrator/snapshot_template.go +++ b/packages/api/internal/orchestrator/snapshot_template.go @@ -152,7 +152,7 @@ func (o *Orchestrator) resolveOrCreateSnapshotTemplate( ) (string, error) { // Existing template — just assign the build if opts.ExistingTemplateID != nil { - err := o.sqlcDB.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ + rows, err := o.sqlcDB.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ TemplateID: *opts.ExistingTemplateID, BuildID: buildID, Tag: opts.Tag, @@ -160,6 +160,9 @@ func (o *Orchestrator) resolveOrCreateSnapshotTemplate( if err != nil { return "", fmt.Errorf("error assigning build to existing template: %w", err) } + if rows == 0 { + return "", fmt.Errorf("template '%s' not found", *opts.ExistingTemplateID) + } return *opts.ExistingTemplateID, nil } diff --git a/packages/api/internal/template-manager/template_manager.go b/packages/api/internal/template-manager/template_manager.go index 3ad7b1434f..6a79b233b6 100644 --- a/packages/api/internal/template-manager/template_manager.go +++ b/packages/api/internal/template-manager/template_manager.go @@ -96,7 +96,7 @@ func (tm *TemplateManager) BuildsStatusPeriodicalSync(ctx context.Context) { logger.L().Info(ctx, "Running periodical sync of builds statuses", zap.Int("count", len(buildsRunning))) for _, b := range buildsRunning { go func(b queries.GetInProgressTemplateBuildsRow) { - err := tm.BuildStatusSync(ctx, b.EnvBuild.ID, b.Env.ID, clustersshared.WithClusterFallback(b.TeamClusterID), b.EnvBuild.ClusterNodeID) + err := tm.BuildStatusSync(ctx, b.EnvBuild.ID, b.ActiveEnv.ID, clustersshared.WithClusterFallback(b.TeamClusterID), b.EnvBuild.ClusterNodeID) if err != nil { logger.L().Error(ctx, "Error syncing build status", zap.Error(err), zap.String("buildID", b.EnvBuild.ID.String())) } diff --git a/packages/api/internal/template/register_build.go b/packages/api/internal/template/register_build.go index 1b5da21930..4908f8fb3e 100644 --- a/packages/api/internal/template/register_build.go +++ b/packages/api/internal/template/register_build.go @@ -165,14 +165,23 @@ func RegisterBuild( clusterID = &data.ClusterID } - // Create the template / or update the build count - err = client.CreateOrUpdateTemplate(ctx, queries.CreateOrUpdateTemplateParams{ + // Create the template / or update the build count. A soft-deleted template + // is not reactivated: the query returns no row, so the rebuild fails. + _, err = client.CreateOrUpdateTemplate(ctx, queries.CreateOrUpdateTemplateParams{ TemplateID: data.TemplateID, TeamID: data.Team.ID, CreatedBy: data.UserID, ClusterID: clusterID, }) if err != nil { + if dberrors.IsNotFoundError(err) { + return nil, &api.APIError{ + Err: err, + ClientMsg: fmt.Sprintf("Template '%s' has been deleted and cannot be rebuilt", data.TemplateID), + Code: http.StatusNotFound, + } + } + telemetry.ReportCriticalError(ctx, "error when updating env", err) return nil, &api.APIError{ @@ -327,7 +336,9 @@ func RegisterBuild( } for _, tag := range tags { - err = client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ + // Env is active in this tx (CreateOrUpdateTemplate succeeded above), so + // the active_envs guard always matches here; rows is ignored. + _, err = client.CreateTemplateBuildAssignment(ctx, queries.CreateTemplateBuildAssignmentParams{ TemplateID: data.TemplateID, BuildID: buildID, Tag: tag, diff --git a/packages/dashboard-api/internal/handlers/template_get.go b/packages/dashboard-api/internal/handlers/template_get.go index 794982d5bc..2b7ef28525 100644 --- a/packages/dashboard-api/internal/handlers/template_get.go +++ b/packages/dashboard-api/internal/handlers/template_get.go @@ -47,16 +47,16 @@ func (s *APIStore) GetTemplatesTemplateID(c *gin.Context, templateID api.Templat } resp := api.TemplateDetail{ - TemplateID: row.Env.ID, + TemplateID: row.ActiveEnv.ID, BuildID: row.BuildID.String(), - Public: row.Env.Public, + Public: row.ActiveEnv.Public, Aliases: row.Aliases, Names: row.Names, - CreatedAt: row.Env.CreatedAt, - UpdatedAt: row.Env.UpdatedAt, - LastSpawnedAt: row.Env.LastSpawnedAt, - SpawnCount: row.Env.SpawnCount, - BuildCount: row.Env.BuildCount, + CreatedAt: row.ActiveEnv.CreatedAt, + UpdatedAt: row.ActiveEnv.UpdatedAt, + LastSpawnedAt: row.ActiveEnv.LastSpawnedAt, + SpawnCount: row.ActiveEnv.SpawnCount, + BuildCount: row.ActiveEnv.BuildCount, } if row.BuildID != uuid.Nil { diff --git a/packages/db/migrations/20260628120000_add_env_deleted_at.sql b/packages/db/migrations/20260628120000_add_env_deleted_at.sql new file mode 100644 index 0000000000..53c6dff36d --- /dev/null +++ b/packages/db/migrations/20260628120000_add_env_deleted_at.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Soft-delete marker for envs: deleting a template/snapshot stamps deleted_at +-- instead of cascading its rows away. The active_envs view is the canonical +-- read path so callers don't repeat the deleted_at IS NULL filter. +ALTER TABLE "public"."envs" ADD COLUMN IF NOT EXISTS deleted_at timestamptz NULL; + +CREATE OR REPLACE VIEW "public"."active_envs" AS +SELECT + id, + created_at, + updated_at, + public, + build_count, + spawn_count, + last_spawned_at, + team_id, + created_by, + cluster_id, + source +FROM "public"."envs" +WHERE deleted_at IS NULL; + +-- +goose Down +DROP VIEW IF EXISTS "public"."active_envs"; +ALTER TABLE "public"."envs" DROP COLUMN IF EXISTS deleted_at; diff --git a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go index c777a60b22..031c2ea7d2 100644 --- a/packages/db/pkg/dashboard/queries/get_default_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/get_default_templates.sql.go @@ -25,7 +25,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id +JOIN public.active_envs e ON e.id = ed.env_id JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go index e758b2e674..9beafd5e10 100644 --- a/packages/db/pkg/dashboard/queries/list_team_templates.sql.go +++ b/packages/db/pkg/dashboard/queries/list_team_templates.sql.go @@ -26,7 +26,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' @@ -59,7 +59,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean @@ -239,7 +239,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' @@ -272,7 +272,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean @@ -436,7 +436,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' @@ -469,7 +469,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean @@ -633,7 +633,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = $1::uuid AND e.source = 'template' @@ -666,7 +666,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE $7::boolean diff --git a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql index 943914bd5f..3d34cb6d4f 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/get_default_templates.sql @@ -11,7 +11,7 @@ SELECT b.total_disk_size_mb, b.envd_version FROM public.env_defaults ed -JOIN public.envs e ON e.id = ed.env_id +JOIN public.active_envs e ON e.id = ed.env_id JOIN LATERAL ( SELECT a.build_id FROM public.env_build_assignments a diff --git a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql index 4beb374761..c20bc1e7dd 100644 --- a/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql +++ b/packages/db/pkg/dashboard/sql_queries/templates/list_team_templates.sql @@ -28,7 +28,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' @@ -61,7 +61,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean @@ -146,7 +146,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' @@ -179,7 +179,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean @@ -264,7 +264,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' @@ -297,7 +297,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean @@ -382,7 +382,7 @@ WITH team_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e LEFT JOIN public.env_defaults AS d ON d.env_id = e.id WHERE e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template' @@ -415,7 +415,7 @@ default_templates AS ( e.created_by, d.env_id AS default_env_id, d.description AS default_description - FROM public.envs AS e + FROM public.active_envs AS e JOIN public.env_defaults AS d ON d.env_id = e.id WHERE sqlc.arg(include_defaults)::boolean diff --git a/packages/db/pkg/tests/templates/delete_template_test.go b/packages/db/pkg/tests/templates/delete_template_test.go new file mode 100644 index 0000000000..6b8984f50c --- /dev/null +++ b/packages/db/pkg/tests/templates/delete_template_test.go @@ -0,0 +1,71 @@ +package templates + +import ( + "testing" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + "github.com/e2b-dev/infra/packages/db/queries" +) + +func envDeleted(t *testing.T, db *testutils.Database, envID string) bool { + t.Helper() + var deleted bool + err := db.SqlcClient.TestsRawSQLQuery(t.Context(), + "SELECT deleted_at IS NOT NULL FROM public.envs WHERE id = $1", + func(rows pgx.Rows) error { + if rows.Next() { + return rows.Scan(&deleted) + } + + return nil + }, + envID, + ) + require.NoError(t, err) + + return deleted +} + +func TestDeleteTemplate_SoftDeletesEnvAndPreservesStructure(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "uploaded") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "default") + alias := testutils.CreateTestTemplateAlias(t, db, templateID) + + _, err := db.SqlcClient.SoftDeleteTemplate(ctx, queries.SoftDeleteTemplateParams{TemplateID: templateID, TeamID: teamID}) + require.NoError(t, err) + _, err = db.SqlcClient.ReleaseTemplateAliases(ctx, templateID) + require.NoError(t, err) + + assert.True(t, testutils.GetEnvByID(t, ctx, db, templateID), "env row must be preserved") + assert.True(t, envDeleted(t, db, templateID), "env must be soft-deleted") + assert.True(t, testutils.GetEnvBuildByID(t, ctx, db, buildID), "build row must be preserved") + assert.NotEmpty(t, testutils.GetBuildAssignments(t, ctx, db, templateID), "build assignment must be preserved") + + templates, err := db.SqlcClient.GetTeamTemplates(ctx, teamID) + require.NoError(t, err) + assert.Empty(t, templates, "soft-deleted template must not be listed") + + var aliasCount int + require.NoError(t, db.SqlcClient.TestsRawSQLQuery(ctx, + "SELECT COUNT(*) FROM public.env_aliases WHERE alias = $1", + func(rows pgx.Rows) error { + if rows.Next() { + return rows.Scan(&aliasCount) + } + + return nil + }, + alias, + )) + assert.Zero(t, aliasCount, "alias must be released for reuse") +} diff --git a/packages/db/pkg/testutils/queries/models.go b/packages/db/pkg/testutils/queries/models.go index 803fb55f9a..0aa9f4d063 100644 --- a/packages/db/pkg/testutils/queries/models.go +++ b/packages/db/pkg/testutils/queries/models.go @@ -24,6 +24,20 @@ type AccessToken struct { AccessTokenMaskSuffix string } +type ActiveEnv struct { + ID string + CreatedAt time.Time + UpdatedAt time.Time + Public bool + BuildCount int32 + SpawnCount int64 + LastSpawnedAt *time.Time + TeamID uuid.UUID + CreatedBy *uuid.UUID + ClusterID *uuid.UUID + Source string +} + type ActiveTemplateBuild struct { BuildID uuid.UUID TeamID uuid.UUID @@ -92,6 +106,7 @@ type Env struct { CreatedBy *uuid.UUID ClusterID *uuid.UUID Source string + DeletedAt *time.Time } type EnvAlias struct { diff --git a/packages/db/queries/builds/get_build_info.sql b/packages/db/queries/builds/get_build_info.sql index 341a9308d5..faf2d0d2a7 100644 --- a/packages/db/queries/builds/get_build_info.sql +++ b/packages/db/queries/builds/get_build_info.sql @@ -9,7 +9,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = sqlc.arg(team_id)::uuid ORDER BY a.created_at DESC, a.id DESC diff --git a/packages/db/queries/builds/get_builds_paginated.sql b/packages/db/queries/builds/get_builds_paginated.sql index 830fca09ab..dfbe5bb229 100644 --- a/packages/db/queries/builds/get_builds_paginated.sql +++ b/packages/db/queries/builds/get_builds_paginated.sql @@ -15,6 +15,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -52,6 +54,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -90,6 +94,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND a.env_id = sqlc.arg(template_id)::text ORDER BY a.created_at DESC, a.id DESC @@ -128,6 +134,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND EXISTS ( SELECT 1 diff --git a/packages/db/queries/builds/get_inprogress_builds.sql b/packages/db/queries/builds/get_inprogress_builds.sql index 1df4603a12..1ad99bf22f 100644 --- a/packages/db/queries/builds/get_inprogress_builds.sql +++ b/packages/db/queries/builds/get_inprogress_builds.sql @@ -2,7 +2,7 @@ SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, sqlc.embed(e), sqlc.embed(b) FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id -JOIN public.envs e ON e.id = eba.env_id +JOIN public.active_envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' diff --git a/packages/db/queries/builds/get_template_build.sql b/packages/db/queries/builds/get_template_build.sql index d6a7bf1791..02ca6a113f 100644 --- a/packages/db/queries/builds/get_template_build.sql +++ b/packages/db/queries/builds/get_template_build.sql @@ -1,6 +1,6 @@ -- name: GetTemplateBuildWithTemplate :one SELECT sqlc.embed(e), sqlc.embed(eb) -FROM "public"."envs" e +FROM "public"."active_envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = sqlc.arg(template_id) AND eb.id = sqlc.arg(build_id) diff --git a/packages/db/queries/builds/validate_build.sql b/packages/db/queries/builds/validate_build.sql index b87b765dc1..5bf82543b6 100644 --- a/packages/db/queries/builds/validate_build.sql +++ b/packages/db/queries/builds/validate_build.sql @@ -1,7 +1,7 @@ -- name: ExistsWaitingTemplateBuild :one SELECT EXISTS ( SELECT 1 - FROM envs e + FROM active_envs e JOIN users_teams ut ON ut.team_id = e.team_id JOIN access_tokens at ON at.user_id = ut.user_id JOIN env_build_assignments eba ON eba.env_id = e.id diff --git a/packages/db/queries/check_alias_exists.sql.go b/packages/db/queries/check_alias_exists.sql.go index 07f34988b5..b53b5ba255 100644 --- a/packages/db/queries/check_alias_exists.sql.go +++ b/packages/db/queries/check_alias_exists.sql.go @@ -11,6 +11,8 @@ import ( const checkAliasConflictsWithTemplateID = `-- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( + -- envs, not active_envs: an id stays reserved while its env row exists + -- (even soft-deleted), so an alias must not shadow it. SELECT 1 FROM "public"."envs" WHERE id = $1 diff --git a/packages/db/queries/create_template.sql.go b/packages/db/queries/create_template.sql.go index 0e25984ac4..45d624e9df 100644 --- a/packages/db/queries/create_template.sql.go +++ b/packages/db/queries/create_template.sql.go @@ -12,12 +12,14 @@ import ( "github.com/google/uuid" ) -const createOrUpdateTemplate = `-- name: CreateOrUpdateTemplate :exec +const createOrUpdateTemplate = `-- name: CreateOrUpdateTemplate :one INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster_id, source) VALUES ($1, $2, $3, NOW(), FALSE, $4, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), build_count = envs.build_count + 1 +WHERE envs.deleted_at IS NULL +RETURNING id ` type CreateOrUpdateTemplateParams struct { @@ -27,14 +29,18 @@ type CreateOrUpdateTemplateParams struct { ClusterID *uuid.UUID } -func (q *Queries) CreateOrUpdateTemplate(ctx context.Context, arg CreateOrUpdateTemplateParams) error { - _, err := q.db.Exec(ctx, createOrUpdateTemplate, +// Soft-delete is permanent: skip the update for a deleted env so no row is +// returned and the build registration fails instead of resurrecting it. +func (q *Queries) CreateOrUpdateTemplate(ctx context.Context, arg CreateOrUpdateTemplateParams) (string, error) { + row := q.db.QueryRow(ctx, createOrUpdateTemplate, arg.TemplateID, arg.TeamID, arg.CreatedBy, arg.ClusterID, ) - return err + var id string + err := row.Scan(&id) + return id, err } const createTemplateBuild = `-- name: CreateTemplateBuild :exec diff --git a/packages/db/queries/create_template_build_assignment.sql.go b/packages/db/queries/create_template_build_assignment.sql.go index 4374ed6067..0ecbef59bf 100644 --- a/packages/db/queries/create_template_build_assignment.sql.go +++ b/packages/db/queries/create_template_build_assignment.sql.go @@ -11,9 +11,16 @@ import ( "github.com/google/uuid" ) -const createTemplateBuildAssignment = `-- name: CreateTemplateBuildAssignment :exec +const createTemplateBuildAssignment = `-- name: CreateTemplateBuildAssignment :execrows +WITH active AS ( + SELECT id + FROM "public"."envs" + WHERE id = $1 AND deleted_at IS NULL + FOR SHARE +) INSERT INTO "public"."env_build_assignments" (env_id, build_id, tag) -VALUES ($1, $2, $3::text) +SELECT $1, $2, $3::text +WHERE EXISTS (SELECT 1 FROM active) ` type CreateTemplateBuildAssignmentParams struct { @@ -22,8 +29,12 @@ type CreateTemplateBuildAssignmentParams struct { Tag string } -// Creates a build assignment to associate a build with a custom tag -func (q *Queries) CreateTemplateBuildAssignment(ctx context.Context, arg CreateTemplateBuildAssignmentParams) error { - _, err := q.db.Exec(ctx, createTemplateBuildAssignment, arg.TemplateID, arg.BuildID, arg.Tag) - return err +// FOR SHARE serializes against a concurrent DeleteTemplate so a build can't be +// attached to a soft-deleted env. 0 rows affected means the template is gone. +func (q *Queries) CreateTemplateBuildAssignment(ctx context.Context, arg CreateTemplateBuildAssignmentParams) (int64, error) { + result, err := q.db.Exec(ctx, createTemplateBuildAssignment, arg.TemplateID, arg.BuildID, arg.Tag) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil } diff --git a/packages/db/queries/delete_template.sql.go b/packages/db/queries/delete_template.sql.go index e7f52dfdb6..ecda84795d 100644 --- a/packages/db/queries/delete_template.sql.go +++ b/packages/db/queries/delete_template.sql.go @@ -11,33 +11,27 @@ import ( "github.com/google/uuid" ) -const deleteTemplate = `-- name: DeleteTemplate :many -WITH alias_keys AS ( - SELECT CASE - WHEN namespace IS NOT NULL THEN namespace || '/' || alias - ELSE alias - END::text AS alias_key - FROM public.env_aliases - WHERE env_id = $1 -), deleted AS ( - DELETE FROM "public"."envs" - WHERE id = $1 - AND team_id = $2 - RETURNING id -) -SELECT alias_key FROM alias_keys -WHERE EXISTS (SELECT 1 FROM deleted) +const deleteActiveTemplateBuilds = `-- name: DeleteActiveTemplateBuilds :exec +DELETE FROM public.active_template_builds WHERE template_id = $1 ` -type DeleteTemplateParams struct { - TemplateID string - TeamID uuid.UUID +// Clears in-flight build tracking (active_template_builds no longer cascades +// since the env row is kept) so a deleted template stops counting toward the +// team build concurrency limit. +func (q *Queries) DeleteActiveTemplateBuilds(ctx context.Context, templateID string) error { + _, err := q.db.Exec(ctx, deleteActiveTemplateBuilds, templateID) + return err } -// Deletes a template and returns its alias cache keys for cache invalidation. -// Alias keys are captured via CTE before the cascade delete removes them. -func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) ([]string, error) { - rows, err := q.db.Query(ctx, deleteTemplate, arg.TemplateID, arg.TeamID) +const releaseTemplateAliases = `-- name: ReleaseTemplateAliases :many +DELETE FROM public.env_aliases +WHERE env_id = $1 +RETURNING (CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END)::text AS alias_key +` + +// Releases the env's aliases (so the name is reusable) and returns their cache keys. +func (q *Queries) ReleaseTemplateAliases(ctx context.Context, templateID string) ([]string, error) { + rows, err := q.db.Query(ctx, releaseTemplateAliases, templateID) if err != nil { return nil, err } @@ -55,3 +49,27 @@ func (q *Queries) DeleteTemplate(ctx context.Context, arg DeleteTemplateParams) } return items, nil } + +const softDeleteTemplate = `-- name: SoftDeleteTemplate :one +UPDATE public.envs +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = $1 AND team_id = $2 AND deleted_at IS NULL +RETURNING id +` + +type SoftDeleteTemplateParams struct { + TemplateID string + TeamID uuid.UUID +} + +// Step 1 of soft delete (run in a tx). The UPDATE locks the env row, so a +// concurrent build registration holding that lock commits first; this then +// soft-deletes. Returns the id (no row if already deleted or not owned). The +// alias/active-build cleanup runs as separate statements afterwards so their +// fresh snapshots see rows that racing registration committed during the wait. +func (q *Queries) SoftDeleteTemplate(ctx context.Context, arg SoftDeleteTemplateParams) (string, error) { + row := q.db.QueryRow(ctx, softDeleteTemplate, arg.TemplateID, arg.TeamID) + var id string + err := row.Scan(&id) + return id, err +} diff --git a/packages/db/queries/exists_template_snapshots.sql.go b/packages/db/queries/exists_template_snapshots.sql.go index e8b7d21463..c4701d2433 100644 --- a/packages/db/queries/exists_template_snapshots.sql.go +++ b/packages/db/queries/exists_template_snapshots.sql.go @@ -12,8 +12,9 @@ import ( const existsTemplateSnapshots = `-- name: ExistsTemplateSnapshots :one SELECT EXISTS( SELECT 1 - FROM "public"."snapshots" - WHERE base_env_id = $1 + FROM "public"."snapshots" s + JOIN "public"."active_envs" e ON e.id = s.env_id + WHERE s.base_env_id = $1 ) ` diff --git a/packages/db/queries/get_build_info.sql.go b/packages/db/queries/get_build_info.sql.go index b374608be4..c15efcc515 100644 --- a/packages/db/queries/get_build_info.sql.go +++ b/packages/db/queries/get_build_info.sql.go @@ -24,7 +24,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a - JOIN public.envs e ON e.id = a.env_id + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND e.team_id = $1::uuid ORDER BY a.created_at DESC, a.id DESC diff --git a/packages/db/queries/get_builds_paginated.sql.go b/packages/db/queries/get_builds_paginated.sql.go index 04a8afe005..00aab3370f 100644 --- a/packages/db/queries/get_builds_paginated.sql.go +++ b/packages/db/queries/get_builds_paginated.sql.go @@ -30,6 +30,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -128,6 +130,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id ORDER BY a.created_at DESC, a.id DESC LIMIT 1 @@ -229,6 +233,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND EXISTS ( SELECT 1 @@ -335,6 +341,8 @@ FROM public.env_builds b JOIN LATERAL ( SELECT a.env_id FROM public.env_build_assignments a + -- active_envs (not envs): the build's env must exist and not be soft-deleted + JOIN public.active_envs e ON e.id = a.env_id WHERE a.build_id = b.id AND a.env_id = $1::text ORDER BY a.created_at DESC, a.id DESC diff --git a/packages/db/queries/get_inprogress_builds.sql.go b/packages/db/queries/get_inprogress_builds.sql.go index ec595b58f1..e1d70ac760 100644 --- a/packages/db/queries/get_inprogress_builds.sql.go +++ b/packages/db/queries/get_inprogress_builds.sql.go @@ -58,7 +58,7 @@ const getInProgressTemplateBuilds = `-- name: GetInProgressTemplateBuilds :many SELECT DISTINCT ON (b.id) t.cluster_id AS team_cluster_id, e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id FROM public.env_builds b JOIN public.env_build_assignments eba ON eba.build_id = b.id -JOIN public.envs e ON e.id = eba.env_id +JOIN public.active_envs e ON e.id = eba.env_id JOIN public.teams t ON e.team_id = t.id WHERE b.status_group IN ('pending', 'in_progress') AND e.source = 'template' @@ -67,7 +67,7 @@ ORDER BY b.id, b.created_at DESC type GetInProgressTemplateBuildsRow struct { TeamClusterID *uuid.UUID - Env Env + ActiveEnv ActiveEnv EnvBuild EnvBuild } @@ -82,17 +82,17 @@ func (q *Queries) GetInProgressTemplateBuilds(ctx context.Context) ([]GetInProgr var i GetInProgressTemplateBuildsRow if err := rows.Scan( &i.TeamClusterID, - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_last_snapshot.sql.go b/packages/db/queries/get_last_snapshot.sql.go index 76fe6599a2..5aaf38360a 100644 --- a/packages/db/queries/get_last_snapshot.sql.go +++ b/packages/db/queries/get_last_snapshot.sql.go @@ -12,6 +12,7 @@ import ( const getLastSnapshot = `-- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, s.created_at, s.env_id, s.sandbox_id, s.id, s.metadata, s.base_env_id, s.sandbox_started_at, s.env_secure, s.origin_node_id, s.allow_internet_access, s.auto_pause, s.team_id, s.config, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/get_snapshot_builds.sql.go b/packages/db/queries/get_snapshot_builds.sql.go index 042dc580b8..ad01844055 100644 --- a/packages/db/queries/get_snapshot_builds.sql.go +++ b/packages/db/queries/get_snapshot_builds.sql.go @@ -13,6 +13,7 @@ import ( const getSnapshotBuilds = `-- name: GetSnapshotBuilds :many SELECT s.env_id as template_id, eb.id as build_id, eb.cluster_node_id as build_cluster_node_id FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN "public"."env_build_assignments" eba ON eba.env_id = s.env_id AND eba.tag = 'default' LEFT JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE s.sandbox_id = $1 diff --git a/packages/db/queries/get_snapshots_with_cursor.sql b/packages/db/queries/get_snapshots_with_cursor.sql index 5b93757b8d..f6fb005ebf 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql +++ b/packages/db/queries/get_snapshots_with_cursor.sql @@ -8,6 +8,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_snapshots_with_cursor.sql.go b/packages/db/queries/get_snapshots_with_cursor.sql.go index 96440390c6..3604d7aad5 100644 --- a/packages/db/queries/get_snapshots_with_cursor.sql.go +++ b/packages/db/queries/get_snapshots_with_cursor.sql.go @@ -24,6 +24,7 @@ SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.nam eb.envd_version AS build_envd_version, eb.created_at AS build_created_at FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_team_template.sql b/packages/db/queries/get_team_template.sql index b1ac187c3b..86d904aca8 100644 --- a/packages/db/queries/get_team_template.sql +++ b/packages/db/queries/get_team_template.sql @@ -9,7 +9,7 @@ SELECT sqlc.embed(e), COALESCE(latest_build.status_group, 'pending') as build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_team_template.sql.go b/packages/db/queries/get_team_template.sql.go index d234141cec..a05dd33761 100644 --- a/packages/db/queries/get_team_template.sql.go +++ b/packages/db/queries/get_team_template.sql.go @@ -23,7 +23,7 @@ SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, COALESCE(latest_build.status_group, 'pending') as build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -59,7 +59,7 @@ type GetTeamTemplateParams struct { } type GetTeamTemplateRow struct { - Env Env + ActiveEnv ActiveEnv BuildID uuid.UUID BuildVcpu int64 BuildRamMb int64 @@ -75,17 +75,17 @@ func (q *Queries) GetTeamTemplate(ctx context.Context, arg GetTeamTemplateParams row := q.db.QueryRow(ctx, getTeamTemplate, arg.ID, arg.TeamID) var i GetTeamTemplateRow err := row.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates.sql b/packages/db/queries/get_team_templates.sql index beddb276a3..e80164e1a1 100644 --- a/packages/db/queries/get_team_templates.sql +++ b/packages/db/queries/get_team_templates.sql @@ -10,7 +10,7 @@ SELECT sqlc.embed(e), e.created_by as creator_id, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_team_templates.sql.go b/packages/db/queries/get_team_templates.sql.go index 491bc95c70..c19d769d57 100644 --- a/packages/db/queries/get_team_templates.sql.go +++ b/packages/db/queries/get_team_templates.sql.go @@ -24,7 +24,7 @@ SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.created_by as creator_id, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, @@ -55,7 +55,7 @@ ORDER BY e.created_at ASC ` type GetTeamTemplatesRow struct { - Env Env + ActiveEnv ActiveEnv BuildID uuid.UUID BuildVcpu int64 BuildRamMb int64 @@ -78,17 +78,17 @@ func (q *Queries) GetTeamTemplates(ctx context.Context, teamID uuid.UUID) ([]Get for rows.Next() { var i GetTeamTemplatesRow if err := rows.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.BuildID, &i.BuildVcpu, &i.BuildRamMb, diff --git a/packages/db/queries/get_team_templates_with_cursor.sql b/packages/db/queries/get_team_templates_with_cursor.sql index 537c75c444..fe9a082475 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql +++ b/packages/db/queries/get_team_templates_with_cursor.sql @@ -23,7 +23,7 @@ SELECT COALESCE(latest_build.status_group, 'pending') AS build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_team_templates_with_cursor.sql.go b/packages/db/queries/get_team_templates_with_cursor.sql.go index 1edbac2d6c..7c321a5167 100644 --- a/packages/db/queries/get_team_templates_with_cursor.sql.go +++ b/packages/db/queries/get_team_templates_with_cursor.sql.go @@ -32,7 +32,7 @@ SELECT COALESCE(latest_build.status_group, 'pending') AS build_status, COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM public.envs AS e +FROM public.active_envs AS e LEFT JOIN LATERAL ( SELECT ARRAY_AGG(alias ORDER BY alias) AS aliases, diff --git a/packages/db/queries/get_template.sql.go b/packages/db/queries/get_template.sql.go index fcc6f45e0d..0d5690d484 100644 --- a/packages/db/queries/get_template.sql.go +++ b/packages/db/queries/get_template.sql.go @@ -14,7 +14,7 @@ import ( const getTemplateByAlias = `-- name: GetTemplateByAlias :one SELECT e.id, e.team_id, e.public FROM public.env_aliases AS ea -JOIN public.envs AS e ON e.id = ea.env_id +JOIN public.active_envs AS e ON e.id = ea.env_id WHERE ea.alias = $1 AND ea.namespace IS NOT DISTINCT FROM $2::text AND e.source IN ('template', 'snapshot_template') @@ -43,7 +43,7 @@ func (q *Queries) GetTemplateByAlias(ctx context.Context, arg GetTemplateByAlias const getTemplateById = `-- name: GetTemplateById :one SELECT e.id, e.team_id, e.public, e.cluster_id -FROM public.envs AS e +FROM public.active_envs AS e WHERE e.id = $1 AND e.source IN ('template', 'snapshot_template') ` diff --git a/packages/db/queries/get_template_build.sql.go b/packages/db/queries/get_template_build.sql.go index a4493db19a..9506c76323 100644 --- a/packages/db/queries/get_template_build.sql.go +++ b/packages/db/queries/get_template_build.sql.go @@ -13,7 +13,7 @@ import ( const getTemplateBuildWithTemplate = `-- name: GetTemplateBuildWithTemplate :one SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id -FROM "public"."envs" e +FROM "public"."active_envs" e JOIN "public"."env_build_assignments" eba ON eba.env_id = e.id JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE e.id = $1 AND eb.id = $2 @@ -26,25 +26,25 @@ type GetTemplateBuildWithTemplateParams struct { } type GetTemplateBuildWithTemplateRow struct { - Env Env - EnvBuild EnvBuild + ActiveEnv ActiveEnv + EnvBuild EnvBuild } func (q *Queries) GetTemplateBuildWithTemplate(ctx context.Context, arg GetTemplateBuildWithTemplateParams) (GetTemplateBuildWithTemplateRow, error) { row := q.db.QueryRow(ctx, getTemplateBuildWithTemplate, arg.TemplateID, arg.BuildID) var i GetTemplateBuildWithTemplateRow err := row.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/get_template_by_id.sql.go b/packages/db/queries/get_template_by_id.sql.go index b8850a7cad..037c05444f 100644 --- a/packages/db/queries/get_template_by_id.sql.go +++ b/packages/db/queries/get_template_by_id.sql.go @@ -12,35 +12,9 @@ import ( "github.com/google/uuid" ) -const getTemplateByID = `-- name: GetTemplateByID :one -SELECT t.id, t.created_at, t.updated_at, t.public, t.build_count, t.spawn_count, t.last_spawned_at, t.team_id, t.created_by, t.cluster_id, t.source -FROM "public"."envs" t -WHERE t.id = $1 - AND t.source IN ('template', 'snapshot_template') -` - -func (q *Queries) GetTemplateByID(ctx context.Context, id string) (Env, error) { - row := q.db.QueryRow(ctx, getTemplateByID, id) - var i Env - err := row.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.Public, - &i.BuildCount, - &i.SpawnCount, - &i.LastSpawnedAt, - &i.TeamID, - &i.CreatedBy, - &i.ClusterID, - &i.Source, - ) - return i, err -} - const getTemplateByIDWithAliases = `-- name: GetTemplateByIDWithAliases :one SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, al.aliases, al.names -FROM "public"."envs" e +FROM "public"."active_envs" e CROSS JOIN LATERAL ( SELECT COALESCE(array_agg(alias), '{}')::text[] AS aliases, diff --git a/packages/db/queries/get_template_with_build_by_tag.sql.go b/packages/db/queries/get_template_with_build_by_tag.sql.go index 63450198a0..d75cf142e0 100644 --- a/packages/db/queries/get_template_with_build_by_tag.sql.go +++ b/packages/db/queries/get_template_with_build_by_tag.sql.go @@ -11,7 +11,7 @@ import ( const getTemplateWithBuildByTag = `-- name: GetTemplateWithBuildByTag :one SELECT e.id, e.created_at, e.updated_at, e.public, e.build_count, e.spawn_count, e.last_spawned_at, e.team_id, e.created_by, e.cluster_id, e.source, eb.id, eb.created_at, eb.updated_at, eb.finished_at, eb.status, eb.dockerfile, eb.start_cmd, eb.vcpu, eb.ram_mb, eb.free_disk_size_mb, eb.total_disk_size_mb, eb.kernel_version, eb.firecracker_version, eb.env_id, eb.envd_version, eb.ready_cmd, eb.cluster_node_id, eb.reason, eb.version, eb.cpu_architecture, eb.cpu_family, eb.cpu_model, eb.cpu_model_name, eb.cpu_flags, eb.status_group, eb.team_id, aliases, names -FROM public.envs AS e +FROM public.active_envs AS e JOIN public.env_build_assignments AS eba ON eba.env_id = e.id AND ( -- Match by tag @@ -41,10 +41,10 @@ type GetTemplateWithBuildByTagParams struct { } type GetTemplateWithBuildByTagRow struct { - Env Env - EnvBuild EnvBuild - Aliases []string - Names []string + ActiveEnv ActiveEnv + EnvBuild EnvBuild + Aliases []string + Names []string } // Fetches a template with its build by template ID and tag. @@ -54,17 +54,17 @@ func (q *Queries) GetTemplateWithBuildByTag(ctx context.Context, arg GetTemplate row := q.db.QueryRow(ctx, getTemplateWithBuildByTag, arg.Tag, arg.TemplateID) var i GetTemplateWithBuildByTagRow err := row.Scan( - &i.Env.ID, - &i.Env.CreatedAt, - &i.Env.UpdatedAt, - &i.Env.Public, - &i.Env.BuildCount, - &i.Env.SpawnCount, - &i.Env.LastSpawnedAt, - &i.Env.TeamID, - &i.Env.CreatedBy, - &i.Env.ClusterID, - &i.Env.Source, + &i.ActiveEnv.ID, + &i.ActiveEnv.CreatedAt, + &i.ActiveEnv.UpdatedAt, + &i.ActiveEnv.Public, + &i.ActiveEnv.BuildCount, + &i.ActiveEnv.SpawnCount, + &i.ActiveEnv.LastSpawnedAt, + &i.ActiveEnv.TeamID, + &i.ActiveEnv.CreatedBy, + &i.ActiveEnv.ClusterID, + &i.ActiveEnv.Source, &i.EnvBuild.ID, &i.EnvBuild.CreatedAt, &i.EnvBuild.UpdatedAt, diff --git a/packages/db/queries/list_team_snapshot_templates.sql.go b/packages/db/queries/list_team_snapshot_templates.sql.go index 47c486ecfe..b3e9936238 100644 --- a/packages/db/queries/list_team_snapshot_templates.sql.go +++ b/packages/db/queries/list_team_snapshot_templates.sql.go @@ -28,7 +28,7 @@ SELECT eb.cluster_node_id, eb.tag, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM "public"."envs" e +FROM "public"."active_envs" e JOIN "public"."snapshot_templates" st ON st.env_id = e.id JOIN LATERAL ( SELECT b.id, b.created_at, b.updated_at, b.finished_at, b.status, b.dockerfile, b.start_cmd, b.vcpu, b.ram_mb, b.free_disk_size_mb, b.total_disk_size_mb, b.kernel_version, b.firecracker_version, b.env_id, b.envd_version, b.ready_cmd, b.cluster_node_id, b.reason, b.version, b.cpu_architecture, b.cpu_family, b.cpu_model, b.cpu_model_name, b.cpu_flags, b.status_group, b.team_id, ba.tag diff --git a/packages/db/queries/models.go b/packages/db/queries/models.go index 242ff92cea..105acf169d 100644 --- a/packages/db/queries/models.go +++ b/packages/db/queries/models.go @@ -12,6 +12,20 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type ActiveEnv struct { + ID string + CreatedAt time.Time + UpdatedAt time.Time + Public bool + BuildCount int32 + SpawnCount int64 + LastSpawnedAt *time.Time + TeamID uuid.UUID + CreatedBy *uuid.UUID + ClusterID *uuid.UUID + Source string +} + type Cluster struct { ID uuid.UUID Endpoint string @@ -22,22 +36,6 @@ type Cluster struct { Name string } -type Env struct { - ID string - CreatedAt time.Time - UpdatedAt time.Time - Public bool - BuildCount int32 - // Number of times the env was spawned - SpawnCount int64 - // Timestamp of the last time the env was spawned - LastSpawnedAt *time.Time - TeamID uuid.UUID - CreatedBy *uuid.UUID - ClusterID *uuid.UUID - Source string -} - type EnvAlias struct { Alias string IsRenamable bool diff --git a/packages/db/queries/snapshots/get_last_snapshot.sql b/packages/db/queries/snapshots/get_last_snapshot.sql index e835fe2d13..603fc6d8a6 100644 --- a/packages/db/queries/snapshots/get_last_snapshot.sql +++ b/packages/db/queries/snapshots/get_last_snapshot.sql @@ -1,6 +1,7 @@ -- name: GetLastSnapshot :one SELECT COALESCE(ea.aliases, ARRAY[]::text[])::text[] AS aliases, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names, sqlc.embed(s), sqlc.embed(eb) FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id JOIN LATERAL ( SELECT eba.build_id FROM "public"."env_build_assignments" eba diff --git a/packages/db/queries/snapshots/get_snapshot_builds.sql b/packages/db/queries/snapshots/get_snapshot_builds.sql index 939053cf12..41fa6ba8e6 100644 --- a/packages/db/queries/snapshots/get_snapshot_builds.sql +++ b/packages/db/queries/snapshots/get_snapshot_builds.sql @@ -1,5 +1,6 @@ -- name: GetSnapshotBuilds :many SELECT s.env_id as template_id, eb.id as build_id, eb.cluster_node_id as build_cluster_node_id FROM "public"."snapshots" s +JOIN "public"."active_envs" e ON e.id = s.env_id LEFT JOIN "public"."env_build_assignments" eba ON eba.env_id = s.env_id AND eba.tag = 'default' LEFT JOIN "public"."env_builds" eb ON eb.id = eba.build_id WHERE s.sandbox_id = @sandbox_id diff --git a/packages/db/queries/snapshots/list_team_snapshot_templates.sql b/packages/db/queries/snapshots/list_team_snapshot_templates.sql index 11c7551b73..b985886b25 100644 --- a/packages/db/queries/snapshots/list_team_snapshot_templates.sql +++ b/packages/db/queries/snapshots/list_team_snapshot_templates.sql @@ -16,7 +16,7 @@ SELECT eb.cluster_node_id, eb.tag, COALESCE(ea.names, ARRAY[]::text[])::text[] AS names -FROM "public"."envs" e +FROM "public"."active_envs" e JOIN "public"."snapshot_templates" st ON st.env_id = e.id JOIN LATERAL ( SELECT b.*, ba.tag diff --git a/packages/db/queries/template_aliases/check_alias_exists.sql b/packages/db/queries/template_aliases/check_alias_exists.sql index 6c15871648..64a3fbfb55 100644 --- a/packages/db/queries/template_aliases/check_alias_exists.sql +++ b/packages/db/queries/template_aliases/check_alias_exists.sql @@ -1,5 +1,7 @@ -- name: CheckAliasConflictsWithTemplateID :one SELECT EXISTS( + -- envs, not active_envs: an id stays reserved while its env row exists + -- (even soft-deleted), so an alias must not shadow it. SELECT 1 FROM "public"."envs" WHERE id = @alias diff --git a/packages/db/queries/templates/create_template.sql b/packages/db/queries/templates/create_template.sql index 38ec530b0e..6cf6e47ee6 100644 --- a/packages/db/queries/templates/create_template.sql +++ b/packages/db/queries/templates/create_template.sql @@ -1,9 +1,13 @@ --- name: CreateOrUpdateTemplate :exec +-- name: CreateOrUpdateTemplate :one INSERT INTO "public"."envs"(id, team_id, created_by, updated_at, public, cluster_id, source) VALUES (@template_id, @team_id, @created_by, NOW(), FALSE, @cluster_id, 'template') ON CONFLICT (id) DO UPDATE SET updated_at = NOW(), - build_count = envs.build_count + 1; + build_count = envs.build_count + 1 +-- Soft-delete is permanent: skip the update for a deleted env so no row is +-- returned and the build registration fails instead of resurrecting it. +WHERE envs.deleted_at IS NULL +RETURNING id; -- name: InvalidateUnstartedTemplateBuilds :exec WITH invalidated AS ( diff --git a/packages/db/queries/templates/create_template_build_assignment.sql b/packages/db/queries/templates/create_template_build_assignment.sql index 816b6d1d23..1d73259049 100644 --- a/packages/db/queries/templates/create_template_build_assignment.sql +++ b/packages/db/queries/templates/create_template_build_assignment.sql @@ -1,5 +1,12 @@ --- name: CreateTemplateBuildAssignment :exec --- Creates a build assignment to associate a build with a custom tag +-- name: CreateTemplateBuildAssignment :execrows +-- FOR SHARE serializes against a concurrent DeleteTemplate so a build can't be +-- attached to a soft-deleted env. 0 rows affected means the template is gone. +WITH active AS ( + SELECT id + FROM "public"."envs" + WHERE id = @template_id AND deleted_at IS NULL + FOR SHARE +) INSERT INTO "public"."env_build_assignments" (env_id, build_id, tag) -VALUES (@template_id, @build_id, @tag::text); - +SELECT @template_id, @build_id, @tag::text +WHERE EXISTS (SELECT 1 FROM active); diff --git a/packages/db/queries/templates/delete_template.sql b/packages/db/queries/templates/delete_template.sql index abd7a3a873..f481b86137 100644 --- a/packages/db/queries/templates/delete_template.sql +++ b/packages/db/queries/templates/delete_template.sql @@ -1,18 +1,22 @@ --- name: DeleteTemplate :many --- Deletes a template and returns its alias cache keys for cache invalidation. --- Alias keys are captured via CTE before the cascade delete removes them. -WITH alias_keys AS ( - SELECT CASE - WHEN namespace IS NOT NULL THEN namespace || '/' || alias - ELSE alias - END::text AS alias_key - FROM public.env_aliases - WHERE env_id = @template_id -), deleted AS ( - DELETE FROM "public"."envs" - WHERE id = @template_id - AND team_id = @team_id - RETURNING id -) -SELECT alias_key FROM alias_keys -WHERE EXISTS (SELECT 1 FROM deleted); \ No newline at end of file +-- name: SoftDeleteTemplate :one +-- Step 1 of soft delete (run in a tx). The UPDATE locks the env row, so a +-- concurrent build registration holding that lock commits first; this then +-- soft-deletes. Returns the id (no row if already deleted or not owned). The +-- alias/active-build cleanup runs as separate statements afterwards so their +-- fresh snapshots see rows that racing registration committed during the wait. +UPDATE public.envs +SET deleted_at = NOW(), updated_at = NOW() +WHERE id = @template_id AND team_id = @team_id AND deleted_at IS NULL +RETURNING id; + +-- name: ReleaseTemplateAliases :many +-- Releases the env's aliases (so the name is reusable) and returns their cache keys. +DELETE FROM public.env_aliases +WHERE env_id = @template_id +RETURNING (CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END)::text AS alias_key; + +-- name: DeleteActiveTemplateBuilds :exec +-- Clears in-flight build tracking (active_template_builds no longer cascades +-- since the env row is kept) so a deleted template stops counting toward the +-- team build concurrency limit. +DELETE FROM public.active_template_builds WHERE template_id = @template_id; diff --git a/packages/db/queries/templates/exists_template_snapshots.sql b/packages/db/queries/templates/exists_template_snapshots.sql index 9cf474b9d2..9d8792ac0e 100644 --- a/packages/db/queries/templates/exists_template_snapshots.sql +++ b/packages/db/queries/templates/exists_template_snapshots.sql @@ -1,6 +1,7 @@ -- name: ExistsTemplateSnapshots :one SELECT EXISTS( SELECT 1 - FROM "public"."snapshots" - WHERE base_env_id = @env_id + FROM "public"."snapshots" s + JOIN "public"."active_envs" e ON e.id = s.env_id + WHERE s.base_env_id = @env_id ); \ No newline at end of file diff --git a/packages/db/queries/templates/get_template.sql b/packages/db/queries/templates/get_template.sql index 0652750c7e..9dd5eca0fc 100644 --- a/packages/db/queries/templates/get_template.sql +++ b/packages/db/queries/templates/get_template.sql @@ -4,7 +4,7 @@ -- @namespace: the namespace to scope the lookup (NULL for promoted templates) SELECT e.id, e.team_id, e.public FROM public.env_aliases AS ea -JOIN public.envs AS e ON e.id = ea.env_id +JOIN public.active_envs AS e ON e.id = ea.env_id WHERE ea.alias = @alias AND ea.namespace IS NOT DISTINCT FROM sqlc.narg(namespace)::text AND e.source IN ('template', 'snapshot_template'); @@ -13,6 +13,6 @@ WHERE ea.alias = @alias -- Looks up a template by its ID directly -- @template_id: the template ID to look up SELECT e.id, e.team_id, e.public, e.cluster_id -FROM public.envs AS e +FROM public.active_envs AS e WHERE e.id = @template_id AND e.source IN ('template', 'snapshot_template'); diff --git a/packages/db/queries/templates/get_template_by_id.sql b/packages/db/queries/templates/get_template_by_id.sql index bb6009268e..954125df9c 100644 --- a/packages/db/queries/templates/get_template_by_id.sql +++ b/packages/db/queries/templates/get_template_by_id.sql @@ -1,12 +1,6 @@ --- name: GetTemplateByID :one -SELECT t.* -FROM "public"."envs" t -WHERE t.id = $1 - AND t.source IN ('template', 'snapshot_template'); - -- name: GetTemplateByIDWithAliases :one SELECT e.*, al.aliases, al.names -FROM "public"."envs" e +FROM "public"."active_envs" e CROSS JOIN LATERAL ( SELECT COALESCE(array_agg(alias), '{}')::text[] AS aliases, diff --git a/packages/db/queries/templates/get_template_with_build_by_tag.sql b/packages/db/queries/templates/get_template_with_build_by_tag.sql index 6f0b1915cd..61457ace8f 100644 --- a/packages/db/queries/templates/get_template_with_build_by_tag.sql +++ b/packages/db/queries/templates/get_template_with_build_by_tag.sql @@ -3,7 +3,7 @@ -- @template_id: the template ID to look up -- @tag: defaults to 'default' if not provided SELECT sqlc.embed(e), sqlc.embed(eb), aliases, names -FROM public.envs AS e +FROM public.active_envs AS e JOIN public.env_build_assignments AS eba ON eba.env_id = e.id AND ( -- Match by tag diff --git a/packages/db/queries/templates/update_template.sql b/packages/db/queries/templates/update_template.sql index 587de52864..077e686edd 100644 --- a/packages/db/queries/templates/update_template.sql +++ b/packages/db/queries/templates/update_template.sql @@ -2,7 +2,7 @@ UPDATE "public"."envs" e SET public = @public WHERE id IN ( - SELECT e.id FROM "public"."envs" e + SELECT e.id FROM "public"."active_envs" e LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = @team_id AND e.source = 'template' diff --git a/packages/db/queries/update_template.sql.go b/packages/db/queries/update_template.sql.go index 906c824d77..7d5d85de73 100644 --- a/packages/db/queries/update_template.sql.go +++ b/packages/db/queries/update_template.sql.go @@ -15,7 +15,7 @@ const updateTemplate = `-- name: UpdateTemplate :one UPDATE "public"."envs" e SET public = $1 WHERE id IN ( - SELECT e.id FROM "public"."envs" e + SELECT e.id FROM "public"."active_envs" e LEFT JOIN "public"."env_aliases" ea ON ea.env_id = e.id WHERE e.team_id = $2 AND e.source = 'template' diff --git a/packages/db/queries/validate_build.sql.go b/packages/db/queries/validate_build.sql.go index 099a62776f..56b403c3ef 100644 --- a/packages/db/queries/validate_build.sql.go +++ b/packages/db/queries/validate_build.sql.go @@ -12,7 +12,7 @@ import ( const existsWaitingTemplateBuild = `-- name: ExistsWaitingTemplateBuild :one SELECT EXISTS ( SELECT 1 - FROM envs e + FROM active_envs e JOIN users_teams ut ON ut.team_id = e.team_id JOIN access_tokens at ON at.user_id = ut.user_id JOIN env_build_assignments eba ON eba.env_id = e.id diff --git a/packages/docker-reverse-proxy/internal/auth/validate_test.go b/packages/docker-reverse-proxy/internal/auth/validate_test.go index caab51598a..ee3becccd7 100644 --- a/packages/docker-reverse-proxy/internal/auth/validate_test.go +++ b/packages/docker-reverse-proxy/internal/auth/validate_test.go @@ -162,10 +162,11 @@ func setupValidateTest(tb testing.TB, db *testutils.Database, userID uuid.UUID, `, buildID, createdEnvStatus, finishedAt) require.NoError(tb, err) - err = db.SqlcClient.CreateTemplateBuildAssignment(tb.Context(), dbqueries.CreateTemplateBuildAssignmentParams{ + rows, err := db.SqlcClient.CreateTemplateBuildAssignment(tb.Context(), dbqueries.CreateTemplateBuildAssignmentParams{ TemplateID: envID, BuildID: buildID, Tag: "default", }) require.NoError(tb, err) + require.EqualValues(tb, 1, rows) }