Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
400 changes: 212 additions & 188 deletions packages/api/internal/api/api.gen.go

Large diffs are not rendered by default.

53 changes: 52 additions & 1 deletion packages/api/internal/handlers/snapshot_template_list.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package handlers

import (
"errors"
"fmt"
"net/http"
"strings"
"time"

"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/trace"

"github.com/e2b-dev/infra/packages/api/internal/api"
templatecache "github.com/e2b-dev/infra/packages/api/internal/cache/templates"
"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"
Expand All @@ -25,7 +29,8 @@ const (
func (a *APIStore) GetSnapshots(c *gin.Context, params api.GetSnapshotsParams) {
ctx := c.Request.Context()

teamID := auth.MustGetTeamID(c)
teamInfo := auth.MustGetTeamInfo(c)
teamID := teamInfo.Team.ID

span := trace.SpanFromContext(ctx)
traceID := span.SpanContext().TraceID().String()
Expand Down Expand Up @@ -64,9 +69,55 @@ func (a *APIStore) GetSnapshots(c *gin.Context, params api.GetSnapshotsParams) {
sandboxIDFilter = &short
}

var envIDFilter, tagFilter *string
if params.Name != nil {
identifier, tag, err := id.ParseName(*params.Name)
if err != nil {
a.sendAPIStoreError(c, http.StatusBadRequest, fmt.Sprintf("Invalid name: %s", err))

return
}

if err := id.ValidateNamespaceMatchesTeam(identifier, teamInfo.Slug); err != nil {
a.sendAPIStoreError(c, http.StatusBadRequest, err.Error())

return
}

// ParseName normalizes an explicit ":default" tag to nil; re-check the raw
// input so "name:default" filters by the default tag, while a bare "name"
// matches builds of any tag.
tagFilter = tag
if _, _, hasTag := strings.Cut(*params.Name, id.TagSeparator); hasTag && tagFilter == nil {
defaultTag := id.DefaultTag
tagFilter = &defaultTag
}

// Resolve alias using the cache — same pattern as template builds. Team
// ownership is enforced by the query's team_id predicate, so a name that
// resolves to another team's template yields an empty list.
aliasInfo, err := a.templateCache.ResolveAlias(ctx, identifier, teamInfo.Slug)
switch {
case err == nil:
envIDFilter = &aliasInfo.TemplateID
case errors.Is(err, templatecache.ErrTemplateNotFound):
c.JSON(http.StatusOK, []api.SnapshotInfo{})

return
default:
apiErr := templatecache.ErrorToAPIError(err, identifier)
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)
telemetry.ReportCriticalError(ctx, "error resolving snapshot template alias", apiErr.Err)

return
}
}

snapshots, err := a.sqlcDB.ListTeamSnapshotTemplates(ctx, queries.ListTeamSnapshotTemplatesParams{
TeamID: teamID,
SandboxID: sandboxIDFilter,
EnvID: envIDFilter,
Tag: tagFilter,
Comment thread
cursor[bot] marked this conversation as resolved.
CursorTime: pagination.CursorTime(),
CursorID: pagination.CursorID(),
PageLimit: pagination.QueryLimit(),
Expand Down
24 changes: 18 additions & 6 deletions packages/db/queries/list_team_snapshot_templates.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions packages/db/queries/snapshots/list_team_snapshot_templates.sql
Comment thread
mishushakov marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,14 @@ SELECT
FROM "public"."active_envs" e
JOIN "public"."snapshot_templates" st ON st.env_id = e.id
JOIN LATERAL (
-- When a tag filter is set, pick the newest build with that tag; the inner
-- join then also drops snapshots with no successful build for the tag.
SELECT b.*, ba.tag
FROM "public"."env_build_assignments" ba
JOIN "public"."env_builds" b ON b.id = ba.build_id
WHERE ba.env_id = e.id AND b.status IN ('success', 'uploaded', 'ready')
WHERE ba.env_id = e.id
AND b.status IN ('success', 'uploaded', 'ready')
AND (sqlc.narg(tag)::text IS NULL OR ba.tag = sqlc.narg(tag)::text)
ORDER BY ba.created_at DESC
LIMIT 1
) eb ON TRUE
Expand All @@ -35,9 +39,13 @@ LEFT JOIN LATERAL (
WHERE e.team_id = @team_id
AND e.source = 'snapshot_template'
AND (
sqlc.narg(sandbox_id)::text IS NULL
sqlc.narg(sandbox_id)::text IS NULL
OR st.sandbox_id = sqlc.narg(sandbox_id)::text
)
AND (
sqlc.narg(env_id)::text IS NULL
OR e.id = sqlc.narg(env_id)::text
)
AND (e.created_at, e.id) < (@cursor_time, @cursor_id::text)
ORDER BY e.created_at DESC, e.id DESC
LIMIT @page_limit;
6 changes: 6 additions & 0 deletions spec/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2711,6 +2711,12 @@ paths:
schema:
type: string
description: Filter snapshots by source sandbox ID
- name: name
in: query
description: Filter snapshots by name or ID, optionally tag-qualified (e.g. "my-snapshot", "my-team/my-snapshot" or "my-snapshot:v1").
required: false
schema:
type: string
- $ref: "#/components/parameters/paginationLimit"
- $ref: "#/components/parameters/paginationNextToken"
responses:
Expand Down
15 changes: 15 additions & 0 deletions tests/integration/internal/api/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,103 @@ func TestSnapshotTemplateList(t *testing.T) {
assert.NotEmpty(t, snap.SnapshotID)
}
})

t.Run("list snapshots filtered by name", func(t *testing.T) {
t.Parallel()
sbx := utils.SetupSandboxWithCleanup(t, c, utils.WithAutoPause(false))

name := "list-by-name-" + sbx.SandboxID
snapshot := createSnapshotTemplateWithCleanup(t, c, sbx.SandboxID, &name)

listSnapshots := func(filter string) []api.SnapshotInfo {
listResp, err := c.GetSnapshotsWithResponse(t.Context(), &api.GetSnapshotsParams{
Name: &filter,
}, setup.WithAPIKey())
require.NoError(t, err)
require.Equal(t, http.StatusOK, listResp.StatusCode())
require.NotNil(t, listResp.JSON200)

return *listResp.JSON200
}

snapshots := listSnapshots(name)
require.Len(t, snapshots, 1)
assert.Equal(t, snapshot.SnapshotID, snapshots[0].SnapshotID)

// The namespaced form returned in Names should also match
require.NotEmpty(t, snapshot.Names)
snapshots = listSnapshots(snapshot.Names[0])
require.Len(t, snapshots, 1)
assert.Equal(t, snapshot.SnapshotID, snapshots[0].SnapshotID)

// A name that doesn't resolve returns an empty list
assert.Empty(t, listSnapshots("missing-"+sbx.SandboxID))

// A tag that has no build on the snapshot returns an empty list
assert.Empty(t, listSnapshots(name+":missing-tag"))

// The default tag has a build, so it matches
snapshots = listSnapshots(name + ":default")
require.Len(t, snapshots, 1)
assert.Equal(t, snapshot.SnapshotID, snapshots[0].SnapshotID)
})

t.Run("list snapshots filtered by older tag shows that tag", func(t *testing.T) {
t.Parallel()
sbx := utils.SetupSandboxWithCleanup(t, c, utils.WithAutoPause(false))

base := "list-by-tag-" + sbx.SandboxID
snapDefault := createSnapshotTemplateWithCleanup(t, c, sbx.SandboxID, &base)
require.Contains(t, snapDefault.SnapshotID, ":default")

nameV2 := base + ":v2"
resp2 := createSnapshotTemplate(t, c, sbx.SandboxID, &nameV2)
require.Equal(t, http.StatusCreated, resp2.StatusCode())
require.NotNil(t, resp2.JSON201)

listSnapshots := func(filter string) []api.SnapshotInfo {
listResp, err := c.GetSnapshotsWithResponse(t.Context(), &api.GetSnapshotsParams{
Name: &filter,
}, setup.WithAPIKey())
require.NoError(t, err)
require.Equal(t, http.StatusOK, listResp.StatusCode())
require.NotNil(t, listResp.JSON200)

return *listResp.JSON200
}

// Filtering by an older tag (here the explicit default) must match and
// show that tag's build, not the newest one
snapshots := listSnapshots(base + ":default")
require.Len(t, snapshots, 1)
assert.Equal(t, snapDefault.SnapshotID, snapshots[0].SnapshotID)

snapshots = listSnapshots(nameV2)
require.Len(t, snapshots, 1)
assert.Equal(t, resp2.JSON201.SnapshotID, snapshots[0].SnapshotID)

// A bare name matches builds of any tag and shows the newest
snapshots = listSnapshots(base)
require.Len(t, snapshots, 1)
assert.Equal(t, resp2.JSON201.SnapshotID, snapshots[0].SnapshotID)
})

t.Run("list unnamed snapshot filtered by its ID", func(t *testing.T) {
t.Parallel()
sbx := utils.SetupSandboxWithCleanup(t, c, utils.WithAutoPause(false))

snapshot := createSnapshotTemplateWithCleanup(t, c, sbx.SandboxID, nil)
templateID, _, _ := strings.Cut(snapshot.SnapshotID, ":")

listResp, err := c.GetSnapshotsWithResponse(t.Context(), &api.GetSnapshotsParams{
Name: &templateID,
}, setup.WithAPIKey())
require.NoError(t, err)
require.Equal(t, http.StatusOK, listResp.StatusCode())
require.NotNil(t, listResp.JSON200)
require.Len(t, *listResp.JSON200, 1)
assert.Equal(t, snapshot.SnapshotID, (*listResp.JSON200)[0].SnapshotID)
})
}

func TestSnapshotTemplateDelete(t *testing.T) {
Expand Down
Loading