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
621 changes: 443 additions & 178 deletions packages/api/internal/api/api.gen.go

Large diffs are not rendered by default.

125 changes: 125 additions & 0 deletions packages/api/internal/handlers/templates_list_v2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package handlers

import (
"net/http"
"time"

"github.com/gin-gonic/gin"

"github.com/e2b-dev/infra/packages/api/internal/api"
"github.com/e2b-dev/infra/packages/api/internal/utils"
"github.com/e2b-dev/infra/packages/db/queries"
"github.com/e2b-dev/infra/packages/shared/pkg/telemetry"
)

const (
templatesDefaultLimit = int32(100)
templatesMaxLimit = int32(100)
)

// GetV2Templates lists a team's templates with cursor pagination (e.g. in the CLI).
func (a *APIStore) GetV2Templates(c *gin.Context, params api.GetV2TemplatesParams) {
Comment thread
huv1k marked this conversation as resolved.
ctx := c.Request.Context()

team, apiErr := a.GetTeam(ctx, c, params.TeamID)
if apiErr != nil {
a.sendAPIStoreError(c, apiErr.Code, apiErr.ClientMsg)
telemetry.ReportCriticalError(ctx, "error when getting team and tier", apiErr.Err)

return
}

if params.TeamID != nil && team.ID.String() != *params.TeamID {
a.sendAPIStoreError(c, http.StatusBadRequest, "Team ID param mismatch with the API key")
telemetry.ReportError(ctx, "team param mismatch with the API key", nil, telemetry.WithTeamID(team.ID.String()))

return
}

telemetry.SetAttributes(ctx,
telemetry.WithTeamID(team.ID.String()),
)

pagination, err := utils.NewPagination[*api.Template](
utils.PaginationParams{
Limit: params.Limit,
NextToken: params.NextToken,
},
utils.PaginationConfig{
DefaultLimit: templatesDefaultLimit,
MaxLimit: templatesMaxLimit,
DefaultID: utils.MaxTemplateID,
},
)
if err != nil {
telemetry.ReportError(ctx, "error parsing pagination cursor", err)
a.sendAPIStoreError(c, http.StatusBadRequest, "Invalid next token")

return
}

rows, err := a.sqlcDB.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
TeamID: team.ID,
CursorCreatedAt: pagination.CursorTime(),
CursorID: pagination.CursorID(),
LimitPlusOne: pagination.QueryLimit(),
})
if err != nil {
a.sendAPIStoreError(c, http.StatusInternalServerError, "Error when getting templates")
telemetry.ReportCriticalError(ctx, "error when getting templates", err)

return
}

telemetry.ReportEvent(ctx, "listed environments")

a.posthog.IdentifyAnalyticsTeam(ctx, team.ID.String(), team.Name)
properties := a.posthog.GetPackageToPosthogProperties(&c.Request.Header)
a.posthog.CreateAnalyticsTeamEvent(ctx, team.ID.String(), "listed environments", properties)

templates := make([]*api.Template, 0, len(rows))
for _, item := range rows {
var createdBy *api.TeamUser
if item.CreatorID != nil {
createdBy = &api.TeamUser{
Id: *item.CreatorID,
Email: nil,
}
}

envdVersion := ""
if item.BuildEnvdVersion != nil {
envdVersion = *item.BuildEnvdVersion
}

diskMB := int64(0)
if item.BuildTotalDiskSizeMb != nil {
diskMB = *item.BuildTotalDiskSizeMb
}

templates = append(templates, &api.Template{
TemplateID: item.TemplateID,
BuildID: item.BuildID.String(),
CpuCount: api.CPUCount(item.BuildVcpu),
MemoryMB: api.MemoryMB(item.BuildRamMb),
DiskSizeMB: api.DiskSizeMB(diskMB),
Public: item.Public,
Aliases: item.Aliases,
Names: item.Names,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
LastSpawnedAt: item.LastSpawnedAt,
SpawnCount: item.SpawnCount,
BuildCount: item.BuildCount,
BuildStatus: getCorrespondingTemplateBuildStatus(ctx, item.BuildStatus),
CreatedBy: createdBy,
EnvdVersion: envdVersion,
})
}

templates = pagination.ProcessResultsWithHeader(c, templates, func(t *api.Template) (time.Time, string) {
return t.CreatedAt, t.TemplateID
})

c.JSON(http.StatusOK, templates)
}
1 change: 1 addition & 0 deletions packages/api/internal/middleware/blocked_team.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ var blockedTeamAllowlist = auth.BlockedTeamAllowlist{
"/templates/:templateID/builds/:buildID/logs": {},
"/templates/:templateID/builds/:buildID/status": {},
"/v2/sandboxes": {},
"/v2/templates": {},
"/v2/sandboxes/:sandboxID/logs": {},
"/volumes": {},
"/volumes/:volumeID": {},
Expand Down
8 changes: 8 additions & 0 deletions packages/api/internal/utils/templates_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package utils

const (
// MaxTemplateID sorts lexically after any real template ID. It is used as
// the default cursor ID for the first page of a descending template listing
// (mirrors MaxSandboxID).
MaxTemplateID = "zzzzzzzzzzzzzzzzzzzz"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package templates

import (
"testing"
"time"

"github.com/stretchr/testify/require"

"github.com/e2b-dev/infra/packages/db/pkg/testutils"
"github.com/e2b-dev/infra/packages/db/queries"
)

// firstPageCursor returns a cursor that selects the first page of a descending
// listing (newer than any real row).
func firstPageCursor() (time.Time, string) {
return time.Now().Add(100 * 365 * 24 * time.Hour), ""
}

func TestGetTeamTemplatesWithCursor_OrdersDescAndPaginates(t *testing.T) {
t.Parallel()
db := testutils.SetupDatabase(t)
ctx := t.Context()

teamID := testutils.CreateTestTeam(t, db)

// Three templates with distinct, increasing created_at (index 2 is newest).
templateIDs := make([]string, 3)
for i := range templateIDs {
templateIDs[i] = testutils.CreateTestTemplate(t, db, teamID)
err := db.SqlcClient.TestsRawSQL(ctx,
"UPDATE public.envs SET created_at = NOW() - ($2 || ' hours')::interval WHERE id = $1",
templateIDs[i], 3-i,
)
require.NoError(t, err)
}

cursorTime, cursorID := firstPageCursor()
rows, err := db.SqlcClient.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
TeamID: teamID,
CursorCreatedAt: cursorTime,
CursorID: cursorID,
LimitPlusOne: 10,
})
require.NoError(t, err)
require.Len(t, rows, 3)
// Newest first.
require.Equal(t, []string{templateIDs[2], templateIDs[1], templateIDs[0]},
[]string{rows[0].TemplateID, rows[1].TemplateID, rows[2].TemplateID})

// Keyset pagination: page of 2 (request 3 = limit+1 to detect more), then
// continue from the last returned row's cursor.
firstPage, err := db.SqlcClient.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
TeamID: teamID,
CursorCreatedAt: cursorTime,
CursorID: cursorID,
LimitPlusOne: 3,
})
require.NoError(t, err)
require.Len(t, firstPage, 3) // 2 + 1 sentinel
last := firstPage[1] // the 2nd item is the page boundary

secondPage, err := db.SqlcClient.GetTeamTemplatesWithCursor(ctx, queries.GetTeamTemplatesWithCursorParams{
TeamID: teamID,
CursorCreatedAt: last.CreatedAt,
CursorID: last.TemplateID,
LimitPlusOne: 10,
})
require.NoError(t, err)
require.Len(t, secondPage, 1)
require.Equal(t, templateIDs[0], secondPage[0].TemplateID)
}
54 changes: 54 additions & 0 deletions packages/db/queries/get_team_templates_with_cursor.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
-- Cursor-paginated team template listing for the CLI (GET /v2/templates).
--
-- Same projection as GetTeamTemplates (two build laterals: latest default-tag
-- build for build_status, latest ready default-tag build for the displayed
-- resources), with keyset pagination added: ordered created_at DESC (newest
-- first) and bounded by the (created_at, id) cursor and LIMIT.

-- name: GetTeamTemplatesWithCursor :many
SELECT
e.id AS template_id,
e.created_at,
e.updated_at,
e.public,
e.build_count,
e.spawn_count,
e.last_spawned_at,
e.created_by AS creator_id,
COALESCE(eb.id, '00000000-0000-0000-0000-000000000000'::uuid) AS build_id,
COALESCE(eb.vcpu, 0)::bigint AS build_vcpu,
COALESCE(eb.ram_mb, 0)::bigint AS build_ram_mb,
eb.total_disk_size_mb AS build_total_disk_size_mb,
eb.envd_version AS build_envd_version,
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
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 = e.id
) ea ON TRUE
LEFT JOIN LATERAL (
SELECT b.status_group
FROM public.env_build_assignments AS ba
JOIN public.env_builds AS b ON b.id = ba.build_id
WHERE ba.env_id = e.id AND ba.tag = 'default'
ORDER BY ba.created_at DESC
LIMIT 1
) latest_build ON TRUE
LEFT JOIN LATERAL (
SELECT b.id, b.vcpu, b.ram_mb, b.total_disk_size_mb, b.envd_version
FROM public.env_build_assignments AS ba
JOIN public.env_builds AS b ON b.id = ba.build_id
WHERE ba.env_id = e.id AND ba.tag = 'default' AND b.status_group = 'ready'
ORDER BY ba.created_at DESC
LIMIT 1
) eb ON TRUE
WHERE
e.team_id = sqlc.arg(team_id)::uuid AND e.source = 'template'
AND (e.created_at, e.id) < (sqlc.arg(cursor_created_at)::timestamptz, sqlc.arg(cursor_id)::text)
ORDER BY e.created_at DESC, e.id DESC
LIMIT sqlc.arg(limit_plus_one)::int;
Loading
Loading