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
2 changes: 1 addition & 1 deletion .claude/rules/openshell-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Skip `GatewayInterceptor`, `SupervisorMiddleware`, `ComputeDriver` — internal/
11. **Optimistic concurrency:** `AttachSandboxProvider`, `DetachSandboxProvider`, `UpdateConfig`, `UpdateProviderProfiles` accept `expected_resource_version` — pass the ObjectMeta.resource_version from the last read. `ApproveDraftChunk` similarly takes a `review_token` (from the chunk's last `GetDraft` read) pinning the exact evaluated candidate; the BFF falls back to resolving it server-side if the client doesn't send one.
12. **Workspace scoping:** most requests carry a workspace selector (`workspace_scope`); list RPCs offer `all_workspaces`. An empty workspace name does not mean "default": gateway 0.1.x refuses a named selector with no name (`workspace_authz.rs` at v0.1.2), so a route must never forward an empty path segment as a workspace. Workspaces themselves are top-level. Exactly four lists accept the all-workspaces scope — sandboxes, sandbox templates, providers and services (`ListOptions{AllWorkspaces: true}`, which makes the SDK ignore the workspace argument) — and the gateway answers it for platform admins only. `ListServices` refuses a sandbox filter in that scope. The BFF serves them at `GET /api/v1/{sandboxes,providers,templates,services}`.
13. **Secret fields** are annotated `[(openshell.options.v1.secret) = true]` in proto — grep for `secret` when adding a wrapper and never serialize those fields to the frontend.
14. **`List` is paginated; use `ListAll` unless you are paging deliberately.** `List(...)` takes no `ctx` and returns a `*Pager[T]`. The eager form is `ListAll(ctx, ...)` returning a slice, and it is what every BFF handler uses. `ListMembers` likewise has `ListAllMembers`, and a sandbox's `ListProviders` has `ListAllProviders`. `Policy().ListAll` takes `(ctx, workspace, sandboxName, opts...)` — pass `""` for both when listing global revisions.
14. **`List` is paginated; use `ListAll` unless you are paging deliberately.** `List(...)` takes no `ctx` and returns a `*Pager[T]`. The eager form is `ListAll(ctx, ...)` returning a slice. Sandbox and workspace list handlers use `List(...).NextPage(ctx)` when `pageSize` is requested, and `ListAll` for legacy array responses. Other handlers use `ListAll`. `ListMembers` likewise has `ListAllMembers`, and a sandbox's `ListProviders` has `ListAllProviders`. `Policy().ListAll` takes `(ctx, workspace, sandboxName, opts...)` — pass `""` for both when listing global revisions.
15. **`Delete` returns `(*DeletionResult, error)`, and a nil error does not mean the resource is gone.** `DeletionOutcome` is one of Unspecified / Completed / Accepted / AlreadyAbsent. Only **Completed** and **AlreadyAbsent** establish completion; **Accepted** means the gateway queued asynchronous cleanup, and unrecognized numeric values must not be treated as completion. Convert through `models.FromSDKDeletion` rather than hardcoding `{"deleted": true}`.
16. **Network policy enum fields are proto enums, not free strings.** `access`, `enforcement` and `tls` on a policy network endpoint serialize through protojson as `NETWORK_ACCESS_PRESET_*`, `NETWORK_ENFORCEMENT_MODE_*`, `NETWORK_TLS_MODE_*`. The gateway does not accept the lowercase spellings (`read-only`, `enforce`, `verify`).

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,9 @@ frontend/cypress/screenshots/
.env.*
.claude/worktrees/

# Local agent guidance
.agents/
AGENTS.md

# compat stack rendered config
deploy/ci/.rendered/
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ This branch is built on:

[docs/compatibility.md](docs/compatibility.md) says what the dashboard shows when the gateway is on another line, and how the claim is tested.

## List pagination

`GET /api/v1/workspaces`, `GET /api/v1/workspaces/{workspace}/sandboxes`, and
`GET /api/v1/sandboxes` accept `pageSize` (1–100), an optional opaque
`pageToken`, and `labelSelector`. A token requires `pageSize`. Paged requests
return `{"items": [...], "nextPageToken": "..."}`; an empty token marks the last
page. Requests without `pageSize` retain the existing array response. The
gateway does not return a total count.

## Documentation

- [Install](docs/install.md): the full guide, with Keycloak, OpenShift and troubleshooting
Expand Down
25 changes: 24 additions & 1 deletion backend/pkg/handlers/allworkspaces_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,30 @@ func everyWorkspace(labelSelector string) openshell.ListOptions {
// ListSandboxes lists the sandboxes of every workspace. Query params:
// labelSelector, as on the workspace-scoped list.
func (h *AllWorkspacesHandler) ListSandboxes(w http.ResponseWriter, r *http.Request) {
sandboxes, err := h.sandboxes.ListAll(r.Context(), "", everyWorkspace(r.URL.Query().Get("labelSelector")))
opts, paged, valid := listRequestOptions(w, r)
if !valid {
return
}
opts.AllWorkspaces = true
if paged {
pager, err := h.sandboxes.List("", opts)
if err != nil {
apiutils.WriteSDKError(w, err)
return
}
page, err := pager.NextPage(r.Context())
if err != nil {
apiutils.WriteSDKError(w, err)
return
}
items := make([]models.Sandbox, 0, len(page.Items))
for _, sandbox := range page.Items {
items = append(items, models.FromSDKSandbox(sandbox))
}
apiutils.WriteJSON(w, http.StatusOK, listPageResponse[models.Sandbox]{Items: items, NextPageToken: page.NextPageToken})
return
}
sandboxes, err := h.sandboxes.ListAll(r.Context(), "", opts)
if err != nil {
apiutils.WriteSDKError(w, err)
return
Expand Down
20 changes: 20 additions & 0 deletions backend/pkg/handlers/drafts_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,26 @@ const (
// GetWorkspaceDraftSummary counts the pending draft chunks of every sandbox
// in a workspace. See summarizeDrafts.
func (h *DraftsHandler) GetWorkspaceDraftSummary(w http.ResponseWriter, r *http.Request) {
if names, named := r.URL.Query()["sandbox"]; named {
if len(names) > 100 {
apiutils.WriteError(w, http.StatusBadRequest, apiutils.InvalidArgument, "you can request draft summaries for no more than 100 sandboxes at a time")
return
}
seen := make(map[string]bool, len(names))
sandboxes := make([]*openshell.Sandbox, 0, len(names))
for _, name := range names {
if !apiutils.ValidDNS1123(name) {
apiutils.WriteError(w, http.StatusBadRequest, apiutils.InvalidName, "sandbox must be a valid DNS-1123 name")
return
}
if !seen[name] {
seen[name] = true
sandboxes = append(sandboxes, &openshell.Sandbox{Name: name, Workspace: r.PathValue("workspace")})
}
}
apiutils.WriteJSON(w, http.StatusOK, h.summarizeDrafts(r.Context(), r.PathValue("workspace"), sandboxes))
return
}
h.writeDraftSummary(w, r, r.PathValue("workspace"))
}

Expand Down
42 changes: 42 additions & 0 deletions backend/pkg/handlers/drafts_summary_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,48 @@ func TestWorkspaceDraftSummaryWireShape(t *testing.T) {
}
}

func TestWorkspaceDraftSummaryForNamedPage(t *testing.T) {
sdk := &mockSDK{}
sdk.sandboxes.listFn = func(context.Context, string, ...openshell.ListOptions) ([]*openshell.Sandbox, error) {
t.Fatal("a named page must not list every sandbox")
return nil, nil
}
inbox := &draftInbox{chunks: map[string][]openshell.PolicyChunk{
"team-a/busy": {{ID: "c1", Status: "pending"}},
}}
sdk.policy.getDraftFn = inbox.get
w := serve(t, draftSummaryRoutes(sdk), http.MethodGet, "/workspaces/team-a/draft-summary?sandbox=busy&sandbox=quiet&sandbox=busy", "")
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
}
want := map[draftRead]int{
{workspace: "team-a", sandbox: "busy", filter: "pending"}: 1,
{workspace: "team-a", sandbox: "quiet", filter: "pending"}: 1,
}
if got := inbox.readSet(); !reflect.DeepEqual(got, want) {
t.Errorf("draft reads = %+v, want %+v", got, want)
}
var body models.DraftSummary
decodeInto(t, w, &body)
if body.TotalPending != 1 || len(body.Sandboxes) != 1 || body.Sandboxes[0].SandboxName != "busy" {
t.Errorf("summary = %+v", body)
}
}

func TestWorkspaceDraftSummaryRejectsInvalidNamedPage(t *testing.T) {
sdk := &mockSDK{}
sdk.sandboxes.listFn = func(context.Context, string, ...openshell.ListOptions) ([]*openshell.Sandbox, error) {
t.Fatal("invalid named page must not list")
return nil, nil
}
for _, query := range []string{"sandbox=", "sandbox=bad%2Fname", strings.Repeat("sandbox=agent&", 101)} {
w := serve(t, draftSummaryRoutes(sdk), http.MethodGet, "/workspaces/team-a/draft-summary?"+query, "")
if w.Code != http.StatusBadRequest {
t.Errorf("%s: status = %d; body: %s", query, w.Code, w.Body.String())
}
}
}

// The list of sandboxes is the one call the summary cannot do without, so
// its failure is the answer.
func TestDraftSummaryListFails(t *testing.T) {
Expand Down
46 changes: 46 additions & 0 deletions backend/pkg/handlers/list_pagination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package handlers

import (
"net/http"
"strconv"

openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"

"github.com/Gkrumbach07/openshell-dashboard/backend/pkg/apiutils"
)

// listRequestOptions keeps the existing array response unless pageSize was
// supplied. A paged request forwards the gateway's opaque continuation token.
func listRequestOptions(w http.ResponseWriter, r *http.Request) (openshell.ListOptions, bool, bool) {
query := r.URL.Query()
opts := openshell.ListOptions{LabelSelector: query.Get("labelSelector")}
sizes, paged := query["pageSize"]
if !paged {
if _, hasToken := query["pageToken"]; hasToken {
apiutils.WriteError(w, http.StatusBadRequest, apiutils.InvalidArgument, "pageToken requires pageSize")
return opts, false, false
}
return opts, false, true
}
if len(sizes) != 1 {
apiutils.WriteError(w, http.StatusBadRequest, apiutils.InvalidArgument, "pageSize must be a single integer from 1 to 100")
return opts, false, false
}
size, err := strconv.Atoi(sizes[0])
if err != nil || size < 1 || size > 100 {
apiutils.WriteError(w, http.StatusBadRequest, apiutils.InvalidArgument, "pageSize must be an integer from 1 to 100")
return opts, false, false
}
if len(query["pageToken"]) > 1 {
apiutils.WriteError(w, http.StatusBadRequest, apiutils.InvalidArgument, "pageToken must appear once")
return opts, false, false
}
opts.PageSize = size
opts.PageToken = query.Get("pageToken")
return opts, true, true
}

type listPageResponse[T any] struct {
NextPageToken string `json:"nextPageToken"`
Items []T `json:"items"`
}
127 changes: 127 additions & 0 deletions backend/pkg/handlers/list_pagination_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package handlers

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"testing"

openshell "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1"
"github.com/go-chi/chi/v5"

"github.com/Gkrumbach07/openshell-dashboard/backend/pkg/services"
)

func TestPagedListRoutesFetchOneGatewayPage(t *testing.T) {
for _, route := range []string{"sandboxes", "workspaces", "all-sandboxes"} {
t.Run(route, func(t *testing.T) {
sdk := &mockSDK{}
calls := 0
var gotOpts openshell.ListOptions
var gotWorkspace string
sdk.sandboxes.listFn = func(context.Context, string, ...openshell.ListOptions) ([]*openshell.Sandbox, error) {
t.Fatal("paged request must not call ListAll")
return nil, nil
}
sdk.workspaces.listFn = func(context.Context, ...openshell.ListOptions) ([]*openshell.Workspace, error) {
t.Fatal("paged request must not call ListAll")
return nil, nil
}
sdk.sandboxes.listPageFn = func(_ context.Context, workspace string, opts ...openshell.ListOptions) (*openshell.Page[*openshell.Sandbox], error) {
calls++
gotWorkspace, gotOpts = workspace, opts[0]
return &openshell.Page[*openshell.Sandbox]{Items: []*openshell.Sandbox{{Name: "agent", Workspace: "team-a"}}, NextPageToken: "next-token"}, nil
}
sdk.workspaces.listPageFn = func(_ context.Context, opts ...openshell.ListOptions) (*openshell.Page[*openshell.Workspace], error) {
calls++
gotOpts = opts[0]
return &openshell.Page[*openshell.Workspace]{Items: []*openshell.Workspace{{Name: "team-a"}}, NextPageToken: "next-token"}, nil
}

r := chi.NewRouter()
path := "/workspaces/team-a/sandboxes"
switch route {
case "sandboxes":
r.Get("/workspaces/{workspace}/sandboxes", NewSandboxHandler(services.NewSandboxService(sdk.Sandboxes(), nil)).ListSandboxes)
case "workspaces":
path = "/workspaces"
r.Get(path, NewWorkspacesHandler(sdk.Workspaces()).ListWorkspaces)
case "all-sandboxes":
path = "/sandboxes"
r.Get(path, NewAllWorkspacesHandler(services.NewSandboxService(sdk.Sandboxes(), nil), nil, nil, nil).ListSandboxes)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path+"?pageSize=1&pageToken=prior-token&labelSelector=type%3Dtool", nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body: %s", w.Code, w.Body.String())
}
if calls != 1 {
t.Fatalf("SDK page calls = %d, want 1", calls)
}
want := openshell.ListOptions{PageSize: 1, PageToken: "prior-token", LabelSelector: "type=tool", AllWorkspaces: route == "all-sandboxes"}
if !reflect.DeepEqual(gotOpts, want) {
t.Errorf("options = %+v, want %+v", gotOpts, want)
}
if route == "sandboxes" && gotWorkspace != "team-a" || route == "all-sandboxes" && gotWorkspace != "" {
t.Errorf("workspace = %q", gotWorkspace)
}
var body struct {
NextPageToken string `json:"nextPageToken"`
Items []json.RawMessage `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Items) != 1 || body.NextPageToken != "next-token" {
t.Errorf("body = %s", w.Body.String())
}
})
}
}

func TestPagedListValidation(t *testing.T) {
for _, path := range []string{"/workspaces", "/workspaces/team-a/sandboxes", "/sandboxes"} {
for _, query := range []string{"pageSize=0", "pageSize=-1", "pageSize=101", "pageSize=abc", "pageSize=999999999999999999999999", "pageToken=token"} {
t.Run(path+"?"+query, func(t *testing.T) {
sdk := &mockSDK{}
r := chi.NewRouter()
r.Get("/workspaces", NewWorkspacesHandler(sdk.Workspaces()).ListWorkspaces)
r.Get("/workspaces/{workspace}/sandboxes", NewSandboxHandler(services.NewSandboxService(sdk.Sandboxes(), nil)).ListSandboxes)
r.Get("/sandboxes", NewAllWorkspacesHandler(services.NewSandboxService(sdk.Sandboxes(), nil), nil, nil, nil).ListSandboxes)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path+"?"+query, nil))
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body: %s", w.Code, w.Body.String())
}
})
}
}
}

func TestPagedListEmptyAndGatewayError(t *testing.T) {
sdk := &mockSDK{}
r := chi.NewRouter()
r.Get("/workspaces", NewWorkspacesHandler(sdk.Workspaces()).ListWorkspaces)
sdk.workspaces.listPageFn = func(context.Context, ...openshell.ListOptions) (*openshell.Page[*openshell.Workspace], error) {
return &openshell.Page[*openshell.Workspace]{}, nil
}
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/workspaces?pageSize=10", nil))
var emptyPage map[string]json.RawMessage
if err := json.Unmarshal(w.Body.Bytes(), &emptyPage); err != nil {
t.Fatal(err)
}
if w.Code != http.StatusOK || string(emptyPage["items"]) != "[]" || string(emptyPage["nextPageToken"]) != `""` {
t.Errorf("empty page: status %d, body %s", w.Code, w.Body.String())
}
sdk.workspaces.listPageFn = func(context.Context, ...openshell.ListOptions) (*openshell.Page[*openshell.Workspace], error) {
return nil, &openshell.StatusError{Code: openshell.ErrorUnavailable, Message: "gateway down"}
}
w = httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/workspaces?pageSize=10", nil))
if w.Code != http.StatusBadGateway {
t.Errorf("gateway error: status %d, body %s", w.Code, w.Body.String())
}
}
12 changes: 12 additions & 0 deletions backend/pkg/handlers/mock_sdk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ func readCreateOptions(opts []openshell.CreateOption) sentCreate {
// mockSDKSandboxes provides injectable sandbox operations.
type mockSDKSandboxes struct {
listFn func(ctx context.Context, workspace string, opts ...openshell.ListOptions) ([]*openshell.Sandbox, error)
listPageFn func(ctx context.Context, workspace string, opts ...openshell.ListOptions) (*openshell.Page[*openshell.Sandbox], error)
createFn func(ctx context.Context, workspace, name string, spec *openshell.SandboxSpec, opts ...openshell.CreateOption) (*openshell.Sandbox, error)
getFn func(ctx context.Context, workspace, name string) (*openshell.Sandbox, error)
deleteFn func(ctx context.Context, workspace, name string) error
Expand Down Expand Up @@ -165,6 +166,11 @@ func (m *mockServerStream) Recv() (*pb.SandboxStreamEvent, error) {
}

func (m *mockSDKSandboxes) List(workspace string, opts ...openshell.ListOptions) (*openshell.Pager[*openshell.Sandbox], error) {
if m.listPageFn != nil {
return openshell.NewPager(opts[0].PageToken, func(ctx context.Context, _ string) (*openshell.Page[*openshell.Sandbox], error) {
return m.listPageFn(ctx, workspace, opts...)
}), nil
}
return openshell.NewPager("", func(ctx context.Context, _ string) (*openshell.Page[*openshell.Sandbox], error) {
items, err := m.ListAll(ctx, workspace, opts...)
return &openshell.Page[*openshell.Sandbox]{Items: items}, err
Expand Down Expand Up @@ -432,6 +438,7 @@ type mockSDKWorkspaces struct {
createFn func(ctx context.Context, name string, labels map[string]string) (*openshell.Workspace, error)
getFn func(ctx context.Context, name string) (*openshell.Workspace, error)
listFn func(ctx context.Context, opts ...openshell.ListOptions) ([]*openshell.Workspace, error)
listPageFn func(ctx context.Context, opts ...openshell.ListOptions) (*openshell.Page[*openshell.Workspace], error)
deleteFn func(ctx context.Context, name string) error
addMemberFn func(ctx context.Context, workspace, principalSubject string, role openshell.WorkspaceRole) (*openshell.WorkspaceMember, error)
removeMemberFn func(ctx context.Context, workspace, principalSubject string) error
Expand All @@ -453,6 +460,11 @@ func (m *mockSDKWorkspaces) Get(ctx context.Context, name string) (*openshell.Wo
}

func (m *mockSDKWorkspaces) List(opts ...openshell.ListOptions) (*openshell.Pager[*openshell.Workspace], error) {
if m.listPageFn != nil {
return openshell.NewPager(opts[0].PageToken, func(ctx context.Context, _ string) (*openshell.Page[*openshell.Workspace], error) {
return m.listPageFn(ctx, opts...)
}), nil
}
return openshell.NewPager("", func(ctx context.Context, _ string) (*openshell.Page[*openshell.Workspace], error) {
items, err := m.ListAll(ctx, opts...)
return &openshell.Page[*openshell.Workspace]{Items: items}, err
Expand Down
Loading
Loading