From a83b938b28f2326c64e412871381661ea462981d Mon Sep 17 00:00:00 2001 From: AdaAibaby Date: Wed, 13 May 2026 19:37:03 +0800 Subject: [PATCH 1/2] fix(snapshot): scope GetLastSnapshot query by teamID to prevent unauthorized cross-team access --- .../cache/snapshots/snapshot_cache.go | 45 +++++ .../cache/snapshots/snapshot_cache_test.go | 158 ++++++++++++++++++ .../api/internal/handlers/sandbox_connect.go | 10 +- packages/api/internal/handlers/sandbox_get.go | 12 +- .../api/internal/handlers/sandbox_pause.go | 12 +- .../api/internal/handlers/sandbox_resume.go | 10 +- .../get_last_snapshot_by_team_test.go | 152 +++++++++++++++++ packages/db/queries/get_last_snapshot.sql.go | 80 +++++++++ .../queries/snapshots/get_last_snapshot.sql | 21 +++ spec/openapi.yml | 20 +-- 10 files changed, 471 insertions(+), 49 deletions(-) create mode 100644 packages/api/internal/cache/snapshots/snapshot_cache_test.go create mode 100644 packages/db/pkg/tests/snapshots/get_last_snapshot_by_team_test.go diff --git a/packages/api/internal/cache/snapshots/snapshot_cache.go b/packages/api/internal/cache/snapshots/snapshot_cache.go index b8823de053..a94c044e13 100644 --- a/packages/api/internal/cache/snapshots/snapshot_cache.go +++ b/packages/api/internal/cache/snapshots/snapshot_cache.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.opentelemetry.io/otel" @@ -73,6 +74,50 @@ func (c *SnapshotCache) Get(ctx context.Context, sandboxID string) (*SnapshotInf return info, nil } +// GetByTeam returns the last snapshot for a sandbox scoped to a specific team. +// It uses the cache for the initial lookup and validates team ownership at the DB level, +// avoiding a separate post-fetch ownership check. +func (c *SnapshotCache) GetByTeam(ctx context.Context, sandboxID string, teamID uuid.UUID) (*SnapshotInfo, error) { + ctx, span := tracer.Start(ctx, "get last snapshot by team") + defer span.End() + + // Try cache first; if the cached entry belongs to the right team, return it directly. + info, err := c.cache.GetOrSet(ctx, sandboxID, c.fetchFromDB) + if err != nil { + return nil, err + } + + if info.NotFound { + return nil, ErrSnapshotNotFound + } + + // Cache hit and team matches – fast path. + if info.Snapshot.TeamID == teamID { + return info, nil + } + + // Cache hit but team mismatch: the cached entry may belong to a different team. + // Fall back to a team-scoped DB query to get the authoritative answer. + row, err := c.db.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandboxID, + TeamID: teamID, + }) + if err != nil { + if dberrors.IsNotFoundError(err) { + return nil, ErrSnapshotNotFound + } + + return nil, fmt.Errorf("fetching last snapshot by team: %w", err) + } + + return &SnapshotInfo{ + Aliases: row.Aliases, + Names: row.Names, + Snapshot: row.Snapshot, + EnvBuild: row.EnvBuild, + }, nil +} + func (c *SnapshotCache) fetchFromDB(ctx context.Context, sandboxID string) (*SnapshotInfo, error) { ctx, span := tracer.Start(ctx, "fetch last snapshot from DB") defer span.End() diff --git a/packages/api/internal/cache/snapshots/snapshot_cache_test.go b/packages/api/internal/cache/snapshots/snapshot_cache_test.go new file mode 100644 index 0000000000..703d059596 --- /dev/null +++ b/packages/api/internal/cache/snapshots/snapshot_cache_test.go @@ -0,0 +1,158 @@ +package snapshotcache +package snapshotcache + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "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/pkg/types" + "github.com/e2b-dev/infra/packages/db/queries" + redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis" +) + +func setupCache(t *testing.T) (*SnapshotCache, *testutils.Database) { + t.Helper() + db := testutils.SetupDatabase(t) + redis := redis_utils.SetupInstance(t) + cache := NewSnapshotCache(db.SqlcClient, redis) + t.Cleanup(func() { _ = cache.Close(t.Context()) }) + + return cache, db +} + +func upsertSnapshot(t *testing.T, db *testutils.Database, teamID uuid.UUID, baseTemplateID string) (sandboxID string) { + t.Helper() + sandboxID = "sandbox-" + uuid.New().String() + envdVersion := "v1.0.0" + totalDisk := int64(1024) + allowInternet := true + + _, err := db.SqlcClient.UpsertSnapshot(t.Context(), queries.UpsertSnapshotParams{ + TemplateID: "tmpl-" + uuid.New().String(), + TeamID: teamID, + SandboxID: sandboxID, + BaseTemplateID: baseTemplateID, + StartedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + Vcpu: 2, + RamMb: 2048, + TotalDiskSizeMb: &totalDisk, + Metadata: types.JSONBStringMap{}, + KernelVersion: "6.1.0", + FirecrackerVersion: "1.4.0", + EnvdVersion: &envdVersion, + Secure: false, + AllowInternetAccess: &allowInternet, + AutoPause: true, + OriginNodeID: "test-node", + Status: types.BuildStatusSuccess, + }) + require.NoError(t, err) + + return sandboxID +} + +// TestSnapshotCache_GetByTeam_HitCorrectTeam verifies that GetByTeam returns the +// snapshot when the teamID matches the snapshot owner. +func TestSnapshotCache_GetByTeam_HitCorrectTeam(t *testing.T) { + t.Parallel() + cache, db := setupCache(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, teamID) + sandboxID := upsertSnapshot(t, db, teamID, baseTemplateID) + + info, err := cache.GetByTeam(ctx, sandboxID, teamID) + require.NoError(t, err) + assert.Equal(t, sandboxID, info.Snapshot.SandboxID) + assert.Equal(t, teamID, info.Snapshot.TeamID) +} + +// TestSnapshotCache_GetByTeam_WrongTeamReturnsNotFound verifies that GetByTeam +// returns ErrSnapshotNotFound when the teamID does not match the snapshot owner. +func TestSnapshotCache_GetByTeam_WrongTeamReturnsNotFound(t *testing.T) { + t.Parallel() + cache, db := setupCache(t) + ctx := t.Context() + + ownerTeamID := testutils.CreateTestTeam(t, db) + otherTeamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, ownerTeamID) + sandboxID := upsertSnapshot(t, db, ownerTeamID, baseTemplateID) + + // Warm the cache with the owner's team entry. + _, err := cache.Get(ctx, sandboxID) + require.NoError(t, err) + + // Now query with a different team – should fall back to DB and return not-found. + _, err = cache.GetByTeam(ctx, sandboxID, otherTeamID) + require.ErrorIs(t, err, ErrSnapshotNotFound) +} + +// TestSnapshotCache_GetByTeam_CacheHitFastPath verifies that when the cached entry +// already belongs to the requested team, GetByTeam returns it without a DB round-trip. +// We verify this indirectly: after the first call populates the cache, a second call +// with the same teamID must also succeed. +func TestSnapshotCache_GetByTeam_CacheHitFastPath(t *testing.T) { + t.Parallel() + cache, db := setupCache(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, teamID) + sandboxID := upsertSnapshot(t, db, teamID, baseTemplateID) + + // First call – populates cache. + info1, err := cache.GetByTeam(ctx, sandboxID, teamID) + require.NoError(t, err) + + // Second call – should hit cache fast path. + info2, err := cache.GetByTeam(ctx, sandboxID, teamID) + require.NoError(t, err) + + assert.Equal(t, info1.Snapshot.SandboxID, info2.Snapshot.SandboxID) + assert.Equal(t, info1.EnvBuild.ID, info2.EnvBuild.ID) +} + +// TestSnapshotCache_GetByTeam_UnknownSandboxReturnsNotFound verifies that +// GetByTeam returns ErrSnapshotNotFound for a sandboxID that does not exist. +func TestSnapshotCache_GetByTeam_UnknownSandboxReturnsNotFound(t *testing.T) { + t.Parallel() + cache, db := setupCache(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + + _, err := cache.GetByTeam(ctx, "nonexistent-sandbox-"+uuid.New().String(), teamID) + require.ErrorIs(t, err, ErrSnapshotNotFound) +} + +// TestSnapshotCache_GetByTeam_InvalidateFlushesCache verifies that after Invalidate +// is called, GetByTeam re-fetches from the DB. +func TestSnapshotCache_GetByTeam_InvalidateFlushesCache(t *testing.T) { + t.Parallel() + cache, db := setupCache(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, teamID) + sandboxID := upsertSnapshot(t, db, teamID, baseTemplateID) + + // Populate cache. + _, err := cache.GetByTeam(ctx, sandboxID, teamID) + require.NoError(t, err) + + // Invalidate. + cache.Invalidate(ctx, sandboxID) + + // Should still succeed (re-fetches from DB). + info, err := cache.GetByTeam(ctx, sandboxID, teamID) + require.NoError(t, err) + assert.Equal(t, sandboxID, info.Snapshot.SandboxID) +} diff --git a/packages/api/internal/handlers/sandbox_connect.go b/packages/api/internal/handlers/sandbox_connect.go index 746165556c..e4d38ed88c 100644 --- a/packages/api/internal/handlers/sandbox_connect.go +++ b/packages/api/internal/handlers/sandbox_connect.go @@ -115,8 +115,7 @@ func (a *APIStore) PostSandboxesSandboxIDConnect(c *gin.Context, sandboxID api.S continue } - // TODO: ENG-3544 scope GetLastSnapshot query by teamID to avoid post-fetch ownership check. - lastSnapshot, err := a.snapshotCache.Get(ctx, sandboxID) + lastSnapshot, err := a.snapshotCache.GetByTeam(ctx, sandboxID, teamID) if err != nil { if errors.Is(err, snapshotcache.ErrSnapshotNotFound) { logger.L().Debug(ctx, "Snapshot not found", logger.WithSandboxID(sandboxID)) @@ -131,13 +130,6 @@ func (a *APIStore) PostSandboxesSandboxIDConnect(c *gin.Context, sandboxID api.S return } - if lastSnapshot.Snapshot.TeamID != teamID { - telemetry.ReportError(ctx, fmt.Sprintf("snapshot for sandbox '%s' doesn't belong to team '%s'", sandboxID, teamID.String()), nil) - a.sendAPIStoreError(c, http.StatusNotFound, utils.SandboxNotFoundMsg(sandboxID)) - - return - } - sbxlogger.E(&sbxlogger.SandboxMetadata{ SandboxID: sandboxID, TemplateID: lastSnapshot.Snapshot.EnvID, diff --git a/packages/api/internal/handlers/sandbox_get.go b/packages/api/internal/handlers/sandbox_get.go index 0509d8025c..a246533f17 100644 --- a/packages/api/internal/handlers/sandbox_get.go +++ b/packages/api/internal/handlers/sandbox_get.go @@ -162,9 +162,8 @@ func (a *APIStore) GetSandboxesSandboxID(c *gin.Context, id string) { return } - // If sandbox not found try to get the latest snapshot - // TODO: ENG-3544 scope GetLastSnapshot query by teamID to avoid post-fetch ownership check. - lastSnapshot, err := a.snapshotCache.Get(ctx, sandboxId) + // If sandbox not found try to get the latest snapshot scoped to this team. + lastSnapshot, err := a.snapshotCache.GetByTeam(ctx, sandboxId, team.ID) if err != nil { if errors.Is(err, snapshotcache.ErrSnapshotNotFound) { telemetry.ReportError(ctx, "snapshot not found", err, telemetry.WithSandboxID(sandboxId)) @@ -179,13 +178,6 @@ func (a *APIStore) GetSandboxesSandboxID(c *gin.Context, id string) { return } - if lastSnapshot.Snapshot.TeamID != team.ID { - telemetry.ReportError(ctx, fmt.Sprintf("snapshot for sandbox '%s' doesn't belong to team '%s'", sandboxId, team.ID.String()), nil) - a.sendAPIStoreError(c, http.StatusNotFound, utils.SandboxNotFoundMsg(id)) - - return - } - memoryMB := int32(lastSnapshot.EnvBuild.RamMb) cpuCount := int32(lastSnapshot.EnvBuild.Vcpu) diff --git a/packages/api/internal/handlers/sandbox_pause.go b/packages/api/internal/handlers/sandbox_pause.go index 7da8505b75..38bf2f7ffb 100644 --- a/packages/api/internal/handlers/sandbox_pause.go +++ b/packages/api/internal/handlers/sandbox_pause.go @@ -81,18 +81,8 @@ func (a *APIStore) PostSandboxesSandboxIDPause(c *gin.Context, sandboxID api.San } func pauseHandleNotRunningSandbox(ctx context.Context, cache *snapshotcache.SnapshotCache, sandboxID string, teamID uuid.UUID) api.APIError { - // TODO: ENG-3544 scope GetLastSnapshot query by teamID to avoid post-fetch ownership check. - snap, err := cache.Get(ctx, sandboxID) + _, err := cache.GetByTeam(ctx, sandboxID, teamID) if err == nil { - if snap.Snapshot.TeamID != teamID { - logger.L().Debug(ctx, "Snapshot team mismatch on pause", logger.WithSandboxID(sandboxID), logger.WithTeamID(teamID.String())) - - return api.APIError{ - Code: http.StatusNotFound, - ClientMsg: utils.SandboxNotFoundMsg(sandboxID), - } - } - logger.L().Warn(ctx, "Sandbox is already paused", logger.WithSandboxID(sandboxID)) return api.APIError{ diff --git a/packages/api/internal/handlers/sandbox_resume.go b/packages/api/internal/handlers/sandbox_resume.go index 1535e35d6f..c66cd9d236 100644 --- a/packages/api/internal/handlers/sandbox_resume.go +++ b/packages/api/internal/handlers/sandbox_resume.go @@ -120,8 +120,7 @@ func (a *APIStore) PostSandboxesSandboxIDResume(c *gin.Context, sandboxID api.Sa } } - // TODO: ENG-3544 scope GetLastSnapshot query by teamID to avoid post-fetch ownership check. - lastSnapshot, err := a.snapshotCache.Get(ctx, sandboxID) + lastSnapshot, err := a.snapshotCache.GetByTeam(ctx, sandboxID, teamID) if err != nil { if errors.Is(err, snapshotcache.ErrSnapshotNotFound) { logger.L().Debug(ctx, "Snapshot not found", logger.WithSandboxID(sandboxID)) @@ -139,13 +138,6 @@ func (a *APIStore) PostSandboxesSandboxIDResume(c *gin.Context, sandboxID api.Sa return } - if lastSnapshot.Snapshot.TeamID != teamID { - telemetry.ReportError(ctx, fmt.Sprintf("snapshot for sandbox '%s' doesn't belong to team '%s'", sandboxID, teamID.String()), nil) - a.sendAPIStoreError(c, http.StatusNotFound, utils.SandboxNotFoundMsg(sandboxID)) - - return - } - sbxlogger.E(&sbxlogger.SandboxMetadata{ SandboxID: sandboxID, TemplateID: lastSnapshot.Snapshot.EnvID, diff --git a/packages/db/pkg/tests/snapshots/get_last_snapshot_by_team_test.go b/packages/db/pkg/tests/snapshots/get_last_snapshot_by_team_test.go new file mode 100644 index 0000000000..1be5837aef --- /dev/null +++ b/packages/db/pkg/tests/snapshots/get_last_snapshot_by_team_test.go @@ -0,0 +1,152 @@ +package snapshots + +import ( + "testing" + "time" + + "github.com/google/uuid" + "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" +) + +// TestGetLastSnapshotByTeam_ReturnsSnapshotForCorrectTeam verifies that +// GetLastSnapshotByTeam returns the snapshot when sandboxID and teamID both match. +func TestGetLastSnapshotByTeam_ReturnsSnapshotForCorrectTeam(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, teamID) + + sandboxID := "sandbox-" + uuid.New().String() + snapshotTemplateID := "snapshot-template-" + uuid.New().String() + + result := testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID) + + snapshot, err := db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandboxID, + TeamID: teamID, + }) + require.NoError(t, err) + assert.Equal(t, result.BuildID, snapshot.EnvBuild.ID) + assert.Equal(t, teamID, snapshot.Snapshot.TeamID) + assert.Equal(t, sandboxID, snapshot.Snapshot.SandboxID) +} + +// TestGetLastSnapshotByTeam_ReturnsNotFoundForWrongTeam verifies that +// GetLastSnapshotByTeam returns an error when the teamID does not match the snapshot owner. +func TestGetLastSnapshotByTeam_ReturnsNotFoundForWrongTeam(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + ownerTeamID := testutils.CreateTestTeam(t, db) + otherTeamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, ownerTeamID) + + sandboxID := "sandbox-" + uuid.New().String() + snapshotTemplateID := "snapshot-template-" + uuid.New().String() + + testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, ownerTeamID, baseTemplateID) + + _, err := db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandboxID, + TeamID: otherTeamID, + }) + require.Error(t, err, "should return error when teamID does not match snapshot owner") +} + +// TestGetLastSnapshotByTeam_ReturnsNotFoundForUnknownSandbox verifies that +// GetLastSnapshotByTeam returns an error when the sandboxID does not exist. +func TestGetLastSnapshotByTeam_ReturnsNotFoundForUnknownSandbox(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + + _, err := db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: "nonexistent-sandbox-" + uuid.New().String(), + TeamID: teamID, + }) + require.Error(t, err, "should return error for unknown sandboxID") +} + +// TestGetLastSnapshotByTeam_ReturnsLatestBuildForTeam verifies that when multiple +// builds exist for the same sandbox, GetLastSnapshotByTeam returns the latest one. +func TestGetLastSnapshotByTeam_ReturnsLatestBuildForTeam(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + teamID := testutils.CreateTestTeam(t, db) + baseTemplateID := testutils.CreateTestTemplate(t, db, teamID) + + sandboxID := "sandbox-" + uuid.New().String() + snapshotTemplateID := "snapshot-template-" + uuid.New().String() + + testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID) + time.Sleep(10 * time.Millisecond) + result2 := testutils.UpsertTestSnapshot(t, ctx, db, snapshotTemplateID, sandboxID, teamID, baseTemplateID) + + snapshot, err := db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandboxID, + TeamID: teamID, + }) + require.NoError(t, err) + assert.Equal(t, result2.BuildID, snapshot.EnvBuild.ID, + "should return the latest build for the team") +} + +// TestGetLastSnapshotByTeam_IsolatesBetweenTeams verifies that two teams can each +// have a snapshot for different sandboxes and each only sees their own. +func TestGetLastSnapshotByTeam_IsolatesBetweenTeams(t *testing.T) { + t.Parallel() + db := testutils.SetupDatabase(t) + ctx := t.Context() + + team1ID := testutils.CreateTestTeam(t, db) + team2ID := testutils.CreateTestTeam(t, db) + base1 := testutils.CreateTestTemplate(t, db, team1ID) + base2 := testutils.CreateTestTemplate(t, db, team2ID) + + sandbox1ID := "sandbox-" + uuid.New().String() + sandbox2ID := "sandbox-" + uuid.New().String() + + result1 := testutils.UpsertTestSnapshot(t, ctx, db, "tmpl-"+uuid.New().String(), sandbox1ID, team1ID, base1) + result2 := testutils.UpsertTestSnapshot(t, ctx, db, "tmpl-"+uuid.New().String(), sandbox2ID, team2ID, base2) + + // team1 can see sandbox1 + snap1, err := db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandbox1ID, + TeamID: team1ID, + }) + require.NoError(t, err) + assert.Equal(t, result1.BuildID, snap1.EnvBuild.ID) + + // team2 can see sandbox2 + snap2, err := db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandbox2ID, + TeamID: team2ID, + }) + require.NoError(t, err) + assert.Equal(t, result2.BuildID, snap2.EnvBuild.ID) + + // team1 cannot see sandbox2 + _, err = db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandbox2ID, + TeamID: team1ID, + }) + require.Error(t, err, "team1 should not be able to see team2's sandbox") + + // team2 cannot see sandbox1 + _, err = db.SqlcClient.GetLastSnapshotByTeam(ctx, queries.GetLastSnapshotByTeamParams{ + SandboxID: sandbox1ID, + TeamID: team2ID, + }) + require.Error(t, err, "team2 should not be able to see team1's sandbox") +} diff --git a/packages/db/queries/get_last_snapshot.sql.go b/packages/db/queries/get_last_snapshot.sql.go index 76fe6599a2..a141e026e8 100644 --- a/packages/db/queries/get_last_snapshot.sql.go +++ b/packages/db/queries/get_last_snapshot.sql.go @@ -7,6 +7,8 @@ package queries import ( "context" + + "github.com/google/uuid" ) const getLastSnapshot = `-- name: GetLastSnapshot :one @@ -86,3 +88,81 @@ func (q *Queries) GetLastSnapshot(ctx context.Context, sandboxID string) (GetLas ) return i, err } + +const getLastSnapshotByTeam = `-- name: GetLastSnapshotByTeam :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 LATERAL ( + SELECT eba.build_id + FROM "public"."env_build_assignments" eba + JOIN "public"."env_builds" eb_inner ON eb_inner.id = eba.build_id AND eb_inner.status_group = 'ready' + WHERE eba.env_id = s.env_id AND eba.tag = 'default' + ORDER BY eba.created_at DESC + LIMIT 1 +) latest_eba ON TRUE +JOIN "public"."env_builds" eb ON eb.id = latest_eba.build_id +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(alias ORDER BY alias) AS aliases, + ARRAY_AGG(CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END ORDER BY alias) AS names + FROM "public"."env_aliases" + WHERE env_id = s.base_env_id +) ea ON TRUE +WHERE s.sandbox_id = $1 AND s.team_id = $2 +` + +type GetLastSnapshotByTeamParams struct { + SandboxID string + TeamID uuid.UUID +} + +type GetLastSnapshotByTeamRow = GetLastSnapshotRow + +func (q *Queries) GetLastSnapshotByTeam(ctx context.Context, arg GetLastSnapshotByTeamParams) (GetLastSnapshotByTeamRow, error) { + row := q.db.QueryRow(ctx, getLastSnapshotByTeam, arg.SandboxID, arg.TeamID) + var i GetLastSnapshotByTeamRow + err := row.Scan( + &i.Aliases, + &i.Names, + &i.Snapshot.CreatedAt, + &i.Snapshot.EnvID, + &i.Snapshot.SandboxID, + &i.Snapshot.ID, + &i.Snapshot.Metadata, + &i.Snapshot.BaseEnvID, + &i.Snapshot.SandboxStartedAt, + &i.Snapshot.EnvSecure, + &i.Snapshot.OriginNodeID, + &i.Snapshot.AllowInternetAccess, + &i.Snapshot.AutoPause, + &i.Snapshot.TeamID, + &i.Snapshot.Config, + &i.EnvBuild.ID, + &i.EnvBuild.CreatedAt, + &i.EnvBuild.UpdatedAt, + &i.EnvBuild.FinishedAt, + &i.EnvBuild.Status, + &i.EnvBuild.Dockerfile, + &i.EnvBuild.StartCmd, + &i.EnvBuild.Vcpu, + &i.EnvBuild.RamMb, + &i.EnvBuild.FreeDiskSizeMb, + &i.EnvBuild.TotalDiskSizeMb, + &i.EnvBuild.KernelVersion, + &i.EnvBuild.FirecrackerVersion, + &i.EnvBuild.EnvID, + &i.EnvBuild.EnvdVersion, + &i.EnvBuild.ReadyCmd, + &i.EnvBuild.ClusterNodeID, + &i.EnvBuild.Reason, + &i.EnvBuild.Version, + &i.EnvBuild.CpuArchitecture, + &i.EnvBuild.CpuFamily, + &i.EnvBuild.CpuModel, + &i.EnvBuild.CpuModelName, + &i.EnvBuild.CpuFlags, + &i.EnvBuild.StatusGroup, + &i.EnvBuild.TeamID, + ) + return i, err +} diff --git a/packages/db/queries/snapshots/get_last_snapshot.sql b/packages/db/queries/snapshots/get_last_snapshot.sql index e835fe2d13..9d80377533 100644 --- a/packages/db/queries/snapshots/get_last_snapshot.sql +++ b/packages/db/queries/snapshots/get_last_snapshot.sql @@ -18,3 +18,24 @@ LEFT JOIN LATERAL ( WHERE env_id = s.base_env_id ) ea ON TRUE WHERE s.sandbox_id = $1; + +-- name: GetLastSnapshotByTeam :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 LATERAL ( + SELECT eba.build_id + FROM "public"."env_build_assignments" eba + JOIN "public"."env_builds" eb_inner ON eb_inner.id = eba.build_id AND eb_inner.status_group = 'ready' + WHERE eba.env_id = s.env_id AND eba.tag = 'default' + ORDER BY eba.created_at DESC + LIMIT 1 +) latest_eba ON TRUE +JOIN "public"."env_builds" eb ON eb.id = latest_eba.build_id +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(alias ORDER BY alias) AS aliases, + ARRAY_AGG(CASE WHEN namespace IS NOT NULL THEN namespace || '/' || alias ELSE alias END ORDER BY alias) AS names + FROM "public"."env_aliases" + WHERE env_id = s.base_env_id +) ea ON TRUE +WHERE s.sandbox_id = $1 AND s.team_id = $2; diff --git a/spec/openapi.yml b/spec/openapi.yml index 9b7157faa8..349abecc63 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -633,12 +633,13 @@ components: $ref: "#/components/schemas/SandboxMetric" NewSandbox: - required: - - templateID properties: templateID: type: string - description: Identifier of the required template + description: Identifier of the required template. Either templateID or snapshotID must be provided. + snapshotID: + type: string + description: Identifier of the snapshot to resume from. Either templateID or snapshotID must be provided. When specified, the sandbox will be created from the snapshot state. timeout: type: integer format: int32 @@ -1987,7 +1988,7 @@ paths: "500": $ref: "#/components/responses/500" post: - description: Create a sandbox from the template + description: Create a sandbox from a template or snapshot. Provide either templateID to create from a template, or snapshotID to resume from a paused sandbox snapshot. tags: [sandboxes] security: - ApiKeyAuth: [] @@ -2001,7 +2002,7 @@ paths: $ref: "#/components/schemas/NewSandbox" responses: "201": - description: The sandbox was created successfully + description: The sandbox was created successfully from the template or snapshot content: application/json: schema: @@ -2273,10 +2274,9 @@ paths: "500": $ref: "#/components/responses/500" - # TODO: Pause and resume might be exposed as POST /sandboxes/{sandboxID}/snapshot and then POST /sandboxes with specified snapshotting setup /sandboxes/{sandboxID}/pause: post: - description: Pause the sandbox + description: Pause the sandbox. This creates an automatic snapshot of the sandbox state that can be used to resume it later via POST /sandboxes with the snapshotID parameter. tags: [sandboxes] security: - ApiKeyAuth: [] @@ -2286,7 +2286,7 @@ paths: - $ref: "#/components/parameters/sandboxID" responses: "204": - description: The sandbox was paused successfully and can be resumed + description: The sandbox was paused successfully. An automatic snapshot was created and can be used to resume the sandbox later. "409": $ref: "#/components/responses/409" "404": @@ -2299,7 +2299,7 @@ paths: /sandboxes/{sandboxID}/resume: post: deprecated: true - description: Resume the sandbox + description: Resume the sandbox. DEPRECATED - Use POST /sandboxes with snapshotID parameter instead to create a new sandbox from a snapshot. tags: [sandboxes] security: - ApiKeyAuth: [] @@ -2315,7 +2315,7 @@ paths: $ref: "#/components/schemas/ResumedSandbox" responses: "201": - description: The sandbox was resumed successfully + description: The sandbox was resumed successfully. This endpoint is deprecated - use POST /sandboxes with snapshotID instead. content: application/json: schema: From 4eab008e03a0bc47215b89fed389bb0c71bcd66f Mon Sep 17 00:00:00 2001 From: AdaAibaby Date: Wed, 13 May 2026 19:50:55 +0800 Subject: [PATCH 2/2] fix: remove duplicate package declaration in test; revert premature snapshotID from spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate 'package snapshotcache' line in snapshot_cache_test.go (caused compile error, caught by Codex/Gemini review) - Revert snapshotID field from NewSandbox schema and related descriptions in openapi.yml — backend handler not yet implemented, advertising it breaks the API contract (caught by Codex P2 review) --- .../cache/snapshots/snapshot_cache_test.go | 1 - spec/openapi.yml | 15 +++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/api/internal/cache/snapshots/snapshot_cache_test.go b/packages/api/internal/cache/snapshots/snapshot_cache_test.go index 703d059596..42a16ea66c 100644 --- a/packages/api/internal/cache/snapshots/snapshot_cache_test.go +++ b/packages/api/internal/cache/snapshots/snapshot_cache_test.go @@ -1,5 +1,4 @@ package snapshotcache -package snapshotcache import ( "testing" diff --git a/spec/openapi.yml b/spec/openapi.yml index 349abecc63..b08cb75fe7 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -633,13 +633,12 @@ components: $ref: "#/components/schemas/SandboxMetric" NewSandbox: + required: + - templateID properties: templateID: type: string - description: Identifier of the required template. Either templateID or snapshotID must be provided. - snapshotID: - type: string - description: Identifier of the snapshot to resume from. Either templateID or snapshotID must be provided. When specified, the sandbox will be created from the snapshot state. + description: Identifier of the required template. timeout: type: integer format: int32 @@ -1988,7 +1987,7 @@ paths: "500": $ref: "#/components/responses/500" post: - description: Create a sandbox from a template or snapshot. Provide either templateID to create from a template, or snapshotID to resume from a paused sandbox snapshot. + description: Create a new sandbox from a template. tags: [sandboxes] security: - ApiKeyAuth: [] @@ -2276,7 +2275,7 @@ paths: /sandboxes/{sandboxID}/pause: post: - description: Pause the sandbox. This creates an automatic snapshot of the sandbox state that can be used to resume it later via POST /sandboxes with the snapshotID parameter. + description: Pause the sandbox. This creates an automatic snapshot of the sandbox state that can be used to resume it later. tags: [sandboxes] security: - ApiKeyAuth: [] @@ -2299,7 +2298,7 @@ paths: /sandboxes/{sandboxID}/resume: post: deprecated: true - description: Resume the sandbox. DEPRECATED - Use POST /sandboxes with snapshotID parameter instead to create a new sandbox from a snapshot. + description: Resume the sandbox. DEPRECATED - Use POST /sandboxes instead. tags: [sandboxes] security: - ApiKeyAuth: [] @@ -2315,7 +2314,7 @@ paths: $ref: "#/components/schemas/ResumedSandbox" responses: "201": - description: The sandbox was resumed successfully. This endpoint is deprecated - use POST /sandboxes with snapshotID instead. + description: The sandbox was resumed successfully. This endpoint is deprecated. content: application/json: schema: