diff --git a/packages/api/internal/handlers/sandbox_kill.go b/packages/api/internal/handlers/sandbox_kill.go index b30ac6b5a9..29048d452b 100644 --- a/packages/api/internal/handlers/sandbox_kill.go +++ b/packages/api/internal/handlers/sandbox_kill.go @@ -33,6 +33,10 @@ func (a *APIStore) deleteSnapshot(ctx context.Context, sandboxID string, teamID a.templateCache.InvalidateAliasesByTemplateID(context.WithoutCancel(ctx), snapshot.TemplateID, aliasKeys) a.snapshotCache.Invalidate(context.WithoutCancel(ctx), sandboxID) + // Delete GCS/S3 artifacts in the background; DB deletion is already committed + // so artifact cleanup failures must not block or fail the response. + go a.deleteTemplateArtifacts(context.WithoutCancel(ctx), teamID, snapshot.TemplateID) + return nil } diff --git a/packages/api/internal/handlers/sandbox_kill_test.go b/packages/api/internal/handlers/sandbox_kill_test.go new file mode 100644 index 0000000000..2da0d0907c --- /dev/null +++ b/packages/api/internal/handlers/sandbox_kill_test.go @@ -0,0 +1,102 @@ +package handlers + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + dbtypes "github.com/e2b-dev/infra/packages/db/pkg/types" + "github.com/e2b-dev/infra/packages/db/queries" + redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis" +) + +// createTestSnapshotForHandler inserts a snapshot and returns (sandboxID, templateID). +// Uses UpsertSnapshot which also creates the env and env_build_assignment rows that +// GetExclusiveBuildsForTemplateDeletion relies on. +func createTestSnapshotForHandler(t *testing.T, db *testutils.Database, teamID uuid.UUID, baseEnvID string) (string, string) { + t.Helper() + + sandboxID := "sbx-" + uuid.New().String()[:8] + templateID := "env-" + uuid.New().String() + envdVersion := "v1.0.0" + totalDisk := int64(1024) + allowInternet := true + + sourceBuildID := testutils.CreateTestBuild(t, t.Context(), db, baseEnvID, "success") + + result, err := db.SqlcClient.UpsertSnapshot(t.Context(), queries.UpsertSnapshotParams{ + TemplateID: templateID, + TeamID: teamID, + SandboxID: sandboxID, + BaseTemplateID: baseEnvID, + StartedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + Vcpu: 2, + RamMb: 2048, + TotalDiskSizeMb: &totalDisk, + Metadata: dbtypes.JSONBStringMap{}, + KernelVersion: "6.1.0", + FirecrackerVersion: "1.4.0", + EnvdVersion: &envdVersion, + Secure: true, + AllowInternetAccess: &allowInternet, + AutoPause: false, + OriginNodeID: "test-node", + SourceBuildID: sourceBuildID, + Status: "success", + }) + require.NoError(t, err) + + return sandboxID, result.TemplateID +} + +// TestDeleteTemplateArtifacts_SnapshotBuildIsDeleted verifies that the snapshot +// path (paused sandbox deletion) also triggers artifact cleanup. +// UpsertSnapshot creates an env + env_build_assignment row, so the SQL query +// picks up the build and the deleter is called. +func TestDeleteTemplateArtifacts_SnapshotBuildIsDeleted(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseEnvID := testutils.CreateTestTemplate(t, db, teamID) + _, snapshotTemplateID := createTestSnapshotForHandler(t, db, teamID, baseEnvID) + + // Simulate what deleteSnapshot does: soft-delete then call the artifact cleanup. + err := db.SqlcClient.TestsRawSQL(ctx, + `UPDATE public.envs SET deleted_at = NOW() WHERE id = $1`, + snapshotTemplateID, + ) + require.NoError(t, err) + + store := &APIStore{ + sqlcDB: db.SqlcClient, + templateCache: templatecache.NewTemplateCache(db.SqlcClient, redis), + buildAncestors: stubAncestorReader{}, + runtimeSandboxes: stubSandboxLister{}, + } + + var mu sync.Mutex + deleted := []uuid.UUID{} + deleteFn := func(_ context.Context, buildID uuid.UUID, _ string, _ uuid.UUID, _ string) error { + mu.Lock() + deleted = append(deleted, buildID) + mu.Unlock() + + return nil + } + + store.deleteTemplateArtifactsWithDeleter(ctx, teamID, snapshotTemplateID, deleteFn) + + assert.Len(t, deleted, 1, "snapshot build should be passed to the deleter") +} diff --git a/packages/api/internal/handlers/store.go b/packages/api/internal/handlers/store.go index 235b6bc42c..5e77c5cdce 100644 --- a/packages/api/internal/handlers/store.go +++ b/packages/api/internal/handlers/store.go @@ -88,6 +88,14 @@ type APIStore struct { snapshotUpsertSem *sharedutils.AdjustableSemaphore sandboxListSem *sharedutils.AdjustableSemaphore snapshotBuildQuerySem *sharedutils.AdjustableSemaphore + // buildAncestors resolves a build's header-mapping ancestor closure for the + // template-deletion reachability guard. Nil when template storage is not + // configured for the API, in which case artifact cleanup fails closed. + buildAncestors buildAncestorReader + // runtimeSandboxes lists the team's live sandboxes so their pinned builds + // are protected roots during template-deletion cleanup. Nil makes the guard + // fail closed. + runtimeSandboxes runtimeSandboxLister } func NewAPIStore(ctx context.Context, tel *telemetry.Client, redisClient redis.UniversalClient, featureFlags *featureflags.Client, config cfg.Config) *APIStore { @@ -286,6 +294,8 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, redisClient redis.U snapshotUpsertSem: snapshotUpsertSem, sandboxListSem: sandboxListSem, snapshotBuildQuerySem: snapshotBuildQuerySem, + buildAncestors: newTemplateBuildAncestorReader(ctx), + runtimeSandboxes: orch, } go a.updateDBThrottleLimits(ctx) diff --git a/packages/api/internal/handlers/template_delete.go b/packages/api/internal/handlers/template_delete.go index ca4738e06a..be13e293a6 100644 --- a/packages/api/internal/handlers/template_delete.go +++ b/packages/api/internal/handlers/template_delete.go @@ -8,11 +8,13 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "go.opentelemetry.io/otel/attribute" + "go.uber.org/zap" "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/clusters" "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" @@ -54,6 +56,180 @@ func (a *APIStore) softDeleteTemplate(ctx context.Context, teamID uuid.UUID, tem return aliasKeys, nil } +type deleteBuildFn func(ctx context.Context, buildID uuid.UUID, templateID string, clusterID uuid.UUID, nodeID string) error + +// deleteTemplateArtifacts removes GCS/S3 build artifacts for builds that are +// exclusively owned by the given template. It is called after the template has +// been soft-deleted from the database; failures are logged but do not affect the +// HTTP response because the DB deletion is already committed. +func (a *APIStore) deleteTemplateArtifacts(ctx context.Context, teamID uuid.UUID, templateID string) { + a.deleteTemplateArtifactsWithDeleter(ctx, teamID, templateID, a.templateManager.DeleteBuild) +} + +func (a *APIStore) deleteTemplateArtifactsWithDeleter(ctx context.Context, teamID uuid.UUID, templateID string, deleteFn deleteBuildFn) { + builds, err := a.sqlcDB.GetExclusiveBuildsForTemplateDeletion(ctx, templateID) + if err != nil { + logger.L().Error(ctx, "Failed to query exclusive builds for artifact cleanup", + logger.WithTemplateID(templateID), + zap.Error(err), + ) + + return + } + + // A build being "exclusive" in the database (no env_build_assignments row + // for any other template) does NOT make it safe to delete: intermediate + // cache layers are never written to the DB, so a build that is DB-exclusive + // to this template can still be a header-mapping ancestor of another live + // template's build. Deleting its storage prefix would orphan that ancestor + // and surface later as a silent, unrecoverable "Input/output error" when + // the other template's image is reconstructed. Guard against that by only + // deleting candidates that are NOT reachable from a live root's header + // closure; fail closed (delete nothing) if reachability cannot be proven. + candidateIDs := make([]uuid.UUID, 0, len(builds)) + for _, b := range builds { + candidateIDs = append(candidateIDs, b.BuildID) + } + + deletable, err := a.deletableAfterReachabilityGuard(ctx, teamID, templateID, candidateIDs) + if err != nil { + logger.L().Warn(ctx, "Skipping template artifact cleanup: could not prove build reachability", + logger.WithTemplateID(templateID), + logger.WithTeamID(teamID.String()), + zap.Error(err), + ) + + return + } + + for _, b := range builds { + if b.ClusterNodeID == nil { + continue + } + + if _, ok := deletable[b.BuildID]; !ok { + logger.L().Warn(ctx, "Keeping build: still referenced by a live template's header closure", + logger.WithTemplateID(templateID), + logger.WithBuildID(b.BuildID.String()), + ) + + continue + } + + clusterID := clusters.WithClusterFallback(b.ClusterID) + if err := deleteFn(ctx, b.BuildID, templateID, clusterID, *b.ClusterNodeID); err != nil { + logger.L().Warn(ctx, "Failed to delete build artifacts", + logger.WithTemplateID(templateID), + zap.String("build_id", b.BuildID.String()), + zap.Error(err), + ) + } else { + logger.L().Info(ctx, "Deleted build artifacts", + logger.WithTemplateID(templateID), + zap.String("build_id", b.BuildID.String()), + ) + } + } +} + +// deletableAfterReachabilityGuard returns the subset of candidate builds that +// are safe to physically delete: those NOT reachable from the header closure of +// any other live template of the team. It fails closed (returns an error) when +// the ancestor reader is unavailable or any live root's headers cannot be read, +// so the caller deletes nothing rather than risk corrupting a shared ancestor. +func (a *APIStore) deletableAfterReachabilityGuard(ctx context.Context, teamID uuid.UUID, templateID string, candidates []uuid.UUID) (map[uuid.UUID]struct{}, error) { + if len(candidates) == 0 { + return map[uuid.UUID]struct{}{}, nil + } + + if a.buildAncestors == nil { + return nil, fmt.Errorf("build ancestor reader not configured; cannot prove reachability") + } + + liveRoots, err := a.collectLiveRoots(ctx, teamID, templateID) + if err != nil { + return nil, err + } + + keep, err := reachableKeepSet(ctx, a.buildAncestors, liveRoots) + if err != nil { + return nil, err + } + + deletable, _ := partitionDeletableBuilds(candidates, keep) + + out := make(map[uuid.UUID]struct{}, len(deletable)) + for _, b := range deletable { + out[b] = struct{}{} + } + + return out, nil +} + +// liveSandboxStates are the sandbox states whose build must be protected: any +// state in which the sandbox may still read the build's data. A sandbox in one +// of these states can pin a build that is no longer any template's current +// assignment. +var liveSandboxStates = []sandbox.State{ + sandbox.StateRunning, + sandbox.StatePausing, + sandbox.StateSnapshotting, + sandbox.StateKilling, +} + +// collectLiveRoots unions the live-root build IDs from all three sources whose +// data a deletion must not orphan: (1) builds assigned to the team's other +// active templates (DB), (2) the team's in-progress builds still writing to +// storage (DB), and (3) builds pinned by the team's live sandboxes in the +// orchestrator (Redis) — which may reference a build no longer tracked as any +// template's current assignment. Any source failing returns an error so the +// caller fails closed. +func (a *APIStore) collectLiveRoots(ctx context.Context, teamID uuid.UUID, templateID string) ([]uuid.UUID, error) { + seen := make(map[uuid.UUID]struct{}) + var roots []uuid.UUID + add := func(id uuid.UUID) { + if _, ok := seen[id]; ok { + return + } + + seen[id] = struct{}{} + roots = append(roots, id) + } + + dbRoots, err := a.sqlcDB.GetLiveBuildRootsForTeamExcludingTemplate(ctx, queries.GetLiveBuildRootsForTeamExcludingTemplateParams{ + TeamID: teamID, + TemplateID: templateID, + }) + if err != nil { + return nil, fmt.Errorf("query live build roots: %w", err) + } + for _, id := range dbRoots { + add(id) + } + + inProgress, err := a.sqlcDB.GetInProgressBuildRootsForTeam(ctx, teamID) + if err != nil { + return nil, fmt.Errorf("query in-progress build roots: %w", err) + } + for _, id := range inProgress { + add(id) + } + + if a.runtimeSandboxes == nil { + return nil, fmt.Errorf("runtime sandbox lister not configured; cannot prove reachability") + } + + sandboxes, err := a.runtimeSandboxes.GetSandboxes(ctx, teamID, liveSandboxStates) + if err != nil { + return nil, fmt.Errorf("list live sandboxes: %w", err) + } + for _, sbx := range sandboxes { + add(sbx.BuildID) + } + + return roots, 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() @@ -129,6 +305,10 @@ func (a *APIStore) DeleteTemplatesTemplateID(c *gin.Context, aliasOrTemplateID a a.templateCache.InvalidateAllTags(context.WithoutCancel(ctx), templateID) a.templateCache.InvalidateAliasesByTemplateID(context.WithoutCancel(ctx), templateID, aliasKeys) + // Delete GCS/S3 artifacts in the background; DB deletion is already committed + // so artifact cleanup failures must not block or fail the HTTP response. + go a.deleteTemplateArtifacts(context.WithoutCancel(ctx), team.ID, templateID) + telemetry.ReportEvent(ctx, "deleted template from db") properties := a.posthog.GetPackageToPosthogProperties(&c.Request.Header) diff --git a/packages/api/internal/handlers/template_delete_guard.go b/packages/api/internal/handlers/template_delete_guard.go new file mode 100644 index 0000000000..813cf25846 --- /dev/null +++ b/packages/api/internal/handlers/template_delete_guard.go @@ -0,0 +1,155 @@ +package handlers + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/e2b-dev/infra/packages/api/internal/sandbox" + "github.com/e2b-dev/infra/packages/shared/pkg/logger" + "github.com/e2b-dev/infra/packages/shared/pkg/storage" + "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" +) + +// buildAncestorReader resolves the set of ancestor build IDs that a given build +// actually references through its header block mappings (memfile + rootfs). A +// template build stores only the bytes it changed relative to its ancestors; +// the full image is reconstructed by following the header's BuildMap into +// parent, grandparent, ... builds. Those ancestor builds are NOT recorded in +// the database (intermediate cache layers never get an env_build_assignments +// row), so the only source of truth for "which builds does this one depend on" +// is the header mapping itself. +type buildAncestorReader interface { + // AncestorBuilds returns every build ID referenced by buildID's memfile and + // rootfs header mappings, including buildID itself. An error means the + // dependency set could not be established and the caller must fail closed. + AncestorBuilds(ctx context.Context, buildID uuid.UUID) ([]uuid.UUID, error) +} + +// runtimeSandboxLister enumerates the team's sandboxes that are live in the +// orchestrator for the given states. A running/pausing/snapshotting sandbox can +// pin a build that is no longer the current DB assignment of any template (it +// was superseded by a newer build), so its BuildID would be missed by the +// DB-only live-root query. It must therefore be added to the live roots. The +// orchestrator's GetSandboxes satisfies this. +type runtimeSandboxLister interface { + GetSandboxes(ctx context.Context, teamID uuid.UUID, states []sandbox.State) ([]sandbox.Sandbox, error) +} + +// reachableKeepSet unions the ancestor closure of every live root build into a +// single "keep" set. Any build in this set is still referenced by a live +// template of the team and must never be deleted. A read failure for any root +// is returned as an error so the caller fails closed (deletes nothing) rather +// than risk deleting a build whose reachability could not be proven. +func reachableKeepSet(ctx context.Context, reader buildAncestorReader, liveRoots []uuid.UUID) (map[uuid.UUID]struct{}, error) { + keep := make(map[uuid.UUID]struct{}) + + for _, root := range liveRoots { + ancestors, err := reader.AncestorBuilds(ctx, root) + if err != nil { + return nil, fmt.Errorf("resolve ancestors of live root %s: %w", root, err) + } + + for _, a := range ancestors { + keep[a] = struct{}{} + } + } + + return keep, nil +} + +// partitionDeletableBuilds splits candidate builds (those the DB considers +// exclusive to the doomed template) into the ones that are safe to physically +// delete and the ones that must be protected because a live root's header +// mapping still references them. A candidate is deletable only if it is NOT in +// the keep set. This is the guard against the silent, permanent "Input/output +// error" corruption that results from deleting a build whose blocks another +// live template still reconstructs from. +func partitionDeletableBuilds(candidates []uuid.UUID, keep map[uuid.UUID]struct{}) (deletable, protected []uuid.UUID) { + for _, b := range candidates { + if _, kept := keep[b]; kept { + protected = append(protected, b) + + continue + } + + deletable = append(deletable, b) + } + + return deletable, protected +} + +// storageBuildAncestorReader is the production buildAncestorReader. It reads the +// two header sidecar objects of a build from template storage and unions the +// build IDs their block mappings reference. +type storageBuildAncestorReader struct { + storage storage.StorageProvider +} + +func newStorageBuildAncestorReader(s storage.StorageProvider) *storageBuildAncestorReader { + return &storageBuildAncestorReader{storage: s} +} + +func (r *storageBuildAncestorReader) AncestorBuilds(ctx context.Context, buildID uuid.UUID) ([]uuid.UUID, error) { + paths := storage.Paths{BuildID: buildID.String()} + + seen := map[uuid.UUID]struct{}{buildID: {}} + + // Both the memfile and rootfs headers carry their own mapping; a build can + // share a parent on one and not the other, so both must be read. A missing + // or unparseable header means the dependency set is unknown -> error -> + // caller fails closed. + for _, hdrPath := range []string{paths.MemfileHeader(), paths.RootfsHeader()} { + h, _, err := header.LoadHeader(ctx, r.storage, hdrPath) + if err != nil { + return nil, fmt.Errorf("load header %s: %w", hdrPath, err) + } + + for _, b := range h.Mapping.Builds() { + seen[b] = struct{}{} + } + } + + out := make([]uuid.UUID, 0, len(seen)) + for b := range seen { + out = append(out, b) + } + + return out, nil +} + +// newTemplateBuildAncestorReader builds the production ancestor reader from the +// template storage destination in the environment (TEMPLATE_STORAGE_URL). It is +// best-effort: if the destination is unset or the provider cannot be built, it +// returns nil, and the deletion guard then fails closed (deletes nothing), +// which is no worse than leaving artifacts in place. Credentials are never +// echoed; only a build error (which may embed the bucket name, not secrets) is +// logged. +func newTemplateBuildAncestorReader(ctx context.Context) buildAncestorReader { + raw := strings.TrimSpace(os.Getenv("TEMPLATE_STORAGE_URL")) + if raw == "" { + logger.L().Warn(ctx, "TEMPLATE_STORAGE_URL unset; template artifact cleanup will fail closed") + + return nil + } + + spec, err := storage.ParseStorageURL(raw) + if err != nil { + logger.L().Warn(ctx, "Could not parse TEMPLATE_STORAGE_URL; template artifact cleanup will fail closed", zap.Error(err)) + + return nil + } + + provider, err := storage.NewProvider(ctx, spec) + if err != nil { + logger.L().Warn(ctx, "Could not build template storage provider; template artifact cleanup will fail closed", zap.Error(err)) + + return nil + } + + return newStorageBuildAncestorReader(provider) +} diff --git a/packages/api/internal/handlers/template_delete_guard_test.go b/packages/api/internal/handlers/template_delete_guard_test.go new file mode 100644 index 0000000000..39bf8a82d3 --- /dev/null +++ b/packages/api/internal/handlers/template_delete_guard_test.go @@ -0,0 +1,134 @@ +package handlers + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/api/internal/sandbox" +) + +// stubAncestorReader is a test buildAncestorReader. AncestorBuilds returns the +// build itself plus any ancestors configured for it; a non-nil err makes every +// lookup fail (to exercise the fail-closed path). +type stubAncestorReader struct { + ancestors map[uuid.UUID][]uuid.UUID + err error +} + +func (s stubAncestorReader) AncestorBuilds(_ context.Context, buildID uuid.UUID) ([]uuid.UUID, error) { + if s.err != nil { + return nil, s.err + } + + out := []uuid.UUID{buildID} + out = append(out, s.ancestors[buildID]...) + + return out, nil +} + +// stubSandboxLister is a test runtimeSandboxLister. +type stubSandboxLister struct { + sandboxes []sandbox.Sandbox + err error +} + +func (s stubSandboxLister) GetSandboxes(_ context.Context, _ uuid.UUID, _ []sandbox.State) ([]sandbox.Sandbox, error) { + return s.sandboxes, s.err +} + +// TestReachabilityGuard_ProtectsReferencedAncestor reproduces the corruption +// scenario: a build that is DB-exclusive to the doomed template is nonetheless a +// header-mapping ancestor of another live template's build. Deleting it would +// orphan that ancestor and later surface as an unrecoverable I/O error. The +// guard must keep it (not delete it). +func TestReachabilityGuard_ProtectsReferencedAncestor(t *testing.T) { + t.Parallel() + + sharedAncestor := uuid.New() // DB-exclusive to the doomed template... + liveRoot := uuid.New() // ...but a live template's build references it. + + reader := stubAncestorReader{ + ancestors: map[uuid.UUID][]uuid.UUID{ + liveRoot: {sharedAncestor}, + }, + } + + keep, err := reachableKeepSet(context.Background(), reader, []uuid.UUID{liveRoot}) + require.NoError(t, err) + + deletable, protected := partitionDeletableBuilds([]uuid.UUID{sharedAncestor}, keep) + + assert.Empty(t, deletable, "a build referenced by a live root's header closure must not be deletable") + assert.Equal(t, []uuid.UUID{sharedAncestor}, protected) +} + +// TestReachabilityGuard_OrphanIsDeletable verifies a build that no live root +// references is deletable. +func TestReachabilityGuard_OrphanIsDeletable(t *testing.T) { + t.Parallel() + + orphan := uuid.New() + liveRoot := uuid.New() + unrelatedAncestor := uuid.New() + + reader := stubAncestorReader{ + ancestors: map[uuid.UUID][]uuid.UUID{ + liveRoot: {unrelatedAncestor}, + }, + } + + keep, err := reachableKeepSet(context.Background(), reader, []uuid.UUID{liveRoot}) + require.NoError(t, err) + + deletable, protected := partitionDeletableBuilds([]uuid.UUID{orphan}, keep) + + assert.Equal(t, []uuid.UUID{orphan}, deletable) + assert.Empty(t, protected, "an unreferenced build should not be protected") +} + +// TestReachabilityGuard_FailsClosedOnReadError verifies that if any live root's +// header cannot be read, the keep set resolution errors so the caller deletes +// nothing rather than risk deleting a build whose reachability is unknown. +func TestReachabilityGuard_FailsClosedOnReadError(t *testing.T) { + t.Parallel() + + reader := stubAncestorReader{err: errors.New("header missing/corrupt")} + + _, err := reachableKeepSet(context.Background(), reader, []uuid.UUID{uuid.New()}) + + require.Error(t, err, "an unreadable live-root header must fail closed, not be treated as no-dependency") +} + +// TestReachabilityGuard_UnionsMultipleRoots verifies the keep set is the union +// of every live root's ancestor closure. +func TestReachabilityGuard_UnionsMultipleRoots(t *testing.T) { + t.Parallel() + + a1, a2 := uuid.New(), uuid.New() + root1, root2 := uuid.New(), uuid.New() + + reader := stubAncestorReader{ + ancestors: map[uuid.UUID][]uuid.UUID{ + root1: {a1}, + root2: {a2}, + }, + } + + keep, err := reachableKeepSet(context.Background(), reader, []uuid.UUID{root1, root2}) + require.NoError(t, err) + + // a1 and a2 both protected; an unrelated build is not. + deletable, protected := partitionDeletableBuilds([]uuid.UUID{a1, a2, uuid.New()}, keep) + + assert.Len(t, protected, 2) + assert.Len(t, deletable, 1) + assert.Contains(t, keep, root1) + assert.Contains(t, keep, root2) + assert.Contains(t, keep, a1) + assert.Contains(t, keep, a2) +} diff --git a/packages/api/internal/handlers/template_delete_test.go b/packages/api/internal/handlers/template_delete_test.go new file mode 100644 index 0000000000..a01d924729 --- /dev/null +++ b/packages/api/internal/handlers/template_delete_test.go @@ -0,0 +1,286 @@ +package handlers + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates" + "github.com/e2b-dev/infra/packages/api/internal/sandbox" + "github.com/e2b-dev/infra/packages/db/pkg/testutils" + redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis" +) + +// TestDeleteTemplateArtifacts_ExclusiveBuildsAreDeleted verifies that +// deleteTemplateArtifactsWithDeleter calls the deleter for each build that has a +// ClusterNodeID (i.e. is materialized on a node) and that it skips builds without +// a ClusterNodeID. +func TestDeleteTemplateArtifacts_ExclusiveBuildsAreDeleted(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + + // Build with a node: should be passed to the deleter + buildWithNode := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildWithNode, "v1") + + // Build without a node: CreateTestBuild uses cluster_node_id = 'test-node', + // so create a second one without cluster_node_id via raw SQL. + buildNoNode := uuid.New() + err := db.SqlcClient.TestsRawSQL(ctx, + `INSERT INTO public.env_builds + (id, env_id, status, vcpu, ram_mb, free_disk_size_mb, kernel_version, firecracker_version, created_at, updated_at) + VALUES ($1, $2, 'success', 2, 2048, 512, '6.1.0', '1.4.0', NOW(), NOW())`, + buildNoNode, templateID, + ) + require.NoError(t, err) + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildNoNode, "v2") + + store := &APIStore{ + sqlcDB: db.SqlcClient, + templateCache: templatecache.NewTemplateCache(db.SqlcClient, redis), + buildAncestors: stubAncestorReader{}, + runtimeSandboxes: stubSandboxLister{}, + } + + var mu sync.Mutex + deleted := []uuid.UUID{} + deleteFn := func(_ context.Context, buildID uuid.UUID, _ string, _ uuid.UUID, _ string) error { + mu.Lock() + deleted = append(deleted, buildID) + mu.Unlock() + + return nil + } + + store.deleteTemplateArtifactsWithDeleter(ctx, teamID, templateID, deleteFn) + + assert.Equal(t, []uuid.UUID{buildWithNode}, deleted, + "only the build with a cluster node should be passed to the deleter") +} + +// TestDeleteTemplateArtifacts_SharedBuildsSkipped verifies that builds assigned +// to multiple templates are not passed to the deleter. +func TestDeleteTemplateArtifacts_SharedBuildsSkipped(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateA := testutils.CreateTestTemplate(t, db, teamID) + templateB := testutils.CreateTestTemplate(t, db, teamID) + + // Shared build: belongs to both templates + shared := testutils.CreateTestBuild(t, ctx, db, templateA, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateA, shared, "latest") + testutils.CreateTestBuildAssignment(t, ctx, db, templateB, shared, "latest") + + // Exclusive build: only on templateA + exclusive := testutils.CreateTestBuild(t, ctx, db, templateA, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateA, exclusive, "v2") + + store := &APIStore{ + sqlcDB: db.SqlcClient, + templateCache: templatecache.NewTemplateCache(db.SqlcClient, redis), + buildAncestors: stubAncestorReader{}, + runtimeSandboxes: stubSandboxLister{}, + } + + var mu sync.Mutex + deleted := []uuid.UUID{} + deleteFn := func(_ context.Context, buildID uuid.UUID, _ string, _ uuid.UUID, _ string) error { + mu.Lock() + deleted = append(deleted, buildID) + mu.Unlock() + + return nil + } + + store.deleteTemplateArtifactsWithDeleter(ctx, teamID, templateA, deleteFn) + + assert.Equal(t, []uuid.UUID{exclusive}, deleted, + "shared build must not be deleted; only the exclusive build should be") +} + +// TestDeleteTemplateArtifacts_DeleterErrorIsNonFatal verifies that when the +// deleter returns an error the function continues and does not panic or propagate +// the error (artifact deletion is best-effort after DB soft-delete). +func TestDeleteTemplateArtifacts_DeleterErrorIsNonFatal(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + + build1 := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, build1, "v1") + + build2 := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, build2, "v2") + + store := &APIStore{ + sqlcDB: db.SqlcClient, + templateCache: templatecache.NewTemplateCache(db.SqlcClient, redis), + buildAncestors: stubAncestorReader{}, + runtimeSandboxes: stubSandboxLister{}, + } + + var mu sync.Mutex + attempted := []uuid.UUID{} + deleteFn := func(_ context.Context, buildID uuid.UUID, _ string, _ uuid.UUID, _ string) error { + mu.Lock() + attempted = append(attempted, buildID) + mu.Unlock() + + return errors.New("storage backend unavailable") + } + + // Must not panic or return early; all builds should be attempted. + assert.NotPanics(t, func() { + store.deleteTemplateArtifactsWithDeleter(ctx, teamID, templateID, deleteFn) + }) + + assert.Len(t, attempted, 2, "both builds should be attempted even when the first fails") +} + +// TestDeleteTemplateArtifacts_WorksAfterSoftDelete verifies that the artifact +// cleanup still finds builds after the template row has been soft-deleted. +func TestDeleteTemplateArtifacts_WorksAfterSoftDelete(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "latest") + + // Simulate what softDeleteTemplate does + err := db.SqlcClient.TestsRawSQL(ctx, + `UPDATE public.envs SET deleted_at = NOW() WHERE id = $1`, + templateID, + ) + require.NoError(t, err) + + store := &APIStore{ + sqlcDB: db.SqlcClient, + templateCache: templatecache.NewTemplateCache(db.SqlcClient, redis), + buildAncestors: stubAncestorReader{}, + runtimeSandboxes: stubSandboxLister{}, + } + + var mu sync.Mutex + deleted := []uuid.UUID{} + deleteFn := func(_ context.Context, id uuid.UUID, _ string, _ uuid.UUID, _ string) error { + mu.Lock() + deleted = append(deleted, id) + mu.Unlock() + + return nil + } + + store.deleteTemplateArtifactsWithDeleter(ctx, teamID, templateID, deleteFn) + + assert.Equal(t, []uuid.UUID{buildID}, deleted, + "build should still be found and deleted after the template is soft-deleted") +} + +// TestDeleteTemplateArtifacts_RuntimeSandboxRootProtectsBuild proves that a +// build pinned by a live sandbox (reported by the runtime sandbox lister) is +// protected from deletion even though it is DB-exclusive to the template being +// deleted. This covers the Redis/runtime-root gap: a sandbox can still read a +// build that is no longer any template's current DB assignment. +func TestDeleteTemplateArtifacts_RuntimeSandboxRootProtectsBuild(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + + // A build exclusive to the doomed template at the DB level. + pinned := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, pinned, "v1") + + // A live sandbox pins that build via the runtime lister. Its header closure + // (self-only stub) therefore protects it. + store := &APIStore{ + sqlcDB: db.SqlcClient, + templateCache: templatecache.NewTemplateCache(db.SqlcClient, redis), + buildAncestors: stubAncestorReader{}, + runtimeSandboxes: stubSandboxLister{ + sandboxes: []sandbox.Sandbox{{BuildID: pinned}}, + }, + } + + var mu sync.Mutex + deleted := []uuid.UUID{} + deleteFn := func(_ context.Context, buildID uuid.UUID, _ string, _ uuid.UUID, _ string) error { + mu.Lock() + deleted = append(deleted, buildID) + mu.Unlock() + + return nil + } + + store.deleteTemplateArtifactsWithDeleter(ctx, teamID, templateID, deleteFn) + + assert.Empty(t, deleted, + "a build pinned by a live sandbox must not be deleted even if DB-exclusive to the doomed template") +} + +// TestDeleteTemplateArtifacts_FailsClosedWhenSandboxListerErrors proves that if +// the runtime sandbox source cannot be read, nothing is deleted (fail closed). +func TestDeleteTemplateArtifacts_FailsClosedWhenSandboxListerErrors(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "v1") + + store := &APIStore{ + sqlcDB: db.SqlcClient, + templateCache: templatecache.NewTemplateCache(db.SqlcClient, redis), + buildAncestors: stubAncestorReader{}, + runtimeSandboxes: stubSandboxLister{err: errors.New("orchestrator unreachable")}, + } + + var mu sync.Mutex + deleted := []uuid.UUID{} + deleteFn := func(_ context.Context, id uuid.UUID, _ string, _ uuid.UUID, _ string) error { + mu.Lock() + deleted = append(deleted, id) + mu.Unlock() + + return nil + } + + store.deleteTemplateArtifactsWithDeleter(ctx, teamID, templateID, deleteFn) + + assert.Empty(t, deleted, + "when the runtime sandbox source cannot be read, deletion must fail closed") +} diff --git a/packages/db/queries/builds/get_exclusive_builds_for_template_deletion.sql b/packages/db/queries/builds/get_exclusive_builds_for_template_deletion.sql index f990fc01cc..00e676901f 100644 --- a/packages/db/queries/builds/get_exclusive_builds_for_template_deletion.sql +++ b/packages/db/queries/builds/get_exclusive_builds_for_template_deletion.sql @@ -2,12 +2,14 @@ -- Returns builds that are ONLY assigned to this template (safe to delete). -- Builds shared with other templates are excluded. -- DISTINCT needed because builds may have multiple tag assignments to the same template. -SELECT DISTINCT eb.id as build_id, eb.cluster_node_id +-- Joins envs (not active_envs) so the query works after the template is soft-deleted. +SELECT DISTINCT eb.id as build_id, eb.cluster_node_id, e.cluster_id FROM "public"."env_build_assignments" eba JOIN "public"."env_builds" eb ON eb.id = eba.build_id +JOIN "public"."envs" e ON e.id = eba.env_id WHERE eba.env_id = @template_id AND NOT EXISTS ( -- Exclude builds that have assignments to OTHER templates SELECT 1 FROM "public"."env_build_assignments" other_eba WHERE other_eba.build_id = eb.id AND other_eba.env_id != @template_id -); \ No newline at end of file +); diff --git a/packages/db/queries/builds/get_inprogress_build_roots.sql b/packages/db/queries/builds/get_inprogress_build_roots.sql new file mode 100644 index 0000000000..9190bfcefa --- /dev/null +++ b/packages/db/queries/builds/get_inprogress_build_roots.sql @@ -0,0 +1,9 @@ +-- name: GetInProgressBuildRootsForTeam :many +-- Returns the build IDs of the team's in-progress template builds (data is +-- being written to storage but the build is not yet finalized in env_builds, +-- so it may not have a stable env_build_assignments row yet). These must be +-- treated as live roots so a concurrent template deletion cannot reclaim a +-- layer an in-flight build still depends on. +SELECT build_id +FROM "public"."active_template_builds" +WHERE team_id = @team_id; diff --git a/packages/db/queries/builds/get_live_build_roots.sql b/packages/db/queries/builds/get_live_build_roots.sql new file mode 100644 index 0000000000..0224510f14 --- /dev/null +++ b/packages/db/queries/builds/get_live_build_roots.sql @@ -0,0 +1,13 @@ +-- name: GetLiveBuildRootsForTeamExcludingTemplate :many +-- Returns the build IDs of every build assigned to a still-active (not +-- soft-deleted) template of the team, EXCLUDING the template being deleted. +-- These are the "live roots" whose header mappings may still reference — and +-- therefore protect — a build that the deletion query considers exclusive to +-- the doomed template. Cache sharing is team-scoped, so a cross-template +-- ancestor can only come from the same team; this bounds the set. +SELECT DISTINCT eb.id AS build_id +FROM "public"."env_build_assignments" eba +JOIN "public"."env_builds" eb ON eb.id = eba.build_id +JOIN "public"."active_envs" e ON e.id = eba.env_id +WHERE e.team_id = @team_id + AND eba.env_id != @template_id; diff --git a/packages/db/queries/get_exclusive_builds_for_template_deletion.sql.go b/packages/db/queries/get_exclusive_builds_for_template_deletion.sql.go index 17d902d6a6..739c86f64e 100644 --- a/packages/db/queries/get_exclusive_builds_for_template_deletion.sql.go +++ b/packages/db/queries/get_exclusive_builds_for_template_deletion.sql.go @@ -12,9 +12,10 @@ import ( ) const getExclusiveBuildsForTemplateDeletion = `-- name: GetExclusiveBuildsForTemplateDeletion :many -SELECT DISTINCT eb.id as build_id, eb.cluster_node_id +SELECT DISTINCT eb.id as build_id, eb.cluster_node_id, e.cluster_id FROM "public"."env_build_assignments" eba JOIN "public"."env_builds" eb ON eb.id = eba.build_id +JOIN "public"."envs" e ON e.id = eba.env_id WHERE eba.env_id = $1 AND NOT EXISTS ( -- Exclude builds that have assignments to OTHER templates @@ -26,11 +27,13 @@ WHERE eba.env_id = $1 type GetExclusiveBuildsForTemplateDeletionRow struct { BuildID uuid.UUID ClusterNodeID *string + ClusterID *uuid.UUID } // Returns builds that are ONLY assigned to this template (safe to delete). // Builds shared with other templates are excluded. // DISTINCT needed because builds may have multiple tag assignments to the same template. +// Joins envs (not active_envs) so the query works after the template is soft-deleted. func (q *Queries) GetExclusiveBuildsForTemplateDeletion(ctx context.Context, templateID string) ([]GetExclusiveBuildsForTemplateDeletionRow, error) { rows, err := q.db.Query(ctx, getExclusiveBuildsForTemplateDeletion, templateID) if err != nil { @@ -40,7 +43,7 @@ func (q *Queries) GetExclusiveBuildsForTemplateDeletion(ctx context.Context, tem var items []GetExclusiveBuildsForTemplateDeletionRow for rows.Next() { var i GetExclusiveBuildsForTemplateDeletionRow - if err := rows.Scan(&i.BuildID, &i.ClusterNodeID); err != nil { + if err := rows.Scan(&i.BuildID, &i.ClusterNodeID, &i.ClusterID); err != nil { return nil, err } items = append(items, i) diff --git a/packages/db/queries/get_exclusive_builds_for_template_deletion_test.go b/packages/db/queries/get_exclusive_builds_for_template_deletion_test.go new file mode 100644 index 0000000000..57467594f9 --- /dev/null +++ b/packages/db/queries/get_exclusive_builds_for_template_deletion_test.go @@ -0,0 +1,160 @@ +package queries_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/db/pkg/testutils" +) + +// TestGetExclusiveBuildsForTemplateDeletion_ExclusiveBuild verifies that a build +// assigned only to the target template is returned by the query. +func TestGetExclusiveBuildsForTemplateDeletion_ExclusiveBuild(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, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "latest") + + rows, err := db.SqlcClient.GetExclusiveBuildsForTemplateDeletion(ctx, templateID) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, buildID, rows[0].BuildID) + assert.Equal(t, "test-node", *rows[0].ClusterNodeID) +} + +// TestGetExclusiveBuildsForTemplateDeletion_SharedBuildExcluded verifies that a +// build assigned to multiple templates is NOT returned (it would break the other template). +func TestGetExclusiveBuildsForTemplateDeletion_SharedBuildExcluded(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateA := testutils.CreateTestTemplate(t, db, teamID) + templateB := testutils.CreateTestTemplate(t, db, teamID) + + // Shared build: assigned to both templates + sharedBuildID := testutils.CreateTestBuild(t, ctx, db, templateA, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateA, sharedBuildID, "latest") + testutils.CreateTestBuildAssignment(t, ctx, db, templateB, sharedBuildID, "latest") + + // Exclusive build: only on templateA + exclusiveBuildID := testutils.CreateTestBuild(t, ctx, db, templateA, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateA, exclusiveBuildID, "v2") + + rows, err := db.SqlcClient.GetExclusiveBuildsForTemplateDeletion(ctx, templateA) + require.NoError(t, err) + require.Len(t, rows, 1, "only the exclusive build should be returned") + assert.Equal(t, exclusiveBuildID, rows[0].BuildID) +} + +// TestGetExclusiveBuildsForTemplateDeletion_WorksAfterSoftDelete verifies that +// the query still returns builds after the template has been soft-deleted, because +// it joins the raw envs table rather than the active_envs view. +func TestGetExclusiveBuildsForTemplateDeletion_WorksAfterSoftDelete(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, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "latest") + + // Soft-delete the template + err := db.SqlcClient.TestsRawSQL(ctx, + `UPDATE public.envs SET deleted_at = NOW() WHERE id = $1`, + templateID, + ) + require.NoError(t, err, "soft-delete template") + + // The query must still work after soft-delete + rows, err := db.SqlcClient.GetExclusiveBuildsForTemplateDeletion(ctx, templateID) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, buildID, rows[0].BuildID) +} + +// TestGetExclusiveBuildsForTemplateDeletion_ReturnsClusterID verifies that +// cluster_id from the envs table is included in the result. +func TestGetExclusiveBuildsForTemplateDeletion_ReturnsClusterID(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + + // Insert a cluster record first (FK constraint) then link the template to it + clusterID := uuid.New() + err := db.SqlcClient.TestsRawSQL(ctx, + `INSERT INTO public.clusters (id, endpoint, endpoint_tls, token, name) VALUES ($1, 'https://cluster.local', false, 'tok', 'test-cluster')`, + clusterID, + ) + require.NoError(t, err, "insert cluster") + + err = db.SqlcClient.TestsRawSQL(ctx, + `UPDATE public.envs SET cluster_id = $1 WHERE id = $2`, + clusterID, templateID, + ) + require.NoError(t, err, "set cluster_id") + + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, "latest") + + rows, err := db.SqlcClient.GetExclusiveBuildsForTemplateDeletion(ctx, templateID) + require.NoError(t, err) + require.Len(t, rows, 1) + require.NotNil(t, rows[0].ClusterID) + assert.Equal(t, clusterID, *rows[0].ClusterID) +} + +// TestGetExclusiveBuildsForTemplateDeletion_NoBuilds verifies that templates +// with no builds return an empty result without error. +func TestGetExclusiveBuildsForTemplateDeletion_NoBuilds(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + + rows, err := db.SqlcClient.GetExclusiveBuildsForTemplateDeletion(ctx, templateID) + require.NoError(t, err) + assert.Empty(t, rows) +} + +// TestGetExclusiveBuildsForTemplateDeletion_MultipleExclusiveBuilds verifies +// that all exclusive builds for a template are returned, not just the first. +func TestGetExclusiveBuildsForTemplateDeletion_MultipleExclusiveBuilds(t *testing.T) { + t.Parallel() + + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + templateID := testutils.CreateTestTemplate(t, db, teamID) + + buildIDs := make([]uuid.UUID, 3) + for i := range buildIDs { + buildID := testutils.CreateTestBuild(t, ctx, db, templateID, "success") + testutils.CreateTestBuildAssignment(t, ctx, db, templateID, buildID, uuid.New().String()) + buildIDs[i] = buildID + } + + rows, err := db.SqlcClient.GetExclusiveBuildsForTemplateDeletion(ctx, templateID) + require.NoError(t, err) + assert.Len(t, rows, 3) +} diff --git a/packages/db/queries/get_inprogress_build_roots.sql.go b/packages/db/queries/get_inprogress_build_roots.sql.go new file mode 100644 index 0000000000..6363397bf2 --- /dev/null +++ b/packages/db/queries/get_inprogress_build_roots.sql.go @@ -0,0 +1,43 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_inprogress_build_roots.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" +) + +const getInProgressBuildRootsForTeam = `-- name: GetInProgressBuildRootsForTeam :many +SELECT build_id +FROM "public"."active_template_builds" +WHERE team_id = $1 +` + +// Returns the build IDs of the team's in-progress template builds (data is +// being written to storage but the build is not yet finalized in env_builds, +// so it may not have a stable env_build_assignments row yet). These must be +// treated as live roots so a concurrent template deletion cannot reclaim a +// layer an in-flight build still depends on. +func (q *Queries) GetInProgressBuildRootsForTeam(ctx context.Context, teamID uuid.UUID) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, getInProgressBuildRootsForTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var build_id uuid.UUID + if err := rows.Scan(&build_id); err != nil { + return nil, err + } + items = append(items, build_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/packages/db/queries/get_live_build_roots.sql.go b/packages/db/queries/get_live_build_roots.sql.go new file mode 100644 index 0000000000..1820af9314 --- /dev/null +++ b/packages/db/queries/get_live_build_roots.sql.go @@ -0,0 +1,48 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: get_live_build_roots.sql + +package queries + +import ( + "context" + + "github.com/google/uuid" +) + +const getLiveBuildRootsForTeamExcludingTemplate = `-- name: GetLiveBuildRootsForTeamExcludingTemplate :many +SELECT DISTINCT eb.id AS build_id +FROM "public"."env_build_assignments" eba +JOIN "public"."env_builds" eb ON eb.id = eba.build_id +JOIN "public"."active_envs" e ON e.id = eba.env_id +WHERE e.team_id = $1 + AND eba.env_id != $2 +` + +type GetLiveBuildRootsForTeamExcludingTemplateParams struct { + TeamID uuid.UUID + TemplateID string +} + +// Returns the build IDs of every build assigned to a still-active (not +// soft-deleted) template of the team, EXCLUDING the template being deleted. +func (q *Queries) GetLiveBuildRootsForTeamExcludingTemplate(ctx context.Context, arg GetLiveBuildRootsForTeamExcludingTemplateParams) ([]uuid.UUID, error) { + rows, err := q.db.Query(ctx, getLiveBuildRootsForTeamExcludingTemplate, arg.TeamID, arg.TemplateID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []uuid.UUID + for rows.Next() { + var build_id uuid.UUID + if err := rows.Scan(&build_id); err != nil { + return nil, err + } + items = append(items, build_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +}