Skip to content
Draft
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
26 changes: 7 additions & 19 deletions cli/configssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,25 +474,13 @@ func (r *RootCmd) configSSH() *serpent.Command {

if configOptions.noWildcard {
// Fetch all workspaces to generate individual host entries.
var wsNames []string
offset := 0
const pageSize = 100
for {
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Owner: codersdk.Me,
Offset: offset,
Limit: pageSize,
})
if err != nil {
return xerrors.Errorf("fetch workspaces: %w", err)
}
for _, ws := range res.Workspaces {
wsNames = append(wsNames, ws.Name)
}
if len(res.Workspaces) < pageSize {
break
}
offset += pageSize
workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{Owner: codersdk.Me})
if err != nil {
return xerrors.Errorf("fetch workspaces: %w", err)
}
wsNames := make([]string, 0, len(workspaces))
for _, ws := range workspaces {
wsNames = append(wsNames, ws.Name)
}
configOptions.workspaceNames = wsNames
}
Expand Down
72 changes: 25 additions & 47 deletions cli/exp_scaletest.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,8 +551,6 @@ func (r *prebuildTemplateCleanupRunner) Run(ctx context.Context, _ string, _ io.
// caught in the cleanup. If template is non-empty only workspaces for that
// template are returned.
func getScaletestPrebuildWorkspaces(ctx context.Context, client *codersdk.Client, template string) ([]codersdk.Workspace, error) {
const pageSize = 100

templates, err := getScaletestPrebuildsTemplates(ctx, client, template)
if err != nil {
return nil, xerrors.Errorf("list scaletest prebuild templates: %w", err)
Expand All @@ -562,23 +560,16 @@ func getScaletestPrebuildWorkspaces(ctx context.Context, client *codersdk.Client
var result []codersdk.Workspace

for _, tmpl := range templates {
for page := 0; ; page++ {
resp, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Template: tmpl.Name,
Offset: page * pageSize,
Limit: pageSize,
})
if err != nil {
return nil, xerrors.Errorf("list workspaces for template %q (page %d): %w", tmpl.Name, page, err)
}
for _, ws := range resp.Workspaces {
if _, ok := seen[ws.ID]; !ok {
seen[ws.ID] = struct{}{}
result = append(result, ws)
}
}
if len(resp.Workspaces) < pageSize {
break
workspaces, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{
Template: tmpl.Name,
})
if err != nil {
return nil, xerrors.Errorf("list workspaces for template %q: %w", tmpl.Name, err)
}
for _, ws := range workspaces {
if _, ok := seen[ws.ID]; !ok {
seen[ws.ID] = struct{}{}
result = append(result, ws)
}
}
}
Expand Down Expand Up @@ -2219,8 +2210,6 @@ func (r *runnableTraceWrapper) GetMetrics() map[string]any {

func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, template string) ([]codersdk.Workspace, int, error) {
var (
pageNumber = 0
limit = 100
workspaces []codersdk.Workspace
skipped int
)
Expand All @@ -2236,35 +2225,24 @@ func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner,
}
noOwnerAccess := dv.Values != nil && dv.Values.DisableOwnerWorkspaceExec.Value()

for {
page, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Name: "scaletest-",
Template: template,
Owner: owner,
Offset: pageNumber * limit,
Limit: limit,
})
if err != nil {
return nil, 0, xerrors.Errorf("fetch scaletest workspaces page %d: %w", pageNumber, err)
}
all, err := client.AllWorkspaces(ctx, codersdk.WorkspaceFilter{
Name: "scaletest-",
Template: template,
Owner: owner,
})
if err != nil {
return nil, 0, xerrors.Errorf("fetch scaletest workspaces: %w", err)
}

pageNumber++
if len(page.Workspaces) == 0 {
break
for _, w := range all {
if !loadtestutil.IsScaleTestWorkspace(w.Name, w.OwnerName) {
continue
}

pageWorkspaces := make([]codersdk.Workspace, 0, len(page.Workspaces))
for _, w := range page.Workspaces {
if !loadtestutil.IsScaleTestWorkspace(w.Name, w.OwnerName) {
continue
}
if noOwnerAccess && w.OwnerID != me.ID {
skipped++
continue
}
pageWorkspaces = append(pageWorkspaces, w)
if noOwnerAccess && w.OwnerID != me.ID {
skipped++
continue
}
workspaces = append(workspaces, pageWorkspaces...)
workspaces = append(workspaces, w)
}
return workspaces, skipped, nil
}
Expand Down
6 changes: 3 additions & 3 deletions cli/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,12 @@ func (r *RootCmd) list() *serpent.Command {
// convert workspaces to scheduleListRow.
func QueryConvertWorkspaces[T any](ctx context.Context, client *codersdk.Client, filter codersdk.WorkspaceFilter, convertF func(time.Time, codersdk.Workspace) T) ([]T, error) {
var empty []T
workspaces, err := client.Workspaces(ctx, filter)
workspaces, err := client.AllWorkspaces(ctx, filter)
if err != nil {
return empty, xerrors.Errorf("query workspaces: %w", err)
}
converted := make([]T, len(workspaces.Workspaces))
for i, workspace := range workspaces.Workspaces {
converted := make([]T, len(workspaces))
for i, workspace := range workspaces {
converted[i] = convertF(time.Now(), workspace)
}
return converted, nil
Expand Down
4 changes: 2 additions & 2 deletions cli/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ func (r *RootCmd) ssh() *serpent.Command {
return []string{}
}

res, err := client.Workspaces(inv.Context(), codersdk.WorkspaceFilter{
workspaces, err := client.AllWorkspaces(inv.Context(), codersdk.WorkspaceFilter{
Owner: codersdk.Me,
})
if err != nil {
Expand All @@ -181,7 +181,7 @@ func (r *RootCmd) ssh() *serpent.Command {
var mu sync.Mutex
var completions []string
var wg sync.WaitGroup
for _, ws := range res.Workspaces {
for _, ws := range workspaces {
wg.Add(1)
go func() {
defer wg.Done()
Expand Down
2 changes: 1 addition & 1 deletion coderd/apidoc/docs.go

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

2 changes: 1 addition & 1 deletion coderd/apidoc/swagger.json

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

37 changes: 37 additions & 0 deletions coderd/pagination.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package coderd

import (
"fmt"
"net/http"

"github.com/google/uuid"
Expand Down Expand Up @@ -32,3 +33,39 @@ func ParsePagination(w http.ResponseWriter, r *http.Request) (p codersdk.Paginat

return params, true
}

// ParsePaginationBounded extracts pagination query params from the http request
// and resolves limit against maxLimit. An omitted limit resolves to maxLimit. A
// limit that is present must be an integer in [1, maxLimit]; anything else is
// rejected rather than clamped, so a caller never receives fewer rows than it
// asked for without being told. If an error is encountered, the error is written
// to w and ok is set to false.
func ParsePaginationBounded(w http.ResponseWriter, r *http.Request, maxLimit int32) (p codersdk.Pagination, ok bool) {
ctx := r.Context()
queryParams := r.URL.Query()
parser := httpapi.NewQueryParamParser()
params := codersdk.Pagination{
AfterID: parser.UUID(queryParams, uuid.Nil, "after_id"),
Offset: int(parser.PositiveInt32(queryParams, 0, "offset")),
}

limitErrs := len(parser.Errors)
params.Limit = int(parser.PositiveInt32(queryParams, maxLimit, "limit"))
limitParsed := len(parser.Errors) == limitErrs
if limitParsed && (params.Limit < 1 || params.Limit > int(maxLimit)) {
parser.Errors = append(parser.Errors, codersdk.ValidationError{
Field: "limit",
Detail: fmt.Sprintf("Query param \"limit\" must be a positive integer no greater than %d.", maxLimit),
})
}

if len(parser.Errors) > 0 {
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
Message: "Query parameters have invalid values.",
Validations: parser.Errors,
})
return params, false
}

return params, true
}
85 changes: 85 additions & 0 deletions coderd/pagination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require"

"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)

Expand Down Expand Up @@ -139,3 +140,87 @@ func TestPagination(t *testing.T) {
})
}
}

func TestPaginationBounded(t *testing.T) {
t.Parallel()
const maxLimit = 100
testCases := []struct {
Name string

// Limit is omitted from the query when nil.
Limit *string
Offset string

ExpectedError string
ExpectedParams codersdk.Pagination
}{
{
Name: "OmittedLimitDefaultsToMax",
ExpectedParams: codersdk.Pagination{Limit: maxLimit},
},
{
Name: "MaxLimit",
Limit: ptr.Ref("100"),
ExpectedParams: codersdk.Pagination{Limit: maxLimit},
},
{
Name: "BelowMaxLimit",
Limit: ptr.Ref("25"),
Offset: "50",
ExpectedParams: codersdk.Pagination{Limit: 25, Offset: 50},
},
{
Name: "ZeroLimit",
Limit: ptr.Ref("0"),
ExpectedError: "must be a positive integer no greater than 100",
},
{
Name: "AboveMaxLimit",
Limit: ptr.Ref("101"),
ExpectedError: "must be a positive integer no greater than 100",
},
{
Name: "NegativeLimit",
Limit: ptr.Ref("-1"),
ExpectedError: "must be a valid 32-bit positive integer: value is negative",
},
{
Name: "UnparseableLimit",
Limit: ptr.Ref("bogus"),
ExpectedError: "must be a valid 32-bit positive integer",
},
}

for _, c := range testCases {
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
rw := httptest.NewRecorder()
r, err := http.NewRequestWithContext(context.Background(), "GET", "https://example.com", nil)
require.NoError(t, err, "new request")

query := r.URL.Query()
if c.Limit != nil {
query.Set("limit", *c.Limit)
}
if c.Offset != "" {
query.Set("offset", c.Offset)
}
r.URL.RawQuery = query.Encode()

params, ok := coderd.ParsePaginationBounded(rw, r, maxLimit)
if c.ExpectedError == "" {
require.True(t, ok, "expect ok")
require.Equal(t, c.ExpectedParams, params, "expected params")
return
}

require.False(t, ok, "expect !ok")
require.Equal(t, http.StatusBadRequest, rw.Code, "bad request status code")
var apiError codersdk.Error
require.NoError(t, json.NewDecoder(rw.Body).Decode(&apiError), "decode response")
require.Len(t, apiError.Validations, 1, "one validation error")
require.Equal(t, "limit", apiError.Validations[0].Field)
require.Contains(t, apiError.Validations[0].Detail, c.ExpectedError)
})
}
}
4 changes: 2 additions & 2 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,15 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
// @Produce json
// @Tags Workspaces
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items)."
// @Param limit query int false "Page limit"
// @Param limit query int false "Page limit, from 1 to 100. Defaults to 100 when omitted."
// @Param offset query int false "Page offset"
// @Success 200 {object} codersdk.WorkspacesResponse
// @Router /api/v2/workspaces [get]
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)

page, ok := ParsePagination(rw, r)
page, ok := ParsePaginationBounded(rw, r, codersdk.WorkspacesPageLimit)
if !ok {
return
}
Expand Down
29 changes: 29 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"net/http/httptest"
"regexp"
"slices"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -3329,6 +3330,34 @@ func TestOffsetLimit(t *testing.T) {
require.Error(t, err)
}

func TestWorkspacesPageLimit(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)

// A limit outside [1, codersdk.WorkspacesPageLimit] is rejected rather than
// clamped. codersdk.Pagination omits a zero limit, so the query is built by
// hand.
for _, limit := range []string{"0", strconv.Itoa(codersdk.WorkspacesPageLimit + 1)} {
res, err := client.Request(ctx, http.MethodGet, "/api/v2/workspaces?limit="+limit, nil)
require.NoError(t, err)
_ = res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
}

_, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{Limit: codersdk.WorkspacesPageLimit + 1})
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
require.Len(t, apiErr.Validations, 1)
require.Equal(t, "limit", apiErr.Validations[0].Field)

// The maximum is accepted.
_, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{Limit: codersdk.WorkspacesPageLimit})
require.NoError(t, err)
}

func TestWorkspaceUpdateAutostart(t *testing.T) {
t.Parallel()
dublinLoc := mustLocation(t, "Europe/Dublin")
Expand Down
Loading
Loading