diff --git a/architecture/gateway.md b/architecture/gateway.md index 588b9f56a4..8771fd4c38 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -549,6 +549,10 @@ sandbox collections in one cancellable background refresh task, never overlaps periodic list refreshes, and discards results after a gateway or workspace change. +Curated Rust, Python, Go, and TypeScript SDK list methods return lazy pagers. +Advancing a pager issues one list RPC and exposes its continuation token; +explicit `list_all` helpers are the only curated APIs that exhaust a collection. + Persistence distinguishes one-page operations from exhaustive scans. `list_object_page` and `list_message_page` return one keyset page and its next cursor. `collect_records` and `collect_messages` exhaust those pages, fail on diff --git a/crates/openshell-sdk/README.md b/crates/openshell-sdk/README.md index 4ff8aca91c..3adbcba375 100644 --- a/crates/openshell-sdk/README.md +++ b/crates/openshell-sdk/README.md @@ -43,7 +43,7 @@ mTLS (client certificates) is not supported. ## Public surface `OpenShellClient::connect(ClientConfig)` returns a connected client exposing -`health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `delete_sandbox`, +`health`, `create_sandbox`, `get_sandbox`, `list_sandboxes`, `list_all_sandboxes`, `delete_sandbox`, `create_sandbox_from_template`, `create_sandbox_template`, `get_sandbox_template`, `list_sandbox_templates`, `delete_sandbox_template`, `list_sandboxes_all_workspaces`, `list_sandbox_templates_all_workspaces`, @@ -59,11 +59,22 @@ Curated calls without a workspace argument explicitly select the `default` workspace. Cross-workspace listing uses the separate `*_all_workspaces` methods and requires Platform Admin access. -Curated list methods follow continuation tokens until the collection is -exhausted. `ListOptions::page_size` and -`SandboxTemplateListOptions::page_size` control the size of each gateway -request; callers that need explicit page boundaries can use the raw protobuf -client. +Curated `list_*` methods return a lazy `Pager`. Each `next_page()` call +issues at most one RPC and returns a `Page` with its opaque continuation +token. The explicit `list_all_*` conveniences exhaust that pager; `page_size` +always controls one gateway request, and `page_token` resumes a saved traversal. + +```rust +let mut pages = client.list_sandboxes(ListOptions { + page_size: 100, + ..Default::default() +}); +while let Some(page) = pages.next_page().await? { + for sandbox in page.items { + println!("{}", sandbox.name); + } +} +``` ```rust use openshell_sdk::{ @@ -115,6 +126,7 @@ let _sandbox = client | `refresh` | `Refresh` trait and single-flight refresh coalescing. | | `edge_tunnel` | Cloudflare Access tunnel dialer. | | `error` | `SdkError` taxonomy. | +| `pagination` | Lazy `Pager` and response `Page`. | | `types` | Curated request/response types and proto conversions. | | `raw` | Escape hatch re-exporting the generated tonic clients. | diff --git a/crates/openshell-sdk/src/client.rs b/crates/openshell-sdk/src/client.rs index d8f1b26205..c789aded99 100644 --- a/crates/openshell-sdk/src/client.rs +++ b/crates/openshell-sdk/src/client.rs @@ -11,6 +11,7 @@ use crate::auth::{BearerSlot, EdgeAuthInterceptor, bearer_metadata}; use crate::config::{AuthConfig, ClientConfig}; use crate::error::{Result, SdkError}; +use crate::pagination::{Page, Pager}; use crate::raw::AuthedGrpcClient; use crate::refresh::{RefreshedToken, TokenSource}; use crate::transport; @@ -192,59 +193,83 @@ impl OpenShellClient { } /// List reusable sandbox templates in the default workspace. - pub async fn list_sandbox_templates( + pub fn list_sandbox_templates( &self, opts: SandboxTemplateListOptions, - ) -> Result> { - let mut templates = Vec::new(); - let mut page_token = String::new(); - loop { - let response = self - .unary(|mut grpc| { - let page_token = page_token.clone(); - let request = proto::ListSandboxTemplatesRequest { - page_size: opts.page_size, - page_token, - label_selector: opts.label_selector.clone(), - workspace_scope: Some(proto::workspace_selector("default")), - }; - async move { grpc.list_sandbox_templates(request).await } + ) -> Pager { + let client = self.clone(); + let initial_page_token = opts.page_token.clone(); + Pager::new(initial_page_token, move |page_token| { + let client = client.clone(); + let opts = opts.clone(); + async move { + let response = client + .unary(|mut grpc| { + let page_token = page_token.clone(); + let request = proto::ListSandboxTemplatesRequest { + page_size: opts.page_size, + page_token, + label_selector: opts.label_selector.clone(), + workspace_scope: Some(proto::workspace_selector("default")), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(Page { + items: response.templates, + next_page_token: response.next_page_token, }) - .await?; - templates.extend(response.templates); - if response.next_page_token.is_empty() { - return Ok(templates); } - page_token = response.next_page_token; - } + }) } - /// List reusable sandbox templates across all workspaces. - pub async fn list_sandbox_templates_all_workspaces( + /// List and collect every reusable sandbox template. + pub async fn list_all_sandbox_templates( &self, opts: SandboxTemplateListOptions, ) -> Result> { - let mut templates = Vec::new(); - let mut page_token = String::new(); - loop { - let response = self - .unary(|mut grpc| { - let page_token = page_token.clone(); - let request = proto::ListSandboxTemplatesRequest { - page_size: opts.page_size, - page_token, - label_selector: opts.label_selector.clone(), - workspace_scope: Some(proto::all_workspaces_selector()), - }; - async move { grpc.list_sandbox_templates(request).await } + self.list_sandbox_templates(opts).collect_all().await + } + + /// List reusable sandbox templates across all workspaces. + pub fn list_sandbox_templates_all_workspaces( + &self, + opts: SandboxTemplateListOptions, + ) -> Pager { + let client = self.clone(); + let initial_page_token = opts.page_token.clone(); + Pager::new(initial_page_token, move |page_token| { + let client = client.clone(); + let opts = opts.clone(); + async move { + let response = client + .unary(|mut grpc| { + let page_token = page_token.clone(); + let request = proto::ListSandboxTemplatesRequest { + page_size: opts.page_size, + page_token, + label_selector: opts.label_selector.clone(), + workspace_scope: Some(proto::all_workspaces_selector()), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(Page { + items: response.templates, + next_page_token: response.next_page_token, }) - .await?; - templates.extend(response.templates); - if response.next_page_token.is_empty() { - return Ok(templates); } - page_token = response.next_page_token; - } + }) + } + + /// List and collect every reusable sandbox template across all workspaces. + pub async fn list_all_sandbox_templates_all_workspaces( + &self, + opts: SandboxTemplateListOptions, + ) -> Result> { + self.list_sandbox_templates_all_workspaces(opts) + .collect_all() + .await } /// Delete a reusable sandbox template by name from the default workspace. @@ -276,28 +301,40 @@ impl OpenShellClient { } /// List sandboxes. - pub async fn list_sandboxes(&self, opts: ListOptions) -> Result> { - let mut sandboxes = Vec::new(); - let mut page_token = String::new(); - loop { - let response = self - .unary(|mut grpc| { - let page_token = page_token.clone(); - let request = proto::ListSandboxesRequest { - page_size: opts.page_size, - page_token, - label_selector: opts.label_selector.clone().unwrap_or_default(), - workspace_scope: Some(proto::workspace_selector("default")), - }; - async move { grpc.list_sandboxes(request).await } + pub fn list_sandboxes(&self, opts: ListOptions) -> Pager { + let client = self.clone(); + let initial_page_token = opts.page_token.clone(); + Pager::new(initial_page_token, move |page_token| { + let client = client.clone(); + let opts = opts.clone(); + async move { + let response = client + .unary(|mut grpc| { + let page_token = page_token.clone(); + let request = proto::ListSandboxesRequest { + page_size: opts.page_size, + page_token, + label_selector: opts.label_selector.clone().unwrap_or_default(), + workspace_scope: Some(proto::workspace_selector("default")), + }; + async move { grpc.list_sandboxes(request).await } + }) + .await?; + Ok(Page { + items: response + .sandboxes + .into_iter() + .map(SandboxRef::from_proto) + .collect(), + next_page_token: response.next_page_token, }) - .await?; - sandboxes.extend(response.sandboxes.into_iter().map(SandboxRef::from_proto)); - if response.next_page_token.is_empty() { - return Ok(sandboxes); } - page_token = response.next_page_token; - } + }) + } + + /// List and collect every sandbox in the default workspace. + pub async fn list_all_sandboxes(&self, opts: ListOptions) -> Result> { + self.list_sandboxes(opts).collect_all().await } /// Delete a sandbox by name. @@ -403,31 +440,43 @@ impl OpenShellClient { } /// List sandboxes across all workspaces. - pub async fn list_sandboxes_all_workspaces( + pub fn list_sandboxes_all_workspaces(&self, opts: ListOptions) -> Pager { + let client = self.clone(); + let initial_page_token = opts.page_token.clone(); + Pager::new(initial_page_token, move |page_token| { + let client = client.clone(); + let opts = opts.clone(); + async move { + let response = client + .unary(|mut grpc| { + let page_token = page_token.clone(); + let request = proto::ListSandboxesRequest { + page_size: opts.page_size, + page_token, + label_selector: opts.label_selector.clone().unwrap_or_default(), + workspace_scope: Some(proto::all_workspaces_selector()), + }; + async move { grpc.list_sandboxes(request).await } + }) + .await?; + Ok(Page { + items: response + .sandboxes + .into_iter() + .map(SandboxRef::from_proto) + .collect(), + next_page_token: response.next_page_token, + }) + } + }) + } + + /// List and collect every sandbox across all workspaces. + pub async fn list_all_sandboxes_all_workspaces( &self, opts: ListOptions, ) -> Result> { - let mut sandboxes = Vec::new(); - let mut page_token = String::new(); - loop { - let response = self - .unary(|mut grpc| { - let page_token = page_token.clone(); - let request = proto::ListSandboxesRequest { - page_size: opts.page_size, - page_token, - label_selector: opts.label_selector.clone().unwrap_or_default(), - workspace_scope: Some(proto::all_workspaces_selector()), - }; - async move { grpc.list_sandboxes(request).await } - }) - .await?; - sandboxes.extend(response.sandboxes.into_iter().map(SandboxRef::from_proto)); - if response.next_page_token.is_empty() { - return Ok(sandboxes); - } - page_token = response.next_page_token; - } + self.list_sandboxes_all_workspaces(opts).collect_all().await } /// Create a new workspace. @@ -468,32 +517,39 @@ impl OpenShellClient { } /// List workspaces. - pub async fn list_workspaces(&self, opts: ListOptions) -> Result> { - let mut workspaces = Vec::new(); - let mut page_token = String::new(); - loop { - let response = self - .unary(|mut grpc| { - let page_token = page_token.clone(); - let request = proto::ListWorkspacesRequest { - page_size: opts.page_size, - page_token, - label_selector: opts.label_selector.clone().unwrap_or_default(), - }; - async move { grpc.list_workspaces(request).await } + pub fn list_workspaces(&self, opts: ListOptions) -> Pager { + let client = self.clone(); + let initial_page_token = opts.page_token.clone(); + Pager::new(initial_page_token, move |page_token| { + let client = client.clone(); + let opts = opts.clone(); + async move { + let response = client + .unary(|mut grpc| { + let page_token = page_token.clone(); + let request = proto::ListWorkspacesRequest { + page_size: opts.page_size, + page_token, + label_selector: opts.label_selector.clone().unwrap_or_default(), + }; + async move { grpc.list_workspaces(request).await } + }) + .await?; + Ok(Page { + items: response + .workspaces + .into_iter() + .map(WorkspaceRef::from_proto) + .collect(), + next_page_token: response.next_page_token, }) - .await?; - workspaces.extend( - response - .workspaces - .into_iter() - .map(WorkspaceRef::from_proto), - ); - if response.next_page_token.is_empty() { - return Ok(workspaces); } - page_token = response.next_page_token; - } + }) + } + + /// List and collect every workspace. + pub async fn list_all_workspaces(&self, opts: ListOptions) -> Result> { + self.list_workspaces(opts).collect_all().await } /// Delete a workspace by name. @@ -734,32 +790,44 @@ impl WorkspaceScopedClient { } /// List reusable sandbox templates in this workspace. - pub async fn list_sandbox_templates( + pub fn list_sandbox_templates( &self, opts: SandboxTemplateListOptions, - ) -> Result> { - let mut templates = Vec::new(); - let mut page_token = String::new(); - loop { - let response = self - .client - .unary(|mut grpc| { - let page_token = page_token.clone(); - let request = proto::ListSandboxTemplatesRequest { - page_size: opts.page_size, - page_token, - label_selector: opts.label_selector.clone(), - workspace_scope: Some(proto::workspace_selector(&self.workspace)), - }; - async move { grpc.list_sandbox_templates(request).await } + ) -> Pager { + let client = self.client.clone(); + let workspace = self.workspace.clone(); + let initial_page_token = opts.page_token.clone(); + Pager::new(initial_page_token, move |page_token| { + let client = client.clone(); + let workspace = workspace.clone(); + let opts = opts.clone(); + async move { + let response = client + .unary(|mut grpc| { + let page_token = page_token.clone(); + let request = proto::ListSandboxTemplatesRequest { + page_size: opts.page_size, + page_token, + label_selector: opts.label_selector.clone(), + workspace_scope: Some(proto::workspace_selector(&workspace)), + }; + async move { grpc.list_sandbox_templates(request).await } + }) + .await?; + Ok(Page { + items: response.templates, + next_page_token: response.next_page_token, }) - .await?; - templates.extend(response.templates); - if response.next_page_token.is_empty() { - return Ok(templates); } - page_token = response.next_page_token; - } + }) + } + + /// List and collect every reusable sandbox template in this scope. + pub async fn list_all_sandbox_templates( + &self, + opts: SandboxTemplateListOptions, + ) -> Result> { + self.list_sandbox_templates(opts).collect_all().await } /// Delete a reusable sandbox template by name in this workspace. @@ -793,29 +861,42 @@ impl WorkspaceScopedClient { } /// List sandboxes in this workspace. - pub async fn list_sandboxes(&self, opts: ListOptions) -> Result> { - let mut sandboxes = Vec::new(); - let mut page_token = String::new(); - loop { - let response = self - .client - .unary(|mut grpc| { - let page_token = page_token.clone(); - let request = proto::ListSandboxesRequest { - page_size: opts.page_size, - page_token, - label_selector: opts.label_selector.clone().unwrap_or_default(), - workspace_scope: Some(proto::workspace_selector(&self.workspace)), - }; - async move { grpc.list_sandboxes(request).await } + pub fn list_sandboxes(&self, opts: ListOptions) -> Pager { + let client = self.client.clone(); + let workspace = self.workspace.clone(); + let initial_page_token = opts.page_token.clone(); + Pager::new(initial_page_token, move |page_token| { + let client = client.clone(); + let workspace = workspace.clone(); + let opts = opts.clone(); + async move { + let response = client + .unary(|mut grpc| { + let page_token = page_token.clone(); + let request = proto::ListSandboxesRequest { + page_size: opts.page_size, + page_token, + label_selector: opts.label_selector.clone().unwrap_or_default(), + workspace_scope: Some(proto::workspace_selector(&workspace)), + }; + async move { grpc.list_sandboxes(request).await } + }) + .await?; + Ok(Page { + items: response + .sandboxes + .into_iter() + .map(SandboxRef::from_proto) + .collect(), + next_page_token: response.next_page_token, }) - .await?; - sandboxes.extend(response.sandboxes.into_iter().map(SandboxRef::from_proto)); - if response.next_page_token.is_empty() { - return Ok(sandboxes); } - page_token = response.next_page_token; - } + }) + } + + /// List and collect every sandbox in this workspace. + pub async fn list_all_sandboxes(&self, opts: ListOptions) -> Result> { + self.list_sandboxes(opts).collect_all().await } /// Delete a sandbox by name in this workspace. diff --git a/crates/openshell-sdk/src/lib.rs b/crates/openshell-sdk/src/lib.rs index 8dfb549294..b94b211f2f 100644 --- a/crates/openshell-sdk/src/lib.rs +++ b/crates/openshell-sdk/src/lib.rs @@ -25,7 +25,7 @@ //! # async fn run() -> Result<(), openshell_sdk::SdkError> { //! let client = OpenShellClient::connect(ClientConfig::new("http://127.0.0.1:8080")).await?; //! let health = client.health().await?; -//! let sandboxes = client.list_sandboxes(ListOptions::default()).await?; +//! let sandboxes = client.list_all_sandboxes(ListOptions::default()).await?; //! # Ok(()) //! # } //! ``` @@ -36,6 +36,7 @@ pub mod config; pub mod edge_tunnel; pub mod error; pub mod oidc; +pub mod pagination; pub mod raw; pub mod refresh; pub mod transport; @@ -45,6 +46,7 @@ pub use auth::EdgeAuthInterceptor; pub use client::{OpenShellClient, WorkspaceScopedClient}; pub use config::{AuthConfig, ClientConfig}; pub use error::SdkError; +pub use pagination::{Page, Pager}; pub use refresh::{Refresh, RefreshError, RefreshedToken, TokenSource}; pub use types::{ ExecOptions, ExecResult, Health, ListOptions, SandboxPhase, SandboxRef, SandboxResources, diff --git a/crates/openshell-sdk/src/pagination.rs b/crates/openshell-sdk/src/pagination.rs new file mode 100644 index 0000000000..8f35d03dab --- /dev/null +++ b/crates/openshell-sdk/src/pagination.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Lazy pagination primitives for curated SDK list methods. + +use crate::error::Result; +use std::future::Future; +use std::pin::Pin; + +type PageFuture = Pin>> + Send>>; +type PageFetcher = Box PageFuture + Send + Sync>; + +/// One response page from a list operation. +#[derive(Clone, Debug)] +pub struct Page { + /// Resources returned by this request. + pub items: Vec, + /// Opaque token that resumes after this page, or an empty string at the end. + pub next_page_token: String, +} + +/// A lazy, single-pass iterator over response pages. +/// +/// Constructed by curated `list_*` methods. No RPC is issued until +/// [`Pager::next_page`] is called, and each call fetches at most one page. +pub struct Pager { + fetch: PageFetcher, + next_page_token: Option, +} + +impl Pager { + pub(crate) fn new(page_token: String, fetch: F) -> Self + where + F: Fn(String) -> Fut + Send + Sync + 'static, + Fut: Future>> + Send + 'static, + { + Self { + fetch: Box::new(move |token| Box::pin(fetch(token))), + next_page_token: Some(page_token), + } + } + + /// Fetch the next page, or return `None` after the final page. + pub async fn next_page(&mut self) -> Result>> { + let Some(page_token) = self.next_page_token.clone() else { + return Ok(None); + }; + let page = (self.fetch)(page_token).await?; + self.next_page_token = + (!page.next_page_token.is_empty()).then(|| page.next_page_token.clone()); + Ok(Some(page)) + } + + /// Consume the pager and collect every remaining item. + pub async fn collect_all(mut self) -> Result> { + let mut items = Vec::new(); + while let Some(page) = self.next_page().await? { + items.extend(page.items); + } + Ok(items) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test] + async fn pager_is_lazy_and_fetches_one_page_at_a_time() { + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let mut pager = Pager::new("resume".to_string(), move |token| { + let calls = observed.clone(); + async move { + let call = calls.fetch_add(1, Ordering::SeqCst); + if call == 0 { + assert_eq!(token, "resume"); + Ok(Page { + items: vec![1], + next_page_token: "next".to_string(), + }) + } else { + assert_eq!(token, "next"); + Ok(Page { + items: vec![2], + next_page_token: String::new(), + }) + } + } + }); + + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(pager.next_page().await.unwrap().unwrap().items, vec![1]); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(pager.next_page().await.unwrap().unwrap().items, vec![2]); + assert!(pager.next_page().await.unwrap().is_none()); + } + + #[tokio::test] + async fn collect_all_consumes_remaining_pages() { + let pager = Pager::new(String::new(), |token| async move { + if token.is_empty() { + Ok(Page { + items: vec![1, 2], + next_page_token: "next".to_string(), + }) + } else { + Ok(Page { + items: vec![3], + next_page_token: String::new(), + }) + } + }); + + assert_eq!(pager.collect_all().await.unwrap(), vec![1, 2, 3]); + } + + #[tokio::test] + async fn failed_fetch_retries_the_same_token() { + let calls = Arc::new(AtomicUsize::new(0)); + let observed = calls.clone(); + let mut pager = Pager::new("resume".to_string(), move |token| { + let calls = observed.clone(); + async move { + assert_eq!(token, "resume"); + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + Err(crate::error::SdkError::connect("temporary failure")) + } else { + Ok(Page { + items: vec![1], + next_page_token: String::new(), + }) + } + } + }); + + assert!(pager.next_page().await.is_err()); + assert_eq!(pager.next_page().await.unwrap().unwrap().items, vec![1]); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/openshell-sdk/src/types.rs b/crates/openshell-sdk/src/types.rs index 1c3fa7014d..a165c37124 100644 --- a/crates/openshell-sdk/src/types.rs +++ b/crates/openshell-sdk/src/types.rs @@ -161,8 +161,10 @@ pub type SandboxStartup = proto::SandboxStartup; /// Options for listing reusable sandbox templates. #[derive(Clone, Debug, Default)] pub struct SandboxTemplateListOptions { - /// Page size requested while collecting templates. `0` uses the server default. + /// Maximum templates requested per page. `0` uses the server default. pub page_size: i32, + /// Opaque token from a previous page. Empty starts at the beginning. + pub page_token: String, /// Optional label selector in `key=value,key2=value2` form. pub label_selector: String, } @@ -245,8 +247,10 @@ impl WorkspaceRef { /// Options for listing sandboxes. #[derive(Clone, Debug, Default)] pub struct ListOptions { - /// Page size requested while collecting results. `0` uses the server default. + /// Maximum resources requested per page. `0` uses the server default. pub page_size: i32, + /// Opaque token from a previous page. Empty starts at the beginning. + pub page_token: String, /// Optional Kubernetes-style label selector (e.g. `env=prod,team=core`). pub label_selector: Option, } diff --git a/crates/openshell-sdk/tests/client_mock.rs b/crates/openshell-sdk/tests/client_mock.rs index 7acce33e0e..549f715d9b 100644 --- a/crates/openshell-sdk/tests/client_mock.rs +++ b/crates/openshell-sdk/tests/client_mock.rs @@ -1031,9 +1031,10 @@ async fn sandbox_template_crud_uses_default_workspace() { ); let listed = client - .list_sandbox_templates_all_workspaces(SandboxTemplateListOptions { + .list_all_sandbox_templates_all_workspaces(SandboxTemplateListOptions { page_size: 10, label_selector: String::new(), + ..Default::default() }) .await .unwrap(); @@ -1095,8 +1096,9 @@ async fn list_sandboxes_propagates_filters() { let opts = ListOptions { page_size: 25, label_selector: Some("team=core".to_string()), + ..Default::default() }; - let items = client.list_sandboxes(opts).await.unwrap(); + let items = client.list_all_sandboxes(opts).await.unwrap(); assert_eq!(items.len(), 2); assert_eq!(items[0].name, "alpha"); assert_eq!(items[0].phase, SandboxPhase::Ready); @@ -1117,17 +1119,22 @@ async fn list_sandboxes_follows_continuation_tokens() { let endpoint = start_mock(state.clone()).await; let client = connect(&endpoint).await; - let items = client - .list_sandboxes(ListOptions { - page_size: 1, - label_selector: Some("team=core".to_string()), - }) - .await - .unwrap(); - - assert_eq!(items.len(), 2); - assert_eq!(items[0].name, "alpha"); - assert_eq!(items[1].name, "beta"); + let mut pager = client.list_sandboxes(ListOptions { + page_size: 1, + label_selector: Some("team=core".to_string()), + ..Default::default() + }); + assert!(state.list_requests.lock().await.is_empty()); + + let first = pager.next_page().await.unwrap().unwrap(); + assert_eq!(first.items.len(), 1); + assert_eq!(first.items[0].name, "alpha"); + assert_eq!(first.next_page_token, "page-2"); + let second = pager.next_page().await.unwrap().unwrap(); + assert_eq!(second.items.len(), 1); + assert_eq!(second.items[0].name, "beta"); + assert!(second.next_page_token.is_empty()); + assert!(pager.next_page().await.unwrap().is_none()); let requests = state.list_requests.lock().await; assert_eq!(requests.len(), 2); assert!(requests[0].page_token.is_empty()); @@ -1135,6 +1142,29 @@ async fn list_sandboxes_follows_continuation_tokens() { assert_eq!(requests[1].label_selector, "team=core"); } +#[tokio::test] +async fn list_sandboxes_passes_initial_page_token() { + let state = Arc::new(MockState { + paginate_list: true, + ..Default::default() + }); + let endpoint = start_mock(state.clone()).await; + let client = connect(&endpoint).await; + + let mut pager = client.list_sandboxes(ListOptions { + page_size: 1, + page_token: "page-2".to_string(), + ..Default::default() + }); + let page = pager.next_page().await.unwrap().unwrap(); + + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].name, "beta"); + let requests = state.list_requests.lock().await; + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].page_token, "page-2"); +} + #[tokio::test] async fn delete_sandbox_returns_server_ack() { let state = Arc::new(MockState::default()); @@ -1471,7 +1501,7 @@ async fn workspace_scoped_list_passes_workspace() { let client = connect(&endpoint).await; let ws = client.workspace("dev"); - let items = ws.list_sandboxes(ListOptions::default()).await.unwrap(); + let items = ws.list_all_sandboxes(ListOptions::default()).await.unwrap(); assert_eq!(items.len(), 2); let observed = state.last_list_request.lock().await.clone().unwrap(); @@ -1503,7 +1533,7 @@ async fn workspace_scoped_sandbox_template_crud_passes_workspace() { ); let listed = ws - .list_sandbox_templates(SandboxTemplateListOptions::default()) + .list_all_sandbox_templates(SandboxTemplateListOptions::default()) .await .unwrap(); assert_eq!(listed.len(), 2); @@ -1514,7 +1544,7 @@ async fn workspace_scoped_sandbox_template_crud_passes_workspace() { ); client - .list_sandbox_templates_all_workspaces(SandboxTemplateListOptions::default()) + .list_all_sandbox_templates_all_workspaces(SandboxTemplateListOptions::default()) .await .unwrap(); let observed_all = state.last_template_list.lock().await.clone().unwrap(); @@ -1551,7 +1581,7 @@ async fn list_sandboxes_all_workspaces_sets_flag() { let client = connect(&endpoint).await; let items = client - .list_sandboxes_all_workspaces(ListOptions::default()) + .list_all_sandboxes_all_workspaces(ListOptions::default()) .await .unwrap(); assert_eq!(items.len(), 2); @@ -1600,7 +1630,7 @@ async fn list_workspaces_returns_all() { let client = connect(&endpoint).await; let workspaces = client - .list_workspaces(ListOptions::default()) + .list_all_workspaces(ListOptions::default()) .await .unwrap(); assert_eq!(workspaces.len(), 2); diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index 41fe7e9e81..e5dadeb284 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -400,10 +400,14 @@ with SandboxClient.from_active_cluster() as client: sandbox = client.create(workspace="default", name="deep-research-1", labels={"env": "dev", "team": "platform"}) assert sandbox.labels["team"] == "platform" - matches = client.list(workspace="default", label_selector="env=dev,team=platform") + matches = client.list_all(workspace="default", label_selector="env=dev,team=platform") assert sandbox.id in {s.id for s in matches} ``` +Python SDK `list` methods return a lazy `Pager` whose iteration yields one +`Page` per gateway request. Use `list_all` only when you want to exhaust the +collection; pass `page_token` to resume from a token saved from an earlier page. + Create reusable sandbox templates through the Python SDK when several runs should share the same workload shape: @@ -431,7 +435,7 @@ from openshell import ClientCredentialsAuth, SandboxClient auth = ClientCredentialsAuth(client_secret=lambda: load_secret()) with SandboxClient.from_active_cluster(client_credentials=auth) as client: - sandboxes = client.list(workspace="default") + sandboxes = client.list_all(workspace="default") ``` ## Expose Long Running Services diff --git a/docs/sandboxes/manage-workspaces.mdx b/docs/sandboxes/manage-workspaces.mdx index 635fa1e7e3..5bff9755e9 100644 --- a/docs/sandboxes/manage-workspaces.mdx +++ b/docs/sandboxes/manage-workspaces.mdx @@ -181,8 +181,9 @@ explicit: | `global: true` | Omit `workspace_scope` | The Rust and Python SDKs expose separate all-workspaces list methods. The Go -SDK uses `ListAll`, and the TypeScript SDK uses a discriminated option type, so -a caller cannot select a named workspace and all workspaces in one typed call. +SDK passes `AllWorkspaces: true` in `ListOptions` to `ListAll`, and the +TypeScript SDK uses a discriminated option type, so a caller cannot select a +named workspace and all workspaces in one typed call. The TUI starts in the `default` workspace and sends that named selector explicitly. Its all-workspaces view sends the marker instead. diff --git a/e2e/python/oidc/oidc_auth_test.py b/e2e/python/oidc/oidc_auth_test.py index 06b5199a6f..8b0a1f70f9 100644 --- a/e2e/python/oidc/oidc_auth_test.py +++ b/e2e/python/oidc/oidc_auth_test.py @@ -258,7 +258,7 @@ def test_ci_token_can_list_sandboxes(self) -> None: metadata=admin_md, ) try: - ci_client.list(workspace="default") + ci_client.list_all(workspace="default") finally: ci_client.close() with contextlib.suppress(grpc.RpcError): diff --git a/e2e/python/test_sandbox_api.py b/e2e/python/test_sandbox_api.py index 954eb228de..5407bdf69e 100644 --- a/e2e/python/test_sandbox_api.py +++ b/e2e/python/test_sandbox_api.py @@ -211,10 +211,10 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: # A specific selector filters to exactly the primary sandbox. assert { - s.name for s in sandbox_client.list(workspace="default", label_selector=primary_selector) + s.name for s in sandbox_client.list_all(workspace="default", label_selector=primary_selector) } == {job_a} # The shared group label returns both. - assert {s.name for s in sandbox_client.list(workspace="default", label_selector=group_selector)} == { + assert {s.name for s in sandbox_client.list_all(workspace="default", label_selector=group_selector)} == { job_a, job_b, } @@ -223,7 +223,7 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: assert sandbox_client.delete(job_a, workspace="default") sandbox_client.wait_deleted(job_a, workspace="default") created.remove(job_a) - assert {s.name for s in sandbox_client.list(workspace="default", label_selector=group_selector)} == { + assert {s.name for s in sandbox_client.list_all(workspace="default", label_selector=group_selector)} == { job_b } @@ -231,7 +231,7 @@ def test_sandbox_labels_and_selectors(sandbox_client: SandboxClient) -> None: assert sandbox_client.delete(job_b, workspace="default") sandbox_client.wait_deleted(job_b, workspace="default") created.remove(job_b) - assert not sandbox_client.list(workspace="default", label_selector=group_selector) + assert not sandbox_client.list_all(workspace="default", label_selector=group_selector) finally: for name in created: with contextlib.suppress(Exception): diff --git a/e2e/python/test_workspace_api.py b/e2e/python/test_workspace_api.py index 01be5c341e..1fb33123eb 100644 --- a/e2e/python/test_workspace_api.py +++ b/e2e/python/test_workspace_api.py @@ -52,7 +52,7 @@ def test_workspace_list_includes_created(workspace_client: WorkspaceClient) -> N try: workspace_client.create(name) - names = {ws.name for ws in workspace_client.list()} + names = {ws.name for ws in workspace_client.list_all()} assert name in names assert "default" in names finally: diff --git a/python/openshell/__init__.py b/python/openshell/__init__.py index f20b76e9c2..775e9f7b16 100644 --- a/python/openshell/__init__.py +++ b/python/openshell/__init__.py @@ -9,6 +9,8 @@ ClientCredentialsAuth, ExecChunk, ExecResult, + Page, + Pager, Sandbox, SandboxClient, SandboxError, @@ -33,6 +35,8 @@ "ClientCredentialsAuth", "ExecChunk", "ExecResult", + "Page", + "Pager", "Sandbox", "SandboxClient", "SandboxError", diff --git a/python/openshell/sandbox.py b/python/openshell/sandbox.py index cfffdf6307..931fa1e2ab 100644 --- a/python/openshell/sandbox.py +++ b/python/openshell/sandbox.py @@ -17,7 +17,7 @@ import time from collections import namedtuple from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Never, SupportsIndex, cast +from typing import TYPE_CHECKING, Any, Generic, Never, SupportsIndex, TypeVar, cast from urllib.parse import urlparse import grpc @@ -35,6 +35,37 @@ ) _OAUTH_MAX_RESPONSE_BYTES = 1 << 20 +T = TypeVar("T") + + +@dataclass(frozen=True) +class Page(Generic[T]): + """One response page from a list operation.""" + + items: builtins.list[T] + next_page_token: str + + +class Pager(Generic[T]): + """Lazy, single-pass iterator that fetches one RPC page per advance.""" + + def __init__(self, fetch: Callable[[str], Page[T]], page_token: str = "") -> None: + self._fetch = fetch + self._page_token: str | None = page_token + + def __iter__(self) -> Pager[T]: + return self + + def __next__(self) -> Page[T]: + if self._page_token is None: + raise StopIteration + page = self._fetch(self._page_token) + self._page_token = page.next_page_token or None + return page + + def all(self) -> builtins.list[T]: + """Consume the pager and collect every remaining item.""" + return [item for page in self for item in page.items] def _workspace_scope(workspace: str) -> datamodel_pb2.WorkspaceSelector: @@ -820,47 +851,77 @@ def list( *, workspace: str, page_size: int = 100, + page_token: str = "", label_selector: str | None = None, - ) -> builtins.list[SandboxRef]: - sandboxes: builtins.list[SandboxRef] = [] - page_token = "" - while True: + ) -> Pager[SandboxRef]: + def fetch(token: str) -> Page[SandboxRef]: response = self._stub.ListSandboxes( openshell_pb2.ListSandboxesRequest( workspace_scope=_workspace_scope(workspace), page_size=page_size, - page_token=page_token, + page_token=token, label_selector=label_selector or "", ), timeout=self._timeout, ) - sandboxes.extend(_sandbox_ref(item) for item in response.sandboxes) - if not getattr(response, "next_page_token", ""): - return sandboxes - page_token = response.next_page_token + return Page( + items=[_sandbox_ref(item) for item in response.sandboxes], + next_page_token=getattr(response, "next_page_token", ""), + ) - def list_for_all_workspaces( + return Pager(fetch, page_token) + + def list_all( self, *, + workspace: str, page_size: int = 100, + page_token: str = "", label_selector: str | None = None, ) -> builtins.list[SandboxRef]: - sandboxes: builtins.list[SandboxRef] = [] - page_token = "" - while True: + return self.list( + workspace=workspace, + page_size=page_size, + page_token=page_token, + label_selector=label_selector, + ).all() + + def list_for_all_workspaces( + self, + *, + page_size: int = 100, + page_token: str = "", + label_selector: str | None = None, + ) -> Pager[SandboxRef]: + def fetch(token: str) -> Page[SandboxRef]: response = self._stub.ListSandboxes( openshell_pb2.ListSandboxesRequest( workspace_scope=_all_workspaces_scope(), page_size=page_size, - page_token=page_token, + page_token=token, label_selector=label_selector or "", ), timeout=self._timeout, ) - sandboxes.extend(_sandbox_ref(item) for item in response.sandboxes) - if not getattr(response, "next_page_token", ""): - return sandboxes - page_token = response.next_page_token + return Page( + items=[_sandbox_ref(item) for item in response.sandboxes], + next_page_token=getattr(response, "next_page_token", ""), + ) + + return Pager(fetch, page_token) + + def list_all_for_all_workspaces( + self, + *, + page_size: int = 100, + page_token: str = "", + label_selector: str | None = None, + ) -> builtins.list[SandboxRef]: + return self.list_for_all_workspaces( + page_size=page_size, + page_token=page_token, + label_selector=label_selector, + ).all() def list_ids( self, @@ -871,7 +932,7 @@ def list_ids( ) -> builtins.list[str]: return [ item.id - for item in self.list( + for item in self.list_all( workspace=workspace, page_size=page_size, label_selector=label_selector, @@ -886,7 +947,7 @@ def list_ids_for_all_workspaces( ) -> builtins.list[str]: return [ item.id - for item in self.list_for_all_workspaces( + for item in self.list_all_for_all_workspaces( page_size=page_size, label_selector=label_selector, ) @@ -1192,47 +1253,77 @@ def list( *, workspace: str, page_size: int = 100, + page_token: str = "", label_selector: str = "", - ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: - templates: builtins.list[openshell_pb2.SandboxWorkloadTemplate] = [] - page_token = "" - while True: + ) -> Pager[openshell_pb2.SandboxWorkloadTemplate]: + def fetch(token: str) -> Page[openshell_pb2.SandboxWorkloadTemplate]: response = self._stub.ListSandboxTemplates( openshell_pb2.ListSandboxTemplatesRequest( workspace_scope=_workspace_scope(workspace), page_size=page_size, - page_token=page_token, + page_token=token, label_selector=label_selector, ), timeout=self._timeout, ) - templates.extend(response.templates) - if not getattr(response, "next_page_token", ""): - return templates - page_token = response.next_page_token + return Page( + items=list(response.templates), + next_page_token=getattr(response, "next_page_token", ""), + ) - def list_for_all_workspaces( + return Pager(fetch, page_token) + + def list_all( self, *, + workspace: str, page_size: int = 100, + page_token: str = "", label_selector: str = "", ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: - templates: builtins.list[openshell_pb2.SandboxWorkloadTemplate] = [] - page_token = "" - while True: + return self.list( + workspace=workspace, + page_size=page_size, + page_token=page_token, + label_selector=label_selector, + ).all() + + def list_for_all_workspaces( + self, + *, + page_size: int = 100, + page_token: str = "", + label_selector: str = "", + ) -> Pager[openshell_pb2.SandboxWorkloadTemplate]: + def fetch(token: str) -> Page[openshell_pb2.SandboxWorkloadTemplate]: response = self._stub.ListSandboxTemplates( openshell_pb2.ListSandboxTemplatesRequest( workspace_scope=_all_workspaces_scope(), page_size=page_size, - page_token=page_token, + page_token=token, label_selector=label_selector, ), timeout=self._timeout, ) - templates.extend(response.templates) - if not getattr(response, "next_page_token", ""): - return templates - page_token = response.next_page_token + return Page( + items=list(response.templates), + next_page_token=getattr(response, "next_page_token", ""), + ) + + return Pager(fetch, page_token) + + def list_all_for_all_workspaces( + self, + *, + page_size: int = 100, + page_token: str = "", + label_selector: str = "", + ) -> builtins.list[openshell_pb2.SandboxWorkloadTemplate]: + return self.list_for_all_workspaces( + page_size=page_size, + page_token=page_token, + label_selector=label_selector, + ).all() def delete(self, name: str, *, workspace: str) -> bool: response = self._stub.DeleteSandboxTemplate( @@ -1297,23 +1388,37 @@ def list( self, *, page_size: int = 100, + page_token: str = "", label_selector: str | None = None, - ) -> builtins.list[WorkspaceRef]: - workspaces: builtins.list[WorkspaceRef] = [] - page_token = "" - while True: + ) -> Pager[WorkspaceRef]: + def fetch(token: str) -> Page[WorkspaceRef]: response = self._stub.ListWorkspaces( openshell_pb2.ListWorkspacesRequest( page_size=page_size, - page_token=page_token, + page_token=token, label_selector=label_selector or "", ), timeout=self._timeout, ) - workspaces.extend(_workspace_ref(ws) for ws in response.workspaces) - if not getattr(response, "next_page_token", ""): - return workspaces - page_token = response.next_page_token + return Page( + items=[_workspace_ref(ws) for ws in response.workspaces], + next_page_token=getattr(response, "next_page_token", ""), + ) + + return Pager(fetch, page_token) + + def list_all( + self, + *, + page_size: int = 100, + page_token: str = "", + label_selector: str | None = None, + ) -> builtins.list[WorkspaceRef]: + return self.list( + page_size=page_size, + page_token=page_token, + label_selector=label_selector, + ).all() def delete(self, name: str) -> bool: response = self._stub.DeleteWorkspace( diff --git a/python/openshell/sandbox_test.py b/python/openshell/sandbox_test.py index e436f714db..0675bc7338 100644 --- a/python/openshell/sandbox_test.py +++ b/python/openshell/sandbox_test.py @@ -23,6 +23,8 @@ _PYTHON_CLOUDPICKLE_BOOTSTRAP, _SANDBOX_PYTHON_BIN, ClientCredentialsAuth, + Page, + Pager, Sandbox, SandboxClient, SandboxError, @@ -2408,7 +2410,7 @@ def test_sandbox_template_client_crud_forwards_requests() -> None: assert stub.get_template_request.name == "gpu-kata" assert _request_workspace(stub.get_template_request) == "default" - listed = client.list( + listed = client.list_all( workspace="default", page_size=50, label_selector="team=runtime" ) assert len(listed) == 1 @@ -2429,7 +2431,7 @@ def test_sandbox_template_list_for_all_workspaces_selects_all() -> None: stub = _FakeSandboxStub() client = _template_client_with_fake_stub(stub) - client.list_for_all_workspaces(page_size=100, label_selector="team=runtime") + client.list_all_for_all_workspaces(page_size=100, label_selector="team=runtime") assert stub.list_template_request is not None assert _request_selects_all_workspaces(stub.list_template_request) @@ -2533,7 +2535,7 @@ def test_list_forwards_label_selector() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list(workspace="default", label_selector="aiq=deep-research") + client.list_all(workspace="default", label_selector="aiq=deep-research") assert stub.list_request is not None assert stub.list_request.label_selector == "aiq=deep-research" @@ -2544,7 +2546,7 @@ def test_list_without_selector_sends_empty_string() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list(workspace="default") + client.list_all(workspace="default") assert stub.list_request is not None assert stub.list_request.label_selector == "" @@ -2559,17 +2561,55 @@ def test_list_follows_continuation_tokens() -> None: ) client = _client_with_fake_stub(stub) - sandboxes = client.list( - workspace="default", page_size=1, label_selector="team=core" - ) - - assert [sandbox.name for sandbox in sandboxes] == ["job-1", "job-2"] + pager = client.list(workspace="default", page_size=1, label_selector="team=core") + + assert stub.list_requests == [] + first = next(pager) + assert [sandbox.name for sandbox in first.items] == ["job-1"] + assert first.next_page_token == "1" + second = next(pager) + assert [sandbox.name for sandbox in second.items] == ["job-2"] + assert second.next_page_token == "" + with pytest.raises(StopIteration): + next(pager) assert len(stub.list_requests) == 2 assert stub.list_requests[0].page_token == "" assert stub.list_requests[1].page_token == "1" assert stub.list_requests[1].label_selector == "team=core" +def test_list_passes_initial_page_token() -> None: + stub = _FakeSandboxStub( + listed_pages=[ + [_make_sandbox_proto("sandbox-1", "skipped")], + [_make_sandbox_proto("sandbox-2", "resumed")], + ] + ) + client = _client_with_fake_stub(stub) + + page = next(client.list(workspace="default", page_token="1")) + + assert [sandbox.name for sandbox in page.items] == ["resumed"] + assert stub.list_requests[0].page_token == "1" + + +def test_pager_retries_same_token_after_fetch_error() -> None: + tokens: list[str] = [] + + def fetch(token: str) -> Page[int]: + tokens.append(token) + if len(tokens) == 1: + raise RuntimeError("temporary failure") + return Page(items=[1], next_page_token="") + + pager = Pager(fetch, page_token="resume") + with pytest.raises(RuntimeError, match="temporary failure"): + next(pager) + + assert next(pager).items == [1] + assert tokens == ["resume", "resume"] + + def test_list_ids_forwards_label_selector() -> None: stub = _FakeSandboxStub(listed=[_make_sandbox_proto("sandbox-1", "job-1")]) client = _client_with_fake_stub(stub) @@ -2804,7 +2844,7 @@ def test_list_for_all_workspaces_sets_flag() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list_for_all_workspaces() + client.list_all_for_all_workspaces() assert stub.list_request is not None assert _request_selects_all_workspaces(stub.list_request) @@ -2815,7 +2855,7 @@ def test_list_with_workspace_passes_workspace() -> None: stub = _FakeSandboxStub() client = _client_with_fake_stub(stub) - client.list(workspace="staging") + client.list_all(workspace="staging") assert stub.list_request is not None assert _request_workspace(stub.list_request) == "staging" diff --git a/sdk/go/README.md b/sdk/go/README.md index 4d09733884..86c6e25479 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -76,6 +76,31 @@ if err != nil { fmt.Println(string(result.Stdout)) ``` +### Pagination + +List methods return a lazy pager without issuing a request. `NextPage` fetches +one page with the supplied context, while `ListAll` explicitly exhausts every +page. `PageSize` is a per-request maximum and `PageToken` resumes a prior query. + +```go +pager, err := client.Sandboxes().List("default", v1.ListOptions{PageSize: 100}) +if err != nil { + log.Fatal(err) +} +for { + page, err := pager.NextPage(ctx) + if err != nil { + log.Fatal(err) + } + if page == nil { + break + } + for _, sandbox := range page.Items { + fmt.Println(sandbox.Name) + } +} +``` + ### With automatic token refresh For OIDC gateways, use `RefreshableToken` to wrap any `oauth2.TokenSource` with diff --git a/sdk/go/docs/src/api/overview.md b/sdk/go/docs/src/api/overview.md index 0ff01c4ce4..0630c1057a 100644 --- a/sdk/go/docs/src/api/overview.md +++ b/sdk/go/docs/src/api/overview.md @@ -56,9 +56,10 @@ Each interface has a reference page with method signatures and usage examples: ## Common Patterns -All SDK methods follow these conventions: +SDK methods follow these conventions: -- Every method takes `context.Context` as its first argument +- Network operations take `context.Context`; list constructors return a pager, + and each `NextPage` call accepts the request context - Methods that can fail return `(result, error)` -- List methods accept variadic option arguments +- List methods accept variadic options and `ListAll` is the explicit exhaustive path - Errors from the gateway carry a `StatusError` with a typed code (see [Error Handling](../error-handling.md)) diff --git a/sdk/go/docs/src/api/policy.md b/sdk/go/docs/src/api/policy.md index 62f475cd68..e4107ee5b9 100644 --- a/sdk/go/docs/src/api/policy.md +++ b/sdk/go/docs/src/api/policy.md @@ -48,10 +48,11 @@ fmt.Printf("Active version: %d, revision status: %s\n", ## List -List all policy revisions for a sandbox. +`List` returns a lazy pager over policy revisions for one sandbox. Use +`ListAll` to collect every page. ```go -revisions, err := client.Policy().List(ctx, "default", "my-sandbox") +revisions, err := client.Policy().ListAll(ctx, "default", "my-sandbox") if err != nil { log.Fatal(err) } diff --git a/sdk/go/docs/src/api/profiles.md b/sdk/go/docs/src/api/profiles.md index 0cd91d5742..57b3b949c6 100644 --- a/sdk/go/docs/src/api/profiles.md +++ b/sdk/go/docs/src/api/profiles.md @@ -7,10 +7,11 @@ credentials, and model mappings for providers like OpenAI, Anthropic, or custom ## List -List all provider profiles visible to the current user. +`List` returns a lazy pager over visible provider profiles. Use `ListAll` to +collect every page. ```go -profiles, err := client.Providers().Profiles().List(ctx, "default") +profiles, err := client.Providers().Profiles().ListAll(ctx, "default") if err != nil { log.Fatal(err) } diff --git a/sdk/go/docs/src/api/providers.md b/sdk/go/docs/src/api/providers.md index f9b1a8c967..3a21ca96b0 100644 --- a/sdk/go/docs/src/api/providers.md +++ b/sdk/go/docs/src/api/providers.md @@ -42,12 +42,12 @@ fmt.Println("Provider type:", provider.Type) ## List -List all registered providers. The SDK follows gateway continuation tokens -automatically; `PageSize` controls each request. +`List` returns a lazy pager over registered providers. `ListAll` follows every +continuation token; `PageSize` controls each request. ```go // List all providers -providers, err := client.Providers().List(ctx, "default") +providers, err := client.Providers().ListAll(ctx, "default") if err != nil { log.Fatal(err) } @@ -56,12 +56,14 @@ for _, p := range providers { } // With a smaller page size -providers, err = client.Providers().List(ctx, "default", v1.ListOptions{ +providers, err = client.Providers().ListAll(ctx, "default", v1.ListOptions{ PageSize: 10, }) // Platform Admin only: list across all workspaces -allProviders, err := client.Providers().ListAll(ctx) +allProviders, err := client.Providers().ListAll(ctx, "", v1.ListOptions{ + AllWorkspaces: true, +}) ``` ## Update diff --git a/sdk/go/docs/src/api/sandbox-templates.md b/sdk/go/docs/src/api/sandbox-templates.md index e955dc1f29..764014400c 100644 --- a/sdk/go/docs/src/api/sandbox-templates.md +++ b/sdk/go/docs/src/api/sandbox-templates.md @@ -89,16 +89,16 @@ fmt.Println(template.Spec.Workload.Image) ## List -Lists every matching template in one workspace or across all workspaces. The -SDK follows gateway continuation tokens automatically. +`List` returns a lazy pager over matching templates in one workspace or across +all workspaces. Use `ListAll` to follow every continuation token automatically. ```go -templates, err := client.SandboxTemplates().List(ctx, "default", v1.ListOptions{ +templates, err := client.SandboxTemplates().ListAll(ctx, "default", v1.ListOptions{ PageSize: 50, }) -allTemplates, err := client.SandboxTemplates().ListAll(ctx, v1.ListOptions{ - PageSize: 50, +allTemplates, err := client.SandboxTemplates().ListAll(ctx, "", v1.ListOptions{ + AllWorkspaces: true, }) ``` diff --git a/sdk/go/docs/src/api/sandboxes.md b/sdk/go/docs/src/api/sandboxes.md index e9ab530690..21ee14a1bf 100644 --- a/sdk/go/docs/src/api/sandboxes.md +++ b/sdk/go/docs/src/api/sandboxes.md @@ -57,21 +57,27 @@ fmt.Println(sb.Status.Phase) // "Ready", "Provisioning", etc. ## List -Lists every matching sandbox, following gateway continuation tokens -automatically. `PageSize` controls the size of each request. +`List` constructs a lazy pager; `NextPage` fetches one page at a time. +`PageSize` controls each request, and `ListAll` explicitly exhausts the pager. ```go // List all sandboxes -sandboxes, err := client.Sandboxes().List(ctx, "default") +sandboxes, err := client.Sandboxes().ListAll(ctx, "default") + +// Process one page at a time +pages, err := client.Sandboxes().List("default", v1.ListOptions{PageSize: 10}) +page, err := pages.NextPage(ctx) // With a page size and label filtering -sandboxes, err := client.Sandboxes().List(ctx, "default", v1.ListOptions{ +sandboxes, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{ PageSize: 10, LabelSelector: "team=platform", }) // Platform Admin only: list across all workspaces -allSandboxes, err := client.Sandboxes().ListAll(ctx) +allSandboxes, err := client.Sandboxes().ListAll(ctx, "", v1.ListOptions{ + AllWorkspaces: true, +}) ``` ## Delete diff --git a/sdk/go/docs/src/api/services.md b/sdk/go/docs/src/api/services.md index 0c44567d2f..d309f7975f 100644 --- a/sdk/go/docs/src/api/services.md +++ b/sdk/go/docs/src/api/services.md @@ -20,10 +20,11 @@ fmt.Printf("Service available at: %s\n", endpoint.URL) ## List -List all exposed services for a sandbox. +`List` returns a lazy pager over exposed services. Use `ListAll` to collect +every page. ```go -services, err := client.Services().List(ctx, "default", "my-sandbox") +services, err := client.Services().ListAll(ctx, "default", "my-sandbox") if err != nil { log.Fatal(err) } @@ -32,7 +33,9 @@ for _, svc := range services { } // Platform Admin only: list services across all workspaces -allServices, err := client.Services().ListAll(ctx) +allServices, err := client.Services().ListAll(ctx, "", "", v1.ListOptions{ + AllWorkspaces: true, +}) ``` ## Delete diff --git a/sdk/go/docs/src/architecture.md b/sdk/go/docs/src/architecture.md index c9c6948d80..0a52481118 100644 --- a/sdk/go/docs/src/architecture.md +++ b/sdk/go/docs/src/architecture.md @@ -76,7 +76,7 @@ exec := client.Exec() Some sub-clients have their own sub-clients. `ProviderInterface` exposes `Profiles()` and `Refresh()`: ```go -profiles, err := client.Providers().Profiles().List(ctx, "default") +profiles, err := client.Providers().Profiles().ListAll(ctx, "default") status, err := client.Providers().Refresh().GetStatus(ctx, "default", "openai", "api-key") ``` diff --git a/sdk/go/docs/src/testing.md b/sdk/go/docs/src/testing.md index af1851ae83..138738ab19 100644 --- a/sdk/go/docs/src/testing.md +++ b/sdk/go/docs/src/testing.md @@ -66,7 +66,7 @@ client.AddProvider(&types.Provider{ }) // List returns the seeded provider -providers, _ := client.Providers().List(ctx, "default") +providers, _ := client.Providers().ListAll(ctx, "default") // len(providers) == 1 ``` diff --git a/sdk/go/openshell/v1/doc.go b/sdk/go/openshell/v1/doc.go index 900d86c643..ecf995dd43 100644 --- a/sdk/go/openshell/v1/doc.go +++ b/sdk/go/openshell/v1/doc.go @@ -5,8 +5,9 @@ // // The SDK follows the Kubernetes client-go sub-client pattern: a single Client // provides typed accessors for each resource domain (Sandboxes, Providers, Exec, -// Files, Health, Services, SSH, TCP, Config, Policy, and Workspaces). All operations accept a context.Context and return idiomatic -// Go types. Proto-generated types never appear in the public API. +// Files, Health, Services, SSH, TCP, Config, Policy, and Workspaces). Network +// operations accept a context.Context and return idiomatic Go types. +// Proto-generated types never appear in the public API. // // # Quick Start // @@ -34,6 +35,28 @@ // log.Fatal(err) // } // +// # Pagination +// +// List methods construct a lazy Pager without issuing an RPC. Each NextPage +// call fetches one page; ListAll is the explicit exhaustive convenience. +// +// pages, err := client.Sandboxes().List("default", v1.ListOptions{PageSize: 100}) +// if err != nil { +// log.Fatal(err) +// } +// for { +// page, err := pages.NextPage(ctx) +// if err != nil { +// log.Fatal(err) +// } +// if page == nil { +// break +// } +// for _, sandbox := range page.Items { +// fmt.Println(sandbox.Name) +// } +// } +// // # Command Execution // // result, err := client.Exec().Run(ctx, "default", sandbox.Name, []string{"echo", "hello"}, v1.ExecOptions{}) @@ -86,7 +109,7 @@ // } // fmt.Printf("Service URL: %s\n", endpoint.URL) // -// endpoints, err := client.Services().List(ctx, "default", "my-sandbox") +// endpoints, err := client.Services().ListAll(ctx, "default", "my-sandbox") // if err != nil { // log.Fatal(err) // } @@ -98,7 +121,7 @@ // // List available provider profiles and import new ones: // -// profiles, err := client.Providers().Profiles().List(ctx, "default") +// profiles, err := client.Providers().Profiles().ListAll(ctx, "default") // if err != nil { // log.Fatal(err) // } @@ -314,7 +337,7 @@ // // Read a policy back from revision history: // -// revisions, err := client.Policy().List(ctx, "default") +// revisions, err := client.Policy().ListAll(ctx, "default", "my-sandbox") // if err != nil { // log.Fatal(err) // } @@ -328,7 +351,7 @@ // // List gateway-global policy revisions (no sandbox name or workspace needed): // -// revisions, err := client.Policy().List(ctx, "", v1.WithListGlobal(true)) +// revisions, err := client.Policy().ListAll(ctx, "", "", v1.WithListGlobal(true)) // if err != nil { // log.Fatal(err) // } @@ -359,7 +382,7 @@ // } // fmt.Printf("Workspace %s created (phase: %s)\n", ws.Name, ws.Phase) // -// workspaces, err := client.Workspaces().List(ctx) +// workspaces, err := client.Workspaces().ListAll(ctx) // if err != nil { // log.Fatal(err) // } @@ -378,7 +401,7 @@ // } // fmt.Printf("Added %s as %s\n", member.PrincipalSubject, member.Role) // -// members, err := client.Workspaces().ListMembers(ctx, "team-alpha") +// members, err := client.Workspaces().ListAllMembers(ctx, "team-alpha") // if err != nil { // log.Fatal(err) // } diff --git a/sdk/go/openshell/v1/example_fake_test.go b/sdk/go/openshell/v1/example_fake_test.go index 94c9ccbd7c..32a2c262ff 100644 --- a/sdk/go/openshell/v1/example_fake_test.go +++ b/sdk/go/openshell/v1/example_fake_test.go @@ -55,7 +55,7 @@ func ExampleNewClient_addProvider() { ctx := context.Background() - providers, err := client.Providers().List(ctx, "default") + providers, err := client.Providers().ListAll(ctx, "default") if err != nil { log.Fatal(err) } diff --git a/sdk/go/openshell/v1/example_test.go b/sdk/go/openshell/v1/example_test.go index eb96fe8c11..d0f6e115c7 100644 --- a/sdk/go/openshell/v1/example_test.go +++ b/sdk/go/openshell/v1/example_test.go @@ -62,7 +62,7 @@ func ExampleClient_Providers() { } // List all providers - providers, err := client.Providers().List(ctx, "default") + providers, err := client.Providers().ListAll(ctx, "default") if err != nil { log.Fatal(err) } diff --git a/sdk/go/openshell/v1/exec_client_test.go b/sdk/go/openshell/v1/exec_client_test.go index 53acc1e8bc..183940ec8b 100644 --- a/sdk/go/openshell/v1/exec_client_test.go +++ b/sdk/go/openshell/v1/exec_client_test.go @@ -37,10 +37,10 @@ func (r *stubSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox, func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) { panic("not implemented") } -func (r *stubSandboxResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { +func (r *stubSandboxResolver) List(string, ...ListOptions) (*Pager[*Sandbox], error) { panic("not implemented") } -func (r *stubSandboxResolver) ListAll(context.Context, ...ListOptions) ([]*Sandbox, error) { +func (r *stubSandboxResolver) ListAll(context.Context, string, ...ListOptions) ([]*Sandbox, error) { panic("not implemented") } func (r *stubSandboxResolver) Delete(context.Context, string, string) error { diff --git a/sdk/go/openshell/v1/fake/fake_test.go b/sdk/go/openshell/v1/fake/fake_test.go index d749b1eac4..dcc494d283 100644 --- a/sdk/go/openshell/v1/fake/fake_test.go +++ b/sdk/go/openshell/v1/fake/fake_test.go @@ -152,7 +152,7 @@ func TestFakeClient_AddSandbox_InList(t *testing.T) { fc.AddSandbox("default", &types.Sandbox{Name: "sb-1"}) fc.AddSandbox("default", &types.Sandbox{Name: "sb-2"}) - list, err := fc.Sandboxes().List(ctx, "default") + list, err := fc.Sandboxes().ListAll(ctx, "default") require.NoError(t, err) assert.Len(t, list, 2) } diff --git a/sdk/go/openshell/v1/fake/pager.go b/sdk/go/openshell/v1/fake/pager.go new file mode 100644 index 0000000000..fed00135d5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/pager.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "strconv" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +func newSlicePager[T any](items []T, pageSize int, pageToken string) (*v1.Pager[T], error) { + if pageSize < 0 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "page size must not be negative"} + } + if pageSize == 0 { + pageSize = 100 + } + if _, err := parsePageOffset(pageToken, len(items)); err != nil { + return nil, err + } + return v1.NewPager(pageToken, func(_ context.Context, token string) (*v1.Page[T], error) { + start, err := parsePageOffset(token, len(items)) + if err != nil { + return nil, err + } + end := min(start+pageSize, len(items)) + next := "" + if end < len(items) { + next = strconv.Itoa(end) + } + pageItems := append(make([]T, 0, end-start), items[start:end]...) + return &v1.Page[T]{Items: pageItems, NextPageToken: next}, nil + }), nil +} + +func parsePageOffset(token string, length int) (int, error) { + if token == "" { + return 0, nil + } + offset, err := strconv.Atoi(token) + if err != nil || offset < 0 || offset > length { + return 0, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "invalid page token"} + } + return offset, nil +} diff --git a/sdk/go/openshell/v1/fake/policy.go b/sdk/go/openshell/v1/fake/policy.go index e69c7bad8e..323b17faa6 100644 --- a/sdk/go/openshell/v1/fake/policy.go +++ b/sdk/go/openshell/v1/fake/policy.go @@ -7,7 +7,6 @@ import ( "context" "maps" "slices" - "strings" "sync" v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" @@ -174,7 +173,7 @@ func (c *fakePolicyClient) GetStatus(_ context.Context, workspace, sandboxName s // List returns policy revisions. When the global option is set, it returns // global revisions; otherwise it returns all sandbox-scoped revisions for the // given workspace. -func (c *fakePolicyClient) List(_ context.Context, workspace string, opts ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { +func (c *fakePolicyClient) List(workspace, sandboxName string, opts ...v1.ListPolicyOption) (*v1.Pager[types.SandboxPolicyRevision], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } @@ -182,6 +181,9 @@ func (c *fakePolicyClient) List(_ context.Context, workspace string, opts ...v1. if cfg.PageSize() < 0 { return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "page size must not be negative"} } + if !cfg.Global() && sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } c.mu.RLock() defer c.mu.RUnlock() @@ -190,17 +192,7 @@ func (c *fakePolicyClient) List(_ context.Context, workspace string, opts ...v1. if cfg.Global() { revisions = slices.Clone(c.globalRevisions) } else { - // Collect all revisions for sandboxes in this workspace. - prefix := workspace + "/" - for key, revs := range c.sandboxRevisions { - if strings.HasPrefix(key, prefix) { - revisions = append(revisions, revs...) - } - } - } - - if len(revisions) == 0 { - return nil, nil + revisions = slices.Clone(c.sandboxRevisions[workspace+"/"+sandboxName]) } // Sort by version for deterministic ordering (map iteration is random). @@ -218,7 +210,15 @@ func (c *fakePolicyClient) List(_ context.Context, workspace string, opts ...v1. for i, r := range revisions { result[i] = copySandboxPolicyRevision(r) } - return result, nil + return newSlicePager(result, int(cfg.PageSize()), cfg.PageToken()) +} + +func (c *fakePolicyClient) ListAll(ctx context.Context, workspace, sandboxName string, opts ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { + pager, err := c.List(workspace, sandboxName, opts...) + if err != nil { + return nil, err + } + return pager.All(ctx) } // EditDraftChunk returns Unimplemented. diff --git a/sdk/go/openshell/v1/fake/policy_test.go b/sdk/go/openshell/v1/fake/policy_test.go index d2ff4e3b0e..924bd2fab6 100644 --- a/sdk/go/openshell/v1/fake/policy_test.go +++ b/sdk/go/openshell/v1/fake/policy_test.go @@ -64,11 +64,12 @@ func TestFakePolicy_GetStatus_EmptyReturnsNotFound(t *testing.T) { assert.True(t, types.IsNotFound(err)) } -func TestFakePolicy_List_EmptyReturnsNil(t *testing.T) { +func TestFakePolicy_List_EmptyReturnsNonNil(t *testing.T) { c := newFakePolicyClient(func() bool { return false }) - revisions, err := c.List(context.Background(), "default") + revisions, err := c.ListAll(context.Background(), "default", "sb-1") require.NoError(t, err) - assert.Nil(t, revisions) + assert.NotNil(t, revisions) + assert.Empty(t, revisions) } func TestFakePolicy_EditDraftChunk_ReturnsUnimplemented(t *testing.T) { @@ -96,7 +97,7 @@ func TestFakePolicy_List_Global(t *testing.T) { c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1", Status: types.PolicyLoadStatusLoaded}) // List global revisions. - revisions, err := c.List(context.Background(), "", types.WithListGlobal(true)) + revisions, err := c.ListAll(context.Background(), "", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 2) assert.Equal(t, uint32(1), revisions[0].Version) @@ -113,7 +114,7 @@ func TestFakePolicy_List_Sandbox(t *testing.T) { c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 2, PolicyHash: "sha256:sb-v2", Status: types.PolicyLoadStatusPending}) // List sandbox-scoped revisions (no global flag). - revisions, err := c.List(context.Background(), "default") + revisions, err := c.ListAll(context.Background(), "default", "sb-1") require.NoError(t, err) require.Len(t, revisions, 2) assert.Equal(t, "sha256:sb-v1", revisions[0].PolicyHash) @@ -127,9 +128,10 @@ func TestFakePolicy_List_NoIsolationCrossContamination(t *testing.T) { c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1"}) // Global list returns empty (no global revisions seeded). - revisions, err := c.List(context.Background(), "", types.WithListGlobal(true)) + revisions, err := c.ListAll(context.Background(), "", "", types.WithListGlobal(true)) require.NoError(t, err) - assert.Nil(t, revisions) + assert.NotNil(t, revisions) + assert.Empty(t, revisions) } func TestFakePolicy_List_GlobalPageSizeStillReturnsAll(t *testing.T) { @@ -139,7 +141,7 @@ func TestFakePolicy_List_GlobalPageSizeStillReturnsAll(t *testing.T) { c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 2}) c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 3}) - revisions, err := c.List(context.Background(), "", types.WithListGlobal(true), types.WithPageSize(2)) + revisions, err := c.ListAll(context.Background(), "", "", types.WithListGlobal(true), types.WithPageSize(2)) require.NoError(t, err) require.Len(t, revisions, 3) assert.Equal(t, uint32(1), revisions[0].Version) @@ -229,14 +231,14 @@ func TestFakePolicy_DeepCopyWithPolicy(t *testing.T) { ctx := context.Background() // Get global revision and mutate it. - revisions, err := fc.Policy().List(ctx, "", types.WithListGlobal(true)) + revisions, err := fc.Policy().ListAll(ctx, "", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 1) require.NotNil(t, revisions[0].Policy) revisions[0].Policy.NetworkPolicies["rule-1"] = types.NetworkPolicyRule{Name: "mutated"} // Verify internal state is not corrupted. - revisions2, err := fc.Policy().List(ctx, "", types.WithListGlobal(true)) + revisions2, err := fc.Policy().ListAll(ctx, "", "", types.WithListGlobal(true)) require.NoError(t, err) assert.Equal(t, "rule-1", revisions2[0].Policy.NetworkPolicies["rule-1"].Name) @@ -249,7 +251,7 @@ func TestFakePolicy_DeepCopyWithPolicy(t *testing.T) { func TestFakePolicy_List_ClosedReturnsUnavailable(t *testing.T) { c := newFakePolicyClient(func() bool { return true }) - _, err := c.List(context.Background(), "", types.WithListGlobal(true)) + _, err := c.ListAll(context.Background(), "", "", types.WithListGlobal(true)) require.Error(t, err) assert.True(t, types.IsUnavailable(err)) } diff --git a/sdk/go/openshell/v1/fake/profile.go b/sdk/go/openshell/v1/fake/profile.go index 067a62c627..b8298ee00d 100644 --- a/sdk/go/openshell/v1/fake/profile.go +++ b/sdk/go/openshell/v1/fake/profile.go @@ -22,13 +22,21 @@ func newFakeProfileClient(closedFunc func() bool) *fakeProfileClient { } // List returns Unimplemented. -func (c *fakeProfileClient) List(_ context.Context, _ string, _ ...v1.ListOptions) ([]*types.ProviderProfile, error) { +func (c *fakeProfileClient) List(_ string, _ ...v1.ListOptions) (*v1.Pager[*types.ProviderProfile], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} } +// ListAll returns Unimplemented. +func (c *fakeProfileClient) ListAll(_ context.Context, _ string, _ ...v1.ListOptions) ([]*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ListAll is not supported by the fake client"} +} + // Get returns Unimplemented. func (c *fakeProfileClient) Get(_ context.Context, _, _ string) (*types.ProviderProfile, error) { if c.closedFunc() { diff --git a/sdk/go/openshell/v1/fake/profile_test.go b/sdk/go/openshell/v1/fake/profile_test.go index a0698c1f0b..b22ed9709d 100644 --- a/sdk/go/openshell/v1/fake/profile_test.go +++ b/sdk/go/openshell/v1/fake/profile_test.go @@ -17,7 +17,7 @@ import ( func TestFakeProfile_List_ReturnsUnimplemented(t *testing.T) { c := newFakeProfileClient(func() bool { return false }) - _, err := c.List(context.Background(), "default") + _, err := c.ListAll(context.Background(), "default") require.Error(t, err) assert.True(t, types.IsUnimplemented(err)) } @@ -59,7 +59,7 @@ func TestFakeProfile_Delete_ReturnsUnimplemented(t *testing.T) { func TestFakeProfile_List_ClosedReturnsUnavailable(t *testing.T) { c := newFakeProfileClient(func() bool { return true }) - _, err := c.List(context.Background(), "default") + _, err := c.ListAll(context.Background(), "default") require.Error(t, err) assert.True(t, types.IsUnavailable(err)) } diff --git a/sdk/go/openshell/v1/fake/provider.go b/sdk/go/openshell/v1/fake/provider.go index 7ba0345880..2fd466f054 100644 --- a/sdk/go/openshell/v1/fake/provider.go +++ b/sdk/go/openshell/v1/fake/provider.go @@ -111,20 +111,28 @@ func (c *fakeProviderClient) Get(_ context.Context, workspace, name string) (*ty return c.store.Get(workspace, name) } -// List returns all providers. ListOptions are accepted for interface -// compatibility but filtering is not implemented. -func (c *fakeProviderClient) List(_ context.Context, workspace string, _ ...v1.ListOptions) ([]*types.Provider, error) { +// List returns a lazy pager over providers. Filtering is not implemented. +func (c *fakeProviderClient) List(workspace string, opts ...v1.ListOptions) (*v1.Pager[*types.Provider], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } - return c.store.List(workspace), nil + var options v1.ListOptions + if len(opts) > 0 { + options = opts[0] + } + items := c.store.List(workspace) + if len(opts) > 0 && opts[0].AllWorkspaces { + items = c.store.ListAll() + } + return newSlicePager(items, options.PageSize, options.PageToken) } -func (c *fakeProviderClient) ListAll(_ context.Context, _ ...v1.ListOptions) ([]*types.Provider, error) { - if c.closedFunc() { - return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} +func (c *fakeProviderClient) ListAll(ctx context.Context, workspace string, opts ...v1.ListOptions) ([]*types.Provider, error) { + pager, err := c.List(workspace, opts...) + if err != nil { + return nil, err } - return c.store.ListAll(), nil + return pager.All(ctx) } // Update replaces an existing provider's data. ResourceVersion is diff --git a/sdk/go/openshell/v1/fake/provider_test.go b/sdk/go/openshell/v1/fake/provider_test.go index 4bc6122c65..df3d1cb470 100644 --- a/sdk/go/openshell/v1/fake/provider_test.go +++ b/sdk/go/openshell/v1/fake/provider_test.go @@ -83,7 +83,7 @@ func TestProvider_List_Empty(t *testing.T) { pc := newTestProviderClient() ctx := context.Background() - list, err := pc.List(ctx, "default") + list, err := pc.ListAll(ctx, "default") require.NoError(t, err) assert.Empty(t, list) } @@ -95,7 +95,7 @@ func TestProvider_List(t *testing.T) { _, _ = pc.Create(ctx, "default", &types.Provider{Name: "openai", Type: "openai"}) _, _ = pc.Create(ctx, "default", &types.Provider{Name: "anthropic", Type: "anthropic"}) - list, err := pc.List(ctx, "default") + list, err := pc.ListAll(ctx, "default") require.NoError(t, err) assert.Len(t, list, 2) } @@ -265,7 +265,7 @@ func TestProvider_ConcurrentCreateGetListDeleteEnsure(_ *testing.T) { p := &types.Provider{Name: name, Type: "test"} _, _ = pc.Create(ctx, "default", p) _, _ = pc.Get(ctx, "default", name) - _, _ = pc.List(ctx, "default") + _, _ = pc.ListAll(ctx, "default") _, _ = pc.Update(ctx, "default", &types.Provider{Name: name, Type: "updated"}) _, _ = pc.Ensure(ctx, "default", &types.Provider{Name: name, Type: "ensured"}) _ = pc.Delete(ctx, "default", name) diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go index 8191594a5c..9f8e6c2287 100644 --- a/sdk/go/openshell/v1/fake/sandbox.go +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -445,20 +445,28 @@ func (c *fakeSandboxClient) Get(_ context.Context, workspace, name string) (*typ return c.store.Get(workspace, name) } -// List returns all sandboxes. ListOptions are accepted for interface -// compatibility but filtering is not implemented. -func (c *fakeSandboxClient) List(_ context.Context, workspace string, _ ...v1.ListOptions) ([]*types.Sandbox, error) { +// List returns a lazy pager over sandboxes. Filtering is not implemented. +func (c *fakeSandboxClient) List(workspace string, opts ...v1.ListOptions) (*v1.Pager[*types.Sandbox], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } - return c.store.List(workspace), nil + var options v1.ListOptions + if len(opts) > 0 { + options = opts[0] + } + items := c.store.List(workspace) + if len(opts) > 0 && opts[0].AllWorkspaces { + items = c.store.ListAll() + } + return newSlicePager(items, options.PageSize, options.PageToken) } -func (c *fakeSandboxClient) ListAll(_ context.Context, _ ...v1.ListOptions) ([]*types.Sandbox, error) { - if c.closedFunc() { - return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} +func (c *fakeSandboxClient) ListAll(ctx context.Context, workspace string, opts ...v1.ListOptions) ([]*types.Sandbox, error) { + pager, err := c.List(workspace, opts...) + if err != nil { + return nil, err } - return c.store.ListAll(), nil + return pager.All(ctx) } // Stop transitions a sandbox to the Stopped phase. diff --git a/sdk/go/openshell/v1/fake/sandbox_template.go b/sdk/go/openshell/v1/fake/sandbox_template.go index f58f8f6eaf..438bbf532f 100644 --- a/sdk/go/openshell/v1/fake/sandbox_template.go +++ b/sdk/go/openshell/v1/fake/sandbox_template.go @@ -105,15 +105,7 @@ func (c *fakeSandboxTemplateClient) Get(_ context.Context, workspace, name strin return c.store.Get(workspace, name) } -func (c *fakeSandboxTemplateClient) List(_ context.Context, workspace string, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { - return c.list(workspace, false, opts...) -} - -func (c *fakeSandboxTemplateClient) ListAll(_ context.Context, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { - return c.list("", true, opts...) -} - -func (c *fakeSandboxTemplateClient) list(workspace string, allWorkspaces bool, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { +func (c *fakeSandboxTemplateClient) List(workspace string, opts ...v1.ListOptions) (*v1.Pager[*types.SandboxWorkloadTemplate], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } @@ -125,7 +117,7 @@ func (c *fakeSandboxTemplateClient) list(workspace string, allWorkspaces bool, o } } var templates []*types.SandboxWorkloadTemplate - if allWorkspaces { + if options.AllWorkspaces { templates = c.store.ListAll() } else { templates = c.store.List(workspace) @@ -135,7 +127,15 @@ func (c *fakeSandboxTemplateClient) list(workspace string, allWorkspaces bool, o if err != nil { return nil, err } - return templates, nil + return newSlicePager(templates, options.PageSize, options.PageToken) +} + +func (c *fakeSandboxTemplateClient) ListAll(ctx context.Context, workspace string, opts ...v1.ListOptions) ([]*types.SandboxWorkloadTemplate, error) { + pager, err := c.List(workspace, opts...) + if err != nil { + return nil, err + } + return pager.All(ctx) } func (c *fakeSandboxTemplateClient) Delete(_ context.Context, workspace, name string) (bool, error) { diff --git a/sdk/go/openshell/v1/fake/sandbox_template_test.go b/sdk/go/openshell/v1/fake/sandbox_template_test.go index 1df35f8436..19bf8909b6 100644 --- a/sdk/go/openshell/v1/fake/sandbox_template_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_template_test.go @@ -71,7 +71,7 @@ func TestSandboxTemplate_CreateGetListDelete(t *testing.T) { assert.Equal(t, "python:3.12", got.Spec.Workload.Image) assert.Equal(t, "kata", got.Spec.DriverConfig["kubernetes"].(map[string]any)["runtime_class_name"]) - listed, err := tc.List(ctx, "default") + listed, err := tc.ListAll(ctx, "default") require.NoError(t, err) require.Len(t, listed, 1) assert.Equal(t, "gpu-kata", listed[0].Name) @@ -108,7 +108,7 @@ func TestSandboxTemplate_ListAllWorkspaces(t *testing.T) { _, _ = tc.Create(ctx, "default", testSandboxWorkloadTemplate("default-template")) _, _ = tc.Create(ctx, "team-a", testSandboxWorkloadTemplate("team-template")) - listed, err := tc.ListAll(ctx) + listed, err := tc.ListAll(ctx, "default", types.ListOptions{AllWorkspaces: true}) require.NoError(t, err) assert.Len(t, listed, 2) } @@ -132,7 +132,7 @@ func TestSandboxTemplate_ListFiltersByLabelSelector(t *testing.T) { }, }) - listed, err := tc.List(ctx, "default", types.ListOptions{LabelSelector: "team=runtime"}) + listed, err := tc.ListAll(ctx, "default", types.ListOptions{LabelSelector: "team=runtime"}) require.NoError(t, err) require.Len(t, listed, 1) @@ -143,7 +143,7 @@ func TestSandboxTemplate_ListRejectsNegativePagination(t *testing.T) { tc := newTestSandboxTemplateClient() ctx := context.Background() - _, err := tc.List(ctx, "default", types.ListOptions{PageSize: -1}) + _, err := tc.ListAll(ctx, "default", types.ListOptions{PageSize: -1}) require.Error(t, err) assert.True(t, types.IsInvalidArgument(err)) } @@ -174,7 +174,7 @@ func TestSandboxTemplate_ListReturnsAllFilteredResults(t *testing.T) { }, }) - listed, err := tc.List(ctx, "default", types.ListOptions{ + listed, err := tc.ListAll(ctx, "default", types.ListOptions{ LabelSelector: "team=runtime", PageSize: 1, }) @@ -293,7 +293,7 @@ func TestSandboxTemplate_CreateSandboxFromTemplateRejectsWorkloadOverrides(t *te }) } - listed, err := client.Sandboxes().List(ctx, "default") + listed, err := client.Sandboxes().ListAll(ctx, "default") require.NoError(t, err) assert.Empty(t, listed) } @@ -453,7 +453,7 @@ func TestSandboxTemplate_CreateRejectsInvalidTemplate(t *testing.T) { }) } - listed, err := tc.List(ctx, "default") + listed, err := tc.ListAll(ctx, "default") require.NoError(t, err) assert.Empty(t, listed) } diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go index 75ff32da2d..1a2673808a 100644 --- a/sdk/go/openshell/v1/fake/sandbox_test.go +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -192,7 +192,7 @@ func TestSandbox_List_Empty(t *testing.T) { sc := newTestSandboxClient() ctx := context.Background() - list, err := sc.List(ctx, "default") + list, err := sc.ListAll(ctx, "default") require.NoError(t, err) assert.Empty(t, list) } @@ -204,7 +204,7 @@ func TestSandbox_List(t *testing.T) { _, _ = sc.Create(ctx, "default", "sb-1", &types.SandboxSpec{}, nil) _, _ = sc.Create(ctx, "default", "sb-2", &types.SandboxSpec{}, nil) - list, err := sc.List(ctx, "default") + list, err := sc.ListAll(ctx, "default") require.NoError(t, err) assert.Len(t, list, 2) } @@ -567,7 +567,7 @@ func TestSandbox_ConcurrentCreateGetDeleteWatch(t *testing.T) { name := fmt.Sprintf("sb-%d-%d", id, j) _, _ = sc.Create(ctx, "default", name, &types.SandboxSpec{LogLevel: "info"}, nil) _, _ = sc.Get(ctx, "default", name) - _, _ = sc.List(ctx, "default") + _, _ = sc.ListAll(ctx, "default") _, _ = sc.WaitReady(ctx, "default", name) _ = sc.Delete(ctx, "default", name) } diff --git a/sdk/go/openshell/v1/fake/service.go b/sdk/go/openshell/v1/fake/service.go index 4b3029012f..e3e08c5796 100644 --- a/sdk/go/openshell/v1/fake/service.go +++ b/sdk/go/openshell/v1/fake/service.go @@ -38,7 +38,7 @@ func (c *fakeServiceClient) Get(_ context.Context, _, _, _ string) (*types.Servi } // List returns Unimplemented. -func (c *fakeServiceClient) List(_ context.Context, _, _ string, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { +func (c *fakeServiceClient) List(_, _ string, _ ...v1.ListOptions) (*v1.Pager[*types.ServiceEndpoint], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } @@ -46,7 +46,7 @@ func (c *fakeServiceClient) List(_ context.Context, _, _ string, _ ...v1.ListOpt } // ListAll returns Unimplemented. -func (c *fakeServiceClient) ListAll(_ context.Context, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { +func (c *fakeServiceClient) ListAll(_ context.Context, _, _ string, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } diff --git a/sdk/go/openshell/v1/fake/service_test.go b/sdk/go/openshell/v1/fake/service_test.go index 3e3a6b7ca8..19e689e07f 100644 --- a/sdk/go/openshell/v1/fake/service_test.go +++ b/sdk/go/openshell/v1/fake/service_test.go @@ -31,7 +31,7 @@ func TestFakeService_Get_ReturnsUnimplemented(t *testing.T) { func TestFakeService_List_ReturnsUnimplemented(t *testing.T) { c := newFakeServiceClient(func() bool { return false }) - _, err := c.List(context.Background(), "default", "sb1") + _, err := c.ListAll(context.Background(), "default", "sb1") require.Error(t, err) assert.True(t, types.IsUnimplemented(err)) } @@ -59,7 +59,7 @@ func TestFakeService_Get_ClosedReturnsUnavailable(t *testing.T) { func TestFakeService_List_ClosedReturnsUnavailable(t *testing.T) { c := newFakeServiceClient(func() bool { return true }) - _, err := c.List(context.Background(), "default", "sb1") + _, err := c.ListAll(context.Background(), "default", "sb1") require.Error(t, err) assert.True(t, types.IsUnavailable(err)) } diff --git a/sdk/go/openshell/v1/fake/workspace.go b/sdk/go/openshell/v1/fake/workspace.go index 026eac43a5..0cf20019a3 100644 --- a/sdk/go/openshell/v1/fake/workspace.go +++ b/sdk/go/openshell/v1/fake/workspace.go @@ -90,12 +90,24 @@ func (c *fakeWorkspaceClient) Get(_ context.Context, name string) (*types.Worksp return c.workspaceStore.Get("", name) } -// List returns all workspaces. ListOptions are accepted for interface compatibility but filtering is not implemented. -func (c *fakeWorkspaceClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Workspace, error) { +// List returns a lazy pager over workspaces. +func (c *fakeWorkspaceClient) List(opts ...v1.ListOptions) (*v1.Pager[*types.Workspace], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } - return c.workspaceStore.ListAll(), nil + var options v1.ListOptions + if len(opts) > 0 { + options = opts[0] + } + return newSlicePager(c.workspaceStore.ListAll(), options.PageSize, options.PageToken) +} + +func (c *fakeWorkspaceClient) ListAll(ctx context.Context, opts ...v1.ListOptions) ([]*types.Workspace, error) { + pager, err := c.List(opts...) + if err != nil { + return nil, err + } + return pager.All(ctx) } // Delete removes a workspace. Unlike the sandbox fake (which treats delete as @@ -161,12 +173,24 @@ func (c *fakeWorkspaceClient) RemoveMember(_ context.Context, workspace, princip } // ListMembers returns all members for the workspace. ListOptions are accepted for interface compatibility but filtering is not implemented. -func (c *fakeWorkspaceClient) ListMembers(_ context.Context, workspace string, _ ...v1.ListOptions) ([]*types.WorkspaceMember, error) { +func (c *fakeWorkspaceClient) ListMembers(workspace string, opts ...v1.ListOptions) (*v1.Pager[*types.WorkspaceMember], error) { if c.closedFunc() { return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} } if workspace == "" { return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace name must not be empty"} } - return c.memberStore.List(workspace), nil + var options v1.ListOptions + if len(opts) > 0 { + options = opts[0] + } + return newSlicePager(c.memberStore.List(workspace), options.PageSize, options.PageToken) +} + +func (c *fakeWorkspaceClient) ListAllMembers(ctx context.Context, workspace string, opts ...v1.ListOptions) ([]*types.WorkspaceMember, error) { + pager, err := c.ListMembers(workspace, opts...) + if err != nil { + return nil, err + } + return pager.All(ctx) } diff --git a/sdk/go/openshell/v1/fake/workspace_test.go b/sdk/go/openshell/v1/fake/workspace_test.go index 32ebf6434a..c9a909ffdc 100644 --- a/sdk/go/openshell/v1/fake/workspace_test.go +++ b/sdk/go/openshell/v1/fake/workspace_test.go @@ -21,7 +21,7 @@ func TestWorkspaceDelete_RemovesMembers(t *testing.T) { require.NoError(t, err) require.NoError(t, fc.Workspaces().Delete(ctx, "team")) - members, err := fc.Workspaces().ListMembers(ctx, "team") + members, err := fc.Workspaces().ListAllMembers(ctx, "team") require.NoError(t, err) assert.Empty(t, members) } @@ -101,14 +101,14 @@ func TestFakeWorkspace_List(t *testing.T) { _, _ = fc.Workspaces().Create(context.Background(), "ws-1", nil) _, _ = fc.Workspaces().Create(context.Background(), "ws-2", nil) - workspaces, err := fc.Workspaces().List(context.Background()) + workspaces, err := fc.Workspaces().ListAll(context.Background()) require.NoError(t, err) assert.Len(t, workspaces, 2) } func TestFakeWorkspace_List_Empty(t *testing.T) { fc := NewClient() - workspaces, err := fc.Workspaces().List(context.Background()) + workspaces, err := fc.Workspaces().ListAll(context.Background()) require.NoError(t, err) assert.Empty(t, workspaces) } @@ -191,7 +191,7 @@ func TestFakeWorkspace_RemoveMember(t *testing.T) { err := fc.Workspaces().RemoveMember(context.Background(), "ws", "user@example.com") require.NoError(t, err) - members, err := fc.Workspaces().ListMembers(context.Background(), "ws") + members, err := fc.Workspaces().ListAllMembers(context.Background(), "ws") require.NoError(t, err) assert.Empty(t, members) } @@ -225,14 +225,14 @@ func TestFakeWorkspace_ListMembers(t *testing.T) { _, _ = fc.Workspaces().AddMember(context.Background(), "ws", "user1@example.com", types.WorkspaceRoleAdmin) _, _ = fc.Workspaces().AddMember(context.Background(), "ws", "user2@example.com", types.WorkspaceRoleUser) - members, err := fc.Workspaces().ListMembers(context.Background(), "ws") + members, err := fc.Workspaces().ListAllMembers(context.Background(), "ws") require.NoError(t, err) assert.Len(t, members, 2) } func TestFakeWorkspace_ListMembers_EmptyWorkspace(t *testing.T) { fc := NewClient() - _, err := fc.Workspaces().ListMembers(context.Background(), "") + _, err := fc.Workspaces().ListAllMembers(context.Background(), "") require.Error(t, err) assert.True(t, types.IsInvalidArgument(err)) @@ -243,12 +243,12 @@ func TestFakeWorkspace_ListMembers_Isolation(t *testing.T) { _, _ = fc.Workspaces().AddMember(context.Background(), "ws-a", "user@example.com", types.WorkspaceRoleAdmin) _, _ = fc.Workspaces().AddMember(context.Background(), "ws-b", "other@example.com", types.WorkspaceRoleUser) - membersA, err := fc.Workspaces().ListMembers(context.Background(), "ws-a") + membersA, err := fc.Workspaces().ListAllMembers(context.Background(), "ws-a") require.NoError(t, err) assert.Len(t, membersA, 1) assert.Equal(t, "user@example.com", membersA[0].PrincipalSubject) - membersB, err := fc.Workspaces().ListMembers(context.Background(), "ws-b") + membersB, err := fc.Workspaces().ListAllMembers(context.Background(), "ws-b") require.NoError(t, err) assert.Len(t, membersB, 1) assert.Equal(t, "other@example.com", membersB[0].PrincipalSubject) @@ -264,7 +264,7 @@ func TestFakeWorkspace_Closed(t *testing.T) { _, err = fc.Workspaces().Get(context.Background(), "ws") assert.True(t, types.IsUnavailable(err)) - _, err = fc.Workspaces().List(context.Background()) + _, err = fc.Workspaces().ListAll(context.Background()) assert.True(t, types.IsUnavailable(err)) err = fc.Workspaces().Delete(context.Background(), "ws") @@ -276,7 +276,7 @@ func TestFakeWorkspace_Closed(t *testing.T) { err = fc.Workspaces().RemoveMember(context.Background(), "ws", "user") assert.True(t, types.IsUnavailable(err)) - _, err = fc.Workspaces().ListMembers(context.Background(), "ws") + _, err = fc.Workspaces().ListAllMembers(context.Background(), "ws") assert.True(t, types.IsUnavailable(err)) } diff --git a/sdk/go/openshell/v1/pager.go b/sdk/go/openshell/v1/pager.go new file mode 100644 index 0000000000..558fda4694 --- /dev/null +++ b/sdk/go/openshell/v1/pager.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" +) + +// Page is one response page from a list operation. +type Page[T any] struct { + Items []T + NextPageToken string +} + +type pageFetcher[T any] func(context.Context, string) (*Page[T], error) + +// Pager lazily fetches one RPC page per call to NextPage. +// +// A Pager is single-pass and must not be used concurrently. +type Pager[T any] struct { + fetch pageFetcher[T] + nextPageToken *string +} + +// NewPager constructs a pager from an RPC page fetcher. +func NewPager[T any](pageToken string, fetch func(context.Context, string) (*Page[T], error)) *Pager[T] { + return &Pager[T]{fetch: fetch, nextPageToken: &pageToken} +} + +func newPager[T any](pageToken string, fetch pageFetcher[T]) *Pager[T] { + return NewPager(pageToken, fetch) +} + +// NextPage fetches the next page. It returns nil after the final page. +func (p *Pager[T]) NextPage(ctx context.Context) (*Page[T], error) { + if p.nextPageToken == nil { + return nil, nil + } + page, err := p.fetch(ctx, *p.nextPageToken) + if err != nil { + return nil, err + } + if page == nil { + return nil, errors.New("pager fetch returned a nil page") + } + if page.Items == nil { + page.Items = make([]T, 0) + } + if page.NextPageToken == "" { + p.nextPageToken = nil + } else { + next := page.NextPageToken + p.nextPageToken = &next + } + return page, nil +} + +// All consumes the pager and collects every remaining item. +func (p *Pager[T]) All(ctx context.Context) ([]T, error) { + items := make([]T, 0) + for { + page, err := p.NextPage(ctx) + if err != nil { + return nil, err + } + if page == nil { + return items, nil + } + items = append(items, page.Items...) + } +} diff --git a/sdk/go/openshell/v1/pager_test.go b/sdk/go/openshell/v1/pager_test.go new file mode 100644 index 0000000000..55dc74eb28 --- /dev/null +++ b/sdk/go/openshell/v1/pager_test.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPagerIsLazyAndFetchesOnePageAtATime(t *testing.T) { + requests := make([]string, 0) + pager := NewPager("resume-token", func(_ context.Context, token string) (*Page[string], error) { + requests = append(requests, token) + if token == "resume-token" { + return &Page[string]{Items: []string{"first"}, NextPageToken: "second-token"}, nil + } + return &Page[string]{Items: []string{"second"}}, nil + }) + + assert.Empty(t, requests) + first, err := pager.NextPage(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{"first"}, first.Items) + assert.Equal(t, "second-token", first.NextPageToken) + assert.Equal(t, []string{"resume-token"}, requests) + + second, err := pager.NextPage(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{"second"}, second.Items) + assert.Equal(t, []string{"resume-token", "second-token"}, requests) + + done, err := pager.NextPage(context.Background()) + require.NoError(t, err) + assert.Nil(t, done) +} + +func TestPagerAllRetriesTheCurrentTokenAfterError(t *testing.T) { + wantErr := errors.New("temporary") + attempts := 0 + pager := NewPager("", func(_ context.Context, token string) (*Page[int], error) { + assert.Empty(t, token) + attempts++ + if attempts == 1 { + return nil, wantErr + } + return &Page[int]{Items: []int{1, 2}}, nil + }) + + _, err := pager.NextPage(context.Background()) + assert.ErrorIs(t, err, wantErr) + items, err := pager.All(context.Background()) + require.NoError(t, err) + assert.Equal(t, []int{1, 2}, items) +} + +func TestPagerNormalizesEmptyItems(t *testing.T) { + pager := NewPager("", func(_ context.Context, _ string) (*Page[string], error) { + return &Page[string]{}, nil + }) + + page, err := pager.NextPage(context.Background()) + require.NoError(t, err) + assert.NotNil(t, page.Items) + assert.Empty(t, page.Items) +} + +func TestPagerRejectsNilPage(t *testing.T) { + pager := NewPager("resume-token", func(_ context.Context, token string) (*Page[string], error) { + assert.Equal(t, "resume-token", token) + return nil, nil + }) + + page, err := pager.NextPage(context.Background()) + assert.Nil(t, page) + assert.EqualError(t, err, "pager fetch returned a nil page") +} diff --git a/sdk/go/openshell/v1/policy.go b/sdk/go/openshell/v1/policy.go index a6025074df..c995eeaf7d 100644 --- a/sdk/go/openshell/v1/policy.go +++ b/sdk/go/openshell/v1/policy.go @@ -87,9 +87,12 @@ var WithVersion = types.WithVersion // ListPolicyOption configures a List call. type ListPolicyOption = types.ListPolicyOption -// WithPageSize sets the page size used while collecting every revision. +// WithPageSize sets the maximum revisions requested per page. var WithPageSize = types.WithPageSize +// WithPageToken resumes listing from an opaque token returned by a previous page. +var WithPageToken = types.WithPageToken + // WithListGlobal enables global policy mode on List. When true, the query // retrieves gateway-global policy revisions instead of sandbox-scoped ones. var WithListGlobal = types.WithListGlobal @@ -108,7 +111,8 @@ type PolicyInterface interface { ClearDraftChunks(ctx context.Context, workspace, sandboxName string) (*ClearResult, error) GetDraftHistory(ctx context.Context, workspace, sandboxName string) ([]DraftHistoryEntry, error) GetStatus(ctx context.Context, workspace, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) - List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) + List(workspace, sandboxName string, opts ...ListPolicyOption) (*Pager[SandboxPolicyRevision], error) + ListAll(ctx context.Context, workspace, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error UndoDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*UndoResult, error) } diff --git a/sdk/go/openshell/v1/policy_client.go b/sdk/go/openshell/v1/policy_client.go index 1dec3fad7e..16f1fe4ec2 100644 --- a/sdk/go/openshell/v1/policy_client.go +++ b/sdk/go/openshell/v1/policy_client.go @@ -129,34 +129,41 @@ func (p *policyClient) GetStatus(ctx context.Context, workspace, sandboxName str return converter.PolicyStatusResultFromProto(resp), nil } -func (p *policyClient) List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) { +func (p *policyClient) List(workspace, sandboxName string, opts ...ListPolicyOption) (*Pager[SandboxPolicyRevision], error) { cfg := types.ApplyListPolicyOptions(opts) if cfg.PageSize() < 0 { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "page size must not be negative"} } - req := &pb.ListSandboxPoliciesRequest{ - PageSize: cfg.PageSize(), - Global: cfg.Global(), + if !cfg.Global() && sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} } - if !cfg.Global() { - req.WorkspaceScope = namedWorkspaceScope(workspace) - } - var result []SandboxPolicyRevision - for { + return newPager(cfg.PageToken(), func(ctx context.Context, pageToken string) (*Page[SandboxPolicyRevision], error) { + req := &pb.ListSandboxPoliciesRequest{ + Name: sandboxName, PageSize: cfg.PageSize(), PageToken: pageToken, Global: cfg.Global(), + } + if !cfg.Global() { + req.WorkspaceScope = namedWorkspaceScope(workspace) + } resp, err := p.client.ListSandboxPolicies(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + result := make([]SandboxPolicyRevision, 0, len(resp.GetRevisions())) for _, revision := range resp.GetRevisions() { if converted := converter.SandboxPolicyRevisionFromProto(revision); converted != nil { result = append(result, *converted) } } - if resp.GetNextPageToken() == "" { - return result, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[SandboxPolicyRevision]{Items: result, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (p *policyClient) ListAll(ctx context.Context, workspace, sandboxName string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) { + pager, err := p.List(workspace, sandboxName, opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } func (p *policyClient) EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error { diff --git a/sdk/go/openshell/v1/policy_client_test.go b/sdk/go/openshell/v1/policy_client_test.go index ebb2491ea3..10e8fd7bb3 100644 --- a/sdk/go/openshell/v1/policy_client_test.go +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -773,7 +773,7 @@ func TestPolicyList(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.ListAll(context.Background(), "default", "sandbox") require.NoError(t, err) require.Len(t, revisions, 2) @@ -781,6 +781,7 @@ func TestPolicyList(t *testing.T) { // Verify request was forwarded (no pagination options). mock.mu.Lock() assert.Equal(t, "default", mock.lastListReq.GetWorkspaceScope().GetWorkspace()) + assert.Equal(t, "sandbox", mock.lastListReq.GetName()) assert.Equal(t, int32(0), mock.lastListReq.GetPageSize()) assert.Empty(t, mock.lastListReq.GetPageToken()) mock.mu.Unlock() @@ -805,7 +806,7 @@ func TestPolicyList_WithPageSize(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default", + revisions, err := client.ListAll(context.Background(), "default", "sandbox", types.WithPageSize(10), ) @@ -826,10 +827,11 @@ func TestPolicyList_Empty(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.ListAll(context.Background(), "default", "sandbox") require.NoError(t, err) - assert.Nil(t, revisions) + assert.NotNil(t, revisions) + assert.Empty(t, revisions) } func TestPolicyList_WithGlobal(t *testing.T) { @@ -844,7 +846,7 @@ func TestPolicyList_WithGlobal(t *testing.T) { defer cleanup() // List with global flag and empty workspace. - revisions, err := client.List(context.Background(), "", types.WithListGlobal(true)) + revisions, err := client.ListAll(context.Background(), "", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 1) @@ -869,7 +871,7 @@ func TestPolicyList_WithGlobalIgnoresWorkspace(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "some-workspace", types.WithListGlobal(true)) + revisions, err := client.ListAll(context.Background(), "some-workspace", "", types.WithListGlobal(true)) require.NoError(t, err) require.Len(t, revisions, 1) @@ -892,7 +894,7 @@ func TestPolicyList_WithGlobalAndPagination(t *testing.T) { defer cleanup() // Global flag composes with page-size options. - revisions, err := client.List(context.Background(), "", + revisions, err := client.ListAll(context.Background(), "", "", types.WithListGlobal(true), types.WithPageSize(10), ) @@ -918,7 +920,7 @@ func TestPolicyList_WithoutGlobal_PreservesExistingBehavior(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.ListAll(context.Background(), "default", "sandbox") require.NoError(t, err) require.Len(t, revisions, 1) @@ -937,7 +939,7 @@ func TestPolicyList_Error(t *testing.T) { client, cleanup := setupPolicyTest(t, mock) defer cleanup() - revisions, err := client.List(context.Background(), "default") + revisions, err := client.ListAll(context.Background(), "default", "sandbox") assert.Nil(t, revisions) require.Error(t, err) diff --git a/sdk/go/openshell/v1/profile.go b/sdk/go/openshell/v1/profile.go index c0518bc95a..4460bfcb96 100644 --- a/sdk/go/openshell/v1/profile.go +++ b/sdk/go/openshell/v1/profile.go @@ -55,7 +55,8 @@ const ( // ProfileInterface defines operations for managing provider profiles. type ProfileInterface interface { - List(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error) + List(workspace string, opts ...ListOptions) (*Pager[*ProviderProfile], error) + ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error) Get(ctx context.Context, workspace, id string) (*ProviderProfile, error) Import(ctx context.Context, workspace string, items []ProfileImportItem) (*ImportResult, error) Update(ctx context.Context, workspace, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) diff --git a/sdk/go/openshell/v1/profile_client.go b/sdk/go/openshell/v1/profile_client.go index 11808c2faa..f1eb66707d 100644 --- a/sdk/go/openshell/v1/profile_client.go +++ b/sdk/go/openshell/v1/profile_client.go @@ -19,29 +19,35 @@ func newProfileClient(conn grpc.ClientConnInterface) *profileClient { return &profileClient{client: pb.NewOpenShellClient(conn)} } -func (p *profileClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error) { +func (p *profileClient) List(workspace string, opts ...ListOptions) (*Pager[*ProviderProfile], error) { pageSize, err := listPageSize(opts) if err != nil { return nil, err } - req := &pb.ListProviderProfilesRequest{ - Workspace: workspace, - PageSize: pageSize, + var pageToken string + if len(opts) > 0 { + pageToken = opts[0].PageToken } - profiles := make([]*ProviderProfile, 0) - for { + return newPager(pageToken, func(ctx context.Context, pageToken string) (*Page[*ProviderProfile], error) { + req := &pb.ListProviderProfilesRequest{Workspace: workspace, PageSize: pageSize, PageToken: pageToken} resp, err := p.client.ListProviderProfiles(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + profiles := make([]*ProviderProfile, 0, len(resp.GetProfiles())) for _, profile := range resp.GetProfiles() { profiles = append(profiles, converter.ProviderProfileFromProto(profile)) } - if resp.GetNextPageToken() == "" { - return profiles, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[*ProviderProfile]{Items: profiles, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (p *profileClient) ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error) { + pager, err := p.List(workspace, opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } func (p *profileClient) Get(ctx context.Context, workspace, id string) (*ProviderProfile, error) { diff --git a/sdk/go/openshell/v1/profile_client_test.go b/sdk/go/openshell/v1/profile_client_test.go index 979fe5bbad..86f7898313 100644 --- a/sdk/go/openshell/v1/profile_client_test.go +++ b/sdk/go/openshell/v1/profile_client_test.go @@ -219,7 +219,7 @@ func TestProfileList(t *testing.T) { client, cleanup := setupProfileTest(t, mock) defer cleanup() - profiles, err := client.List(context.Background(), "default") + profiles, err := client.ListAll(context.Background(), "default") require.NoError(t, err) assert.Len(t, profiles, 2) @@ -230,7 +230,7 @@ func TestProfileList_Empty(t *testing.T) { client, cleanup := setupProfileTest(t, mock) defer cleanup() - profiles, err := client.List(context.Background(), "default") + profiles, err := client.ListAll(context.Background(), "default") require.NoError(t, err) assert.NotNil(t, profiles) @@ -243,7 +243,7 @@ func TestProfileList_WithOptions(t *testing.T) { client, cleanup := setupProfileTest(t, mock) defer cleanup() - profiles, err := client.List(context.Background(), "default", ListOptions{PageSize: 10}) + profiles, err := client.ListAll(context.Background(), "default", ListOptions{PageSize: 10}) require.NoError(t, err) assert.Len(t, profiles, 1) @@ -258,7 +258,7 @@ func TestProfileList_Error(t *testing.T) { client, cleanup := setupProfileTest(t, mock) defer cleanup() - profiles, err := client.List(context.Background(), "default") + profiles, err := client.ListAll(context.Background(), "default") assert.Nil(t, profiles) require.Error(t, err) diff --git a/sdk/go/openshell/v1/provider.go b/sdk/go/openshell/v1/provider.go index 42788f8855..99c0bd731c 100644 --- a/sdk/go/openshell/v1/provider.go +++ b/sdk/go/openshell/v1/provider.go @@ -20,8 +20,8 @@ type ProviderSpec = types.ProviderSpec type ProviderInterface interface { Create(ctx context.Context, workspace string, provider *Provider) (*Provider, error) Get(ctx context.Context, workspace, name string) (*Provider, error) - List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error) - ListAll(ctx context.Context, opts ...ListOptions) ([]*Provider, error) + List(workspace string, opts ...ListOptions) (*Pager[*Provider], error) + ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error) Update(ctx context.Context, workspace string, provider *Provider) (*Provider, error) Delete(ctx context.Context, workspace, name string) error Ensure(ctx context.Context, workspace string, provider *Provider) (*Provider, error) diff --git a/sdk/go/openshell/v1/provider_client.go b/sdk/go/openshell/v1/provider_client.go index fdfb4c0bc9..5d991052fd 100644 --- a/sdk/go/openshell/v1/provider_client.go +++ b/sdk/go/openshell/v1/provider_client.go @@ -55,38 +55,41 @@ func (p *providerClient) Get(ctx context.Context, workspace, name string) (*Prov return converter.ProviderFromProto(resp.GetProvider()), nil } -func (p *providerClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error) { - req := &pb.ListProvidersRequest{ - WorkspaceScope: namedWorkspaceScope(workspace), - } - return p.list(ctx, req, opts...) -} - -func (p *providerClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*Provider, error) { - return p.list(ctx, &pb.ListProvidersRequest{WorkspaceScope: allWorkspacesScope()}, opts...) -} - -func (p *providerClient) list(ctx context.Context, req *pb.ListProvidersRequest, opts ...ListOptions) ([]*Provider, error) { +func (p *providerClient) List(workspace string, opts ...ListOptions) (*Pager[*Provider], error) { pageSize, err := listPageSize(opts) if err != nil { return nil, err } - req.PageSize = pageSize - - providers := make([]*Provider, 0) - for { + var pageToken string + var allWorkspaces bool + if len(opts) > 0 { + pageToken = opts[0].PageToken + allWorkspaces = opts[0].AllWorkspaces + } + workspaceScope := namedWorkspaceScope(workspace) + if allWorkspaces { + workspaceScope = allWorkspacesScope() + } + return newPager(pageToken, func(ctx context.Context, pageToken string) (*Page[*Provider], error) { + req := &pb.ListProvidersRequest{WorkspaceScope: workspaceScope, PageSize: pageSize, PageToken: pageToken} resp, err := p.client.ListProviders(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + providers := make([]*Provider, 0, len(resp.GetProviders())) for _, proto := range resp.GetProviders() { providers = append(providers, converter.ProviderFromProto(proto)) } - if resp.GetNextPageToken() == "" { - return providers, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[*Provider]{Items: providers, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (p *providerClient) ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error) { + pager, err := p.List(workspace, opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } func (p *providerClient) Update(ctx context.Context, workspace string, provider *Provider) (*Provider, error) { diff --git a/sdk/go/openshell/v1/provider_client_test.go b/sdk/go/openshell/v1/provider_client_test.go index 51830b7881..251799502c 100644 --- a/sdk/go/openshell/v1/provider_client_test.go +++ b/sdk/go/openshell/v1/provider_client_test.go @@ -22,6 +22,7 @@ import ( type mockProviderServer struct { pb.UnimplementedOpenShellServer providers map[string]*dm.Provider + lastList *pb.ListProvidersRequest createErr error getErr error listErr error @@ -57,7 +58,8 @@ func (s *mockProviderServer) GetProvider(_ context.Context, req *pb.GetProviderR return &pb.ProviderResponse{Provider: p}, nil } -func (s *mockProviderServer) ListProviders(_ context.Context, _ *pb.ListProvidersRequest) (*pb.ListProvidersResponse, error) { +func (s *mockProviderServer) ListProviders(_ context.Context, req *pb.ListProvidersRequest) (*pb.ListProvidersResponse, error) { + s.lastList = req if s.listErr != nil { return nil, s.listErr } @@ -188,7 +190,7 @@ func TestProviderList(t *testing.T) { client, cleanup := setupProviderTest(t, mock) defer cleanup() - result, err := client.List(context.Background(), "default") + result, err := client.ListAll(context.Background(), "default") require.NoError(t, err) assert.Len(t, result, 2) @@ -199,13 +201,30 @@ func TestProviderList_Empty(t *testing.T) { client, cleanup := setupProviderTest(t, mock) defer cleanup() - result, err := client.List(context.Background(), "default") + result, err := client.ListAll(context.Background(), "default") require.NoError(t, err) assert.NotNil(t, result) assert.Empty(t, result) } +func TestProviderListAll_SelectsAllWorkspaces(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + providers, err := client.ListAll(context.Background(), "", ListOptions{ + PageSize: 10, + AllWorkspaces: true, + }) + + require.NoError(t, err) + assert.Empty(t, providers) + require.NotNil(t, mock.lastList) + assert.Equal(t, int32(10), mock.lastList.GetPageSize()) + assert.NotNil(t, mock.lastList.GetWorkspaceScope().GetAllWorkspaces()) +} + func TestProviderUpdate(t *testing.T) { mock := newMockProviderServer() mock.providers["updatable"] = &dm.Provider{ diff --git a/sdk/go/openshell/v1/sandbox.go b/sdk/go/openshell/v1/sandbox.go index 59ab37ee91..1fb166825d 100644 --- a/sdk/go/openshell/v1/sandbox.go +++ b/sdk/go/openshell/v1/sandbox.go @@ -55,8 +55,8 @@ var WithLogMinLevel = types.WithLogMinLevel type SandboxInterface interface { Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) Get(ctx context.Context, workspace, name string) (*Sandbox, error) - List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) - ListAll(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) + List(workspace string, opts ...ListOptions) (*Pager[*Sandbox], error) + ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) Stop(ctx context.Context, workspace, name string) (*Sandbox, error) Start(ctx context.Context, workspace, name string) (*Sandbox, error) Delete(ctx context.Context, workspace, name string) error diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index 187c826d8b..0760d4469b 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -98,41 +98,47 @@ func (s *sandboxClient) Get(ctx context.Context, workspace, name string) (*Sandb return converter.SandboxFromProto(resp.GetSandbox()), nil } -func (s *sandboxClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) { - req := &pb.ListSandboxesRequest{ - WorkspaceScope: namedWorkspaceScope(workspace), - } - return s.list(ctx, req, opts...) -} - -func (s *sandboxClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*Sandbox, error) { - return s.list(ctx, &pb.ListSandboxesRequest{WorkspaceScope: allWorkspacesScope()}, opts...) -} - -func (s *sandboxClient) list(ctx context.Context, req *pb.ListSandboxesRequest, opts ...ListOptions) ([]*Sandbox, error) { +func (s *sandboxClient) List(workspace string, opts ...ListOptions) (*Pager[*Sandbox], error) { pageSize, err := listPageSize(opts) if err != nil { return nil, err } - req.PageSize = pageSize + var pageToken, labelSelector string + var allWorkspaces bool if len(opts) > 0 { - req.LabelSelector = opts[0].LabelSelector - } - - sandboxes := make([]*Sandbox, 0) - for { + pageToken = opts[0].PageToken + labelSelector = opts[0].LabelSelector + allWorkspaces = opts[0].AllWorkspaces + } + workspaceScope := namedWorkspaceScope(workspace) + if allWorkspaces { + workspaceScope = allWorkspacesScope() + } + return newPager(pageToken, func(ctx context.Context, pageToken string) (*Page[*Sandbox], error) { + req := &pb.ListSandboxesRequest{ + WorkspaceScope: workspaceScope, + PageSize: pageSize, + PageToken: pageToken, + LabelSelector: labelSelector, + } resp, err := s.client.ListSandboxes(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + sandboxes := make([]*Sandbox, 0, len(resp.GetSandboxes())) for _, proto := range resp.GetSandboxes() { sandboxes = append(sandboxes, converter.SandboxFromProto(proto)) } - if resp.GetNextPageToken() == "" { - return sandboxes, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[*Sandbox]{Items: sandboxes, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (s *sandboxClient) ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) { + pager, err := s.List(workspace, opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } func (s *sandboxClient) Delete(ctx context.Context, workspace, name string) error { diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go index 53429c6d1b..0494249775 100644 --- a/sdk/go/openshell/v1/sandbox_client_test.go +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -419,7 +419,7 @@ func TestSandboxList(t *testing.T) { client, cleanup := setupSandboxTest(t, mock) defer cleanup() - result, err := client.List(context.Background(), "default") + result, err := client.ListAll(context.Background(), "default") require.NoError(t, err) assert.Len(t, result, 2) @@ -430,13 +430,30 @@ func TestSandboxList_Empty(t *testing.T) { client, cleanup := setupSandboxTest(t, mock) defer cleanup() - result, err := client.List(context.Background(), "default") + result, err := client.ListAll(context.Background(), "default") require.NoError(t, err) assert.NotNil(t, result) assert.Empty(t, result) } +func TestSandboxListAll_SelectsAllWorkspaces(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + sandboxes, err := client.ListAll(context.Background(), "", ListOptions{ + PageSize: 10, + AllWorkspaces: true, + }) + + require.NoError(t, err) + assert.Empty(t, sandboxes) + require.Len(t, mock.listRequests, 1) + assert.Equal(t, int32(10), mock.listRequests[0].GetPageSize()) + assert.NotNil(t, mock.listRequests[0].GetWorkspaceScope().GetAllWorkspaces()) +} + func TestSandboxList_WithOptions(t *testing.T) { mock := newMockSandboxServer() mock.sandboxes["sb1"] = &pb.Sandbox{ @@ -446,7 +463,7 @@ func TestSandboxList_WithOptions(t *testing.T) { client, cleanup := setupSandboxTest(t, mock) defer cleanup() - result, err := client.List(context.Background(), "default", ListOptions{PageSize: 10}) + result, err := client.ListAll(context.Background(), "default", ListOptions{PageSize: 10}) require.NoError(t, err) assert.Len(t, result, 1) @@ -461,7 +478,7 @@ func TestSandboxList_FollowsContinuationTokens(t *testing.T) { client, cleanup := setupSandboxTest(t, mock) defer cleanup() - result, err := client.List(context.Background(), "default", ListOptions{ + result, err := client.ListAll(context.Background(), "default", ListOptions{ PageSize: 1, LabelSelector: "team=core", }) diff --git a/sdk/go/openshell/v1/sandbox_template.go b/sdk/go/openshell/v1/sandbox_template.go index 395b254bac..ed635177d1 100644 --- a/sdk/go/openshell/v1/sandbox_template.go +++ b/sdk/go/openshell/v1/sandbox_template.go @@ -40,7 +40,7 @@ type SandboxWorkloadTemplateProvenance = types.SandboxWorkloadTemplateProvenance type SandboxTemplateInterface interface { Create(ctx context.Context, workspace string, template *SandboxWorkloadTemplate) (*SandboxWorkloadTemplate, error) Get(ctx context.Context, workspace, name string) (*SandboxWorkloadTemplate, error) - List(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) - ListAll(ctx context.Context, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) + List(workspace string, opts ...ListOptions) (*Pager[*SandboxWorkloadTemplate], error) + ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) Delete(ctx context.Context, workspace, name string) (bool, error) } diff --git a/sdk/go/openshell/v1/sandbox_template_client.go b/sdk/go/openshell/v1/sandbox_template_client.go index 3b14e36e66..511b6f9952 100644 --- a/sdk/go/openshell/v1/sandbox_template_client.go +++ b/sdk/go/openshell/v1/sandbox_template_client.go @@ -50,41 +50,45 @@ func (s *sandboxTemplateClient) Get(ctx context.Context, workspace, name string) return converter.SandboxWorkloadTemplateFromProto(resp.GetTemplate()), nil } -func (s *sandboxTemplateClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { - req := &pb.ListSandboxTemplatesRequest{ - WorkspaceScope: namedWorkspaceScope(workspace), - } - return s.list(ctx, req, opts...) -} - -func (s *sandboxTemplateClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { - return s.list(ctx, &pb.ListSandboxTemplatesRequest{WorkspaceScope: allWorkspacesScope()}, opts...) -} - -func (s *sandboxTemplateClient) list(ctx context.Context, req *pb.ListSandboxTemplatesRequest, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { +func (s *sandboxTemplateClient) List(workspace string, opts ...ListOptions) (*Pager[*SandboxWorkloadTemplate], error) { pageSize, err := listPageSize(opts) if err != nil { return nil, err } - req.PageSize = pageSize + var pageToken, labelSelector string + var allWorkspaces bool if len(opts) > 0 { - req.LabelSelector = opts[0].LabelSelector + pageToken = opts[0].PageToken + labelSelector = opts[0].LabelSelector + allWorkspaces = opts[0].AllWorkspaces } - - templates := make([]*SandboxWorkloadTemplate, 0) - for { + workspaceScope := namedWorkspaceScope(workspace) + if allWorkspaces { + workspaceScope = allWorkspacesScope() + } + return newPager(pageToken, func(ctx context.Context, pageToken string) (*Page[*SandboxWorkloadTemplate], error) { + req := &pb.ListSandboxTemplatesRequest{ + WorkspaceScope: workspaceScope, PageSize: pageSize, PageToken: pageToken, + LabelSelector: labelSelector, + } resp, err := s.client.ListSandboxTemplates(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + templates := make([]*SandboxWorkloadTemplate, 0, len(resp.GetTemplates())) for _, protoTemplate := range resp.GetTemplates() { templates = append(templates, converter.SandboxWorkloadTemplateFromProto(protoTemplate)) } - if resp.GetNextPageToken() == "" { - return templates, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[*SandboxWorkloadTemplate]{Items: templates, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (s *sandboxTemplateClient) ListAll(ctx context.Context, workspace string, opts ...ListOptions) ([]*SandboxWorkloadTemplate, error) { + pager, err := s.List(workspace, opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } func (s *sandboxTemplateClient) Delete(ctx context.Context, workspace, name string) (bool, error) { diff --git a/sdk/go/openshell/v1/sandbox_template_client_test.go b/sdk/go/openshell/v1/sandbox_template_client_test.go index 2f63c6c042..115d31f097 100644 --- a/sdk/go/openshell/v1/sandbox_template_client_test.go +++ b/sdk/go/openshell/v1/sandbox_template_client_test.go @@ -220,7 +220,7 @@ func TestSandboxTemplateGetListDelete(t *testing.T) { assert.Equal(t, "gpu-kata", got.Name) assert.Equal(t, "img:v1", got.Spec.Workload.Image) - list, err := client.ListAll(context.Background(), ListOptions{ + list, err := client.ListAll(context.Background(), "default", ListOptions{ PageSize: 10, LabelSelector: "team=runtime", }) @@ -238,7 +238,7 @@ func TestSandboxTemplateGetListDelete(t *testing.T) { assert.Equal(t, "default", mock.getRequest.GetWorkspaceScope().GetWorkspace()) assert.Equal(t, "gpu-kata", mock.getRequest.Name) require.NotNil(t, mock.listRequest) - assert.NotNil(t, mock.listRequest.GetWorkspaceScope().GetAllWorkspaces()) + assert.Equal(t, "default", mock.listRequest.GetWorkspaceScope().GetWorkspace()) assert.Equal(t, int32(10), mock.listRequest.PageSize) assert.Empty(t, mock.listRequest.PageToken) assert.Equal(t, "team=runtime", mock.listRequest.LabelSelector) @@ -252,7 +252,7 @@ func TestSandboxTemplateList_RejectsNegativePagination(t *testing.T) { client, cleanup := setupSandboxTemplateTest(t, mock) defer cleanup() - _, err := client.List(context.Background(), "default", ListOptions{PageSize: -1}) + _, err := client.ListAll(context.Background(), "default", ListOptions{PageSize: -1}) require.Error(t, err) assert.True(t, IsInvalidArgument(err)) } @@ -262,7 +262,7 @@ func TestSandboxTemplateList_EmptyReturnsNonNilSlice(t *testing.T) { client, cleanup := setupSandboxTemplateTest(t, mock) defer cleanup() - templates, err := client.List(context.Background(), "default") + templates, err := client.ListAll(context.Background(), "default") require.NoError(t, err) assert.NotNil(t, templates) diff --git a/sdk/go/openshell/v1/service.go b/sdk/go/openshell/v1/service.go index acdcd1eeb6..44a316be3e 100644 --- a/sdk/go/openshell/v1/service.go +++ b/sdk/go/openshell/v1/service.go @@ -16,7 +16,7 @@ type ServiceEndpoint = types.ServiceEndpoint type ServiceInterface interface { Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error) - List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) - ListAll(ctx context.Context, opts ...ListOptions) ([]*ServiceEndpoint, error) + List(workspace, sandboxName string, opts ...ListOptions) (*Pager[*ServiceEndpoint], error) + ListAll(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) Delete(ctx context.Context, workspace, sandboxName, serviceName string) error } diff --git a/sdk/go/openshell/v1/service_client.go b/sdk/go/openshell/v1/service_client.go index 3ab60d2da7..fcca16936b 100644 --- a/sdk/go/openshell/v1/service_client.go +++ b/sdk/go/openshell/v1/service_client.go @@ -45,39 +45,44 @@ func (s *serviceClient) Get(ctx context.Context, workspace, sandboxName, service return converter.ServiceEndpointFromProto(resp), nil } -func (s *serviceClient) List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) { - req := &pb.ListServicesRequest{ - Sandbox: sandboxName, - WorkspaceScope: namedWorkspaceScope(workspace), - } - return s.list(ctx, req, opts...) -} - -func (s *serviceClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*ServiceEndpoint, error) { - return s.list(ctx, &pb.ListServicesRequest{WorkspaceScope: allWorkspacesScope()}, opts...) -} - -func (s *serviceClient) list(ctx context.Context, req *pb.ListServicesRequest, opts ...ListOptions) ([]*ServiceEndpoint, error) { +func (s *serviceClient) List(workspace, sandboxName string, opts ...ListOptions) (*Pager[*ServiceEndpoint], error) { pageSize, err := listPageSize(opts) if err != nil { return nil, err } - req.PageSize = pageSize - - endpoints := make([]*ServiceEndpoint, 0) - for { + var pageToken string + var allWorkspaces bool + if len(opts) > 0 { + pageToken = opts[0].PageToken + allWorkspaces = opts[0].AllWorkspaces + } + workspaceScope := namedWorkspaceScope(workspace) + if allWorkspaces { + workspaceScope = allWorkspacesScope() + } + return newPager(pageToken, func(ctx context.Context, pageToken string) (*Page[*ServiceEndpoint], error) { + req := &pb.ListServicesRequest{ + Sandbox: sandboxName, WorkspaceScope: workspaceScope, PageSize: pageSize, + PageToken: pageToken, + } resp, err := s.client.ListServices(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + endpoints := make([]*ServiceEndpoint, 0, len(resp.GetServices())) for _, svc := range resp.GetServices() { endpoints = append(endpoints, converter.ServiceEndpointFromProto(svc)) } - if resp.GetNextPageToken() == "" { - return endpoints, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[*ServiceEndpoint]{Items: endpoints, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (s *serviceClient) ListAll(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) { + pager, err := s.List(workspace, sandboxName, opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } func (s *serviceClient) Delete(ctx context.Context, workspace, sandboxName, serviceName string) error { diff --git a/sdk/go/openshell/v1/service_client_test.go b/sdk/go/openshell/v1/service_client_test.go index ace6a25099..ade5de5af4 100644 --- a/sdk/go/openshell/v1/service_client_test.go +++ b/sdk/go/openshell/v1/service_client_test.go @@ -237,7 +237,7 @@ func TestServiceList(t *testing.T) { _, err = client.Expose(context.Background(), "default", "web-app", "web", 3000, false) require.NoError(t, err) - endpoints, err := client.List(context.Background(), "default", "web-app") + endpoints, err := client.ListAll(context.Background(), "default", "web-app") require.NoError(t, err) assert.Len(t, endpoints, 2) @@ -248,7 +248,7 @@ func TestServiceList_Empty(t *testing.T) { client, cleanup := setupServiceTest(t, mock) defer cleanup() - endpoints, err := client.List(context.Background(), "default", "web-app") + endpoints, err := client.ListAll(context.Background(), "default", "web-app") require.NoError(t, err) assert.NotNil(t, endpoints) @@ -263,7 +263,7 @@ func TestServiceList_WithOptions(t *testing.T) { _, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true) require.NoError(t, err) - endpoints, err := client.List(context.Background(), "default", "web-app", ListOptions{PageSize: 10}) + endpoints, err := client.ListAll(context.Background(), "default", "web-app", ListOptions{PageSize: 10}) require.NoError(t, err) assert.Len(t, endpoints, 1) @@ -274,7 +274,10 @@ func TestServiceListAll_SelectsAllWorkspaces(t *testing.T) { client, cleanup := setupServiceTest(t, mock) defer cleanup() - endpoints, err := client.ListAll(context.Background(), ListOptions{PageSize: 10}) + endpoints, err := client.ListAll(context.Background(), "", "", ListOptions{ + PageSize: 10, + AllWorkspaces: true, + }) require.NoError(t, err) assert.Empty(t, endpoints) @@ -289,7 +292,7 @@ func TestServiceList_Error(t *testing.T) { client, cleanup := setupServiceTest(t, mock) defer cleanup() - endpoints, err := client.List(context.Background(), "default", "web-app") + endpoints, err := client.ListAll(context.Background(), "default", "web-app") assert.Nil(t, endpoints) require.Error(t, err) diff --git a/sdk/go/openshell/v1/ssh_client_test.go b/sdk/go/openshell/v1/ssh_client_test.go index f1d0516ca1..d06edb115b 100644 --- a/sdk/go/openshell/v1/ssh_client_test.go +++ b/sdk/go/openshell/v1/ssh_client_test.go @@ -149,10 +149,12 @@ func (m *mockSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox, return sb, nil } -func (m *mockSandboxResolver) List(_ context.Context, _ string, _ ...ListOptions) ([]*Sandbox, error) { - return nil, nil +func (m *mockSandboxResolver) List(_ string, _ ...ListOptions) (*Pager[*Sandbox], error) { + return NewPager("", func(_ context.Context, _ string) (*Page[*Sandbox], error) { + return &Page[*Sandbox]{Items: make([]*Sandbox, 0)}, nil + }), nil } -func (m *mockSandboxResolver) ListAll(_ context.Context, _ ...ListOptions) ([]*Sandbox, error) { +func (m *mockSandboxResolver) ListAll(_ context.Context, _ string, _ ...ListOptions) ([]*Sandbox, error) { return nil, nil } func (m *mockSandboxResolver) Delete(_ context.Context, _, _ string) error { return nil } diff --git a/sdk/go/openshell/v1/tcp_client_test.go b/sdk/go/openshell/v1/tcp_client_test.go index 100a746ccb..4ea96dbbab 100644 --- a/sdk/go/openshell/v1/tcp_client_test.go +++ b/sdk/go/openshell/v1/tcp_client_test.go @@ -996,10 +996,10 @@ func (r *flippableResolver) Get(_ context.Context, _, name string) (*Sandbox, er func (r *flippableResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) { panic("not implemented") } -func (r *flippableResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { +func (r *flippableResolver) List(string, ...ListOptions) (*Pager[*Sandbox], error) { panic("not implemented") } -func (r *flippableResolver) ListAll(context.Context, ...ListOptions) ([]*Sandbox, error) { +func (r *flippableResolver) ListAll(context.Context, string, ...ListOptions) ([]*Sandbox, error) { panic("not implemented") } func (r *flippableResolver) Delete(context.Context, string, string) error { diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go index 8f220248ef..def5ca0fff 100644 --- a/sdk/go/openshell/v1/types/options.go +++ b/sdk/go/openshell/v1/types/options.go @@ -12,8 +12,13 @@ type CreateOptions struct { // ListOptions configures resource listing with pagination and filtering. type ListOptions struct { - PageSize int + // PageSize is the maximum number of resources requested per RPC. + PageSize int + // PageToken resumes after a page returned by the same list query. + PageToken string LabelSelector string + // AllWorkspaces selects a platform-admin view across workspace boundaries. + AllWorkspaces bool } // WatchOptions configures watch behavior. diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index 858cc2c162..8fb0712b60 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -363,20 +363,28 @@ func (c *getStatusConfig) Global() bool { // listPolicyConfig holds configuration for List calls. type listPolicyConfig struct { - pageSize int32 - global bool + pageSize int32 + pageToken string + global bool } // ListPolicyOption configures a List call. type ListPolicyOption func(*listPolicyConfig) -// WithPageSize sets the page size used while collecting every revision. +// WithPageSize sets the maximum revisions requested per page. func WithPageSize(pageSize int32) ListPolicyOption { return func(c *listPolicyConfig) { c.pageSize = pageSize } } +// WithPageToken resumes listing from an opaque token returned by a previous page. +func WithPageToken(pageToken string) ListPolicyOption { + return func(c *listPolicyConfig) { + c.pageToken = pageToken + } +} + // WithListGlobal enables global policy mode on List. When true, the query // retrieves gateway-global policy revisions instead of sandbox-scoped ones, // and the workspace parameter is ignored. @@ -398,6 +406,11 @@ func (c *listPolicyConfig) PageSize() int32 { return c.pageSize } +// PageToken returns the configured initial continuation token. +func (c *listPolicyConfig) PageToken() string { + return c.pageToken +} + // Global returns whether global policy mode is enabled. func (c *listPolicyConfig) Global() bool { return c.global diff --git a/sdk/go/openshell/v1/workspace.go b/sdk/go/openshell/v1/workspace.go index 15f46ef4e0..cff33185f8 100644 --- a/sdk/go/openshell/v1/workspace.go +++ b/sdk/go/openshell/v1/workspace.go @@ -39,9 +39,11 @@ const ( type WorkspaceInterface interface { Create(ctx context.Context, name string, labels map[string]string) (*Workspace, error) Get(ctx context.Context, name string) (*Workspace, error) - List(ctx context.Context, opts ...ListOptions) ([]*Workspace, error) + List(opts ...ListOptions) (*Pager[*Workspace], error) + ListAll(ctx context.Context, opts ...ListOptions) ([]*Workspace, error) Delete(ctx context.Context, name string) error AddMember(ctx context.Context, workspace, principalSubject string, role WorkspaceRole) (*WorkspaceMember, error) RemoveMember(ctx context.Context, workspace, principalSubject string) error - ListMembers(ctx context.Context, workspace string, opts ...ListOptions) ([]*WorkspaceMember, error) + ListMembers(workspace string, opts ...ListOptions) (*Pager[*WorkspaceMember], error) + ListAllMembers(ctx context.Context, workspace string, opts ...ListOptions) ([]*WorkspaceMember, error) } diff --git a/sdk/go/openshell/v1/workspace_client.go b/sdk/go/openshell/v1/workspace_client.go index 852c32969b..e17f268643 100644 --- a/sdk/go/openshell/v1/workspace_client.go +++ b/sdk/go/openshell/v1/workspace_client.go @@ -48,30 +48,36 @@ func (w *workspaceClient) Get(ctx context.Context, name string) (*Workspace, err return converter.WorkspaceFromProto(resp.GetWorkspace()), nil } -func (w *workspaceClient) List(ctx context.Context, opts ...ListOptions) ([]*Workspace, error) { +func (w *workspaceClient) List(opts ...ListOptions) (*Pager[*Workspace], error) { pageSize, err := listPageSize(opts) if err != nil { return nil, err } - req := &pb.ListWorkspacesRequest{PageSize: pageSize} + var pageToken, labelSelector string if len(opts) > 0 { - req.LabelSelector = opts[0].LabelSelector + pageToken = opts[0].PageToken + labelSelector = opts[0].LabelSelector } - - workspaces := make([]*Workspace, 0) - for { + return newPager(pageToken, func(ctx context.Context, pageToken string) (*Page[*Workspace], error) { + req := &pb.ListWorkspacesRequest{PageSize: pageSize, PageToken: pageToken, LabelSelector: labelSelector} resp, err := w.client.ListWorkspaces(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + workspaces := make([]*Workspace, 0, len(resp.GetWorkspaces())) for _, proto := range resp.GetWorkspaces() { workspaces = append(workspaces, converter.WorkspaceFromProto(proto)) } - if resp.GetNextPageToken() == "" { - return workspaces, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[*Workspace]{Items: workspaces, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (w *workspaceClient) ListAll(ctx context.Context, opts ...ListOptions) ([]*Workspace, error) { + pager, err := w.List(opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } func (w *workspaceClient) Delete(ctx context.Context, name string) error { @@ -130,7 +136,7 @@ func (w *workspaceClient) RemoveMember(ctx context.Context, workspace, principal return nil } -func (w *workspaceClient) ListMembers(ctx context.Context, workspace string, opts ...ListOptions) ([]*WorkspaceMember, error) { +func (w *workspaceClient) ListMembers(workspace string, opts ...ListOptions) (*Pager[*WorkspaceMember], error) { if workspace == "" { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "workspace name must not be empty"} } @@ -139,22 +145,28 @@ func (w *workspaceClient) ListMembers(ctx context.Context, workspace string, opt if err != nil { return nil, err } - req := &pb.ListWorkspaceMembersRequest{ - Workspace: workspace, - PageSize: pageSize, + var pageToken string + if len(opts) > 0 { + pageToken = opts[0].PageToken } - members := make([]*WorkspaceMember, 0) - for { + return newPager(pageToken, func(ctx context.Context, pageToken string) (*Page[*WorkspaceMember], error) { + req := &pb.ListWorkspaceMembersRequest{Workspace: workspace, PageSize: pageSize, PageToken: pageToken} resp, err := w.client.ListWorkspaceMembers(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } + members := make([]*WorkspaceMember, 0, len(resp.GetMembers())) for _, proto := range resp.GetMembers() { members = append(members, converter.WorkspaceMemberFromProto(proto)) } - if resp.GetNextPageToken() == "" { - return members, nil - } - req.PageToken = resp.GetNextPageToken() + return &Page[*WorkspaceMember]{Items: members, NextPageToken: resp.GetNextPageToken()}, nil + }), nil +} + +func (w *workspaceClient) ListAllMembers(ctx context.Context, workspace string, opts ...ListOptions) ([]*WorkspaceMember, error) { + pager, err := w.ListMembers(workspace, opts...) + if err != nil { + return nil, err } + return pager.All(ctx) } diff --git a/sdk/go/openshell/v1/workspace_test.go b/sdk/go/openshell/v1/workspace_test.go index 743a9be0eb..37dde6eba4 100644 --- a/sdk/go/openshell/v1/workspace_test.go +++ b/sdk/go/openshell/v1/workspace_test.go @@ -223,7 +223,7 @@ func TestWorkspaceList_Success(t *testing.T) { defer cleanup() wc := newWorkspaceClient(conn) - workspaces, err := wc.List(context.Background()) + workspaces, err := wc.ListAll(context.Background()) require.NoError(t, err) require.Len(t, workspaces, 1) @@ -238,7 +238,7 @@ func TestWorkspaceList_WithOptions(t *testing.T) { defer cleanup() wc := newWorkspaceClient(conn) - _, err := wc.List(context.Background(), ListOptions{ + _, err := wc.ListAll(context.Background(), ListOptions{ PageSize: 10, LabelSelector: "team=platform", }) @@ -254,7 +254,7 @@ func TestWorkspaceList_EmptyReturnsNonNilSlice(t *testing.T) { conn, cleanup := newMockWorkspaceServer(mock) defer cleanup() - workspaces, err := newWorkspaceClient(conn).List(context.Background()) + workspaces, err := newWorkspaceClient(conn).ListAll(context.Background()) require.NoError(t, err) assert.NotNil(t, workspaces) @@ -444,7 +444,7 @@ func TestListMembers_Success(t *testing.T) { defer cleanup() wc := newWorkspaceClient(conn) - members, err := wc.ListMembers(context.Background(), "test-ws") + members, err := wc.ListAllMembers(context.Background(), "test-ws") require.NoError(t, err) require.Len(t, members, 1) @@ -457,7 +457,7 @@ func TestListMembers_EmptyWorkspace(t *testing.T) { defer cleanup() wc := newWorkspaceClient(conn) - _, err := wc.ListMembers(context.Background(), "") + _, err := wc.ListAllMembers(context.Background(), "") require.Error(t, err) assert.True(t, IsInvalidArgument(err)) @@ -470,7 +470,7 @@ func TestListMembers_EmptyResultReturnsNonNilSlice(t *testing.T) { conn, cleanup := newMockWorkspaceServer(mock) defer cleanup() - members, err := newWorkspaceClient(conn).ListMembers(context.Background(), "test-ws") + members, err := newWorkspaceClient(conn).ListAllMembers(context.Background(), "test-ws") require.NoError(t, err) assert.NotNil(t, members) @@ -485,7 +485,7 @@ func TestListMembers_WithOptions(t *testing.T) { defer cleanup() wc := newWorkspaceClient(conn) - _, err := wc.ListMembers(context.Background(), "test-ws", ListOptions{PageSize: 5}) + _, err := wc.ListAllMembers(context.Background(), "test-ws", ListOptions{PageSize: 5}) require.NoError(t, err) assert.Equal(t, int32(5), mock.lastListMembersReq.GetPageSize()) diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 01689c61e8..23d5d8f84b 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -186,15 +186,24 @@ const sandbox = await client.sandbox.createFromTemplate({ }) await client.sandboxTemplates.get('python', { workspace: 'default' }) -await client.sandboxTemplates.list({ workspace: 'default', pageSize: 100 }) +await client.sandboxTemplates.listAll({ workspace: 'default', pageSize: 100 }) await client.sandboxTemplates.delete('python', { workspace: 'default' }) ``` Use `allWorkspaces: true` on `list()` for a platform-admin view. The discriminated option type makes `workspace` and `allWorkspaces` mutually exclusive. Omitting both options explicitly selects the `default` workspace. -List methods follow continuation tokens automatically; `pageSize` controls -each gateway request. +List methods follow continuation tokens through a lazy `Pager`; each advance +fetches one RPC page. Use `listAll()` only +when you want to exhaust the collection. `pageSize` controls one request and +`pageToken` resumes a saved traversal. + +```ts +const pages = client.sandbox.list({ workspace: 'default', pageSize: 100 }) +for await (const page of pages) { + for (const sandbox of page.items) console.log(sandbox.name) +} +``` ## Surface and roadmap diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 824ab4a115..d440c75108 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -13,6 +13,7 @@ import { Code, ConnectError, createRouterTransport, type ServiceImpl, type Trans import { describe, expect, it } from 'vitest'; import { errorCode, + Pager, PHASE_NAMES, POLICY_SOURCE_NAMES, Pushable, @@ -438,7 +439,7 @@ describe('create', () => { const created = await sandbox.create({ name: 'direct', workspace: 'staging', image: 'img' }); const got = await sandbox.get('lookup', { workspace: 'staging' }); - const listed = await sandbox.list({ workspace: 'staging', pageSize: 10 }); + const listed = await sandbox.listAll({ workspace: 'staging', pageSize: 10 }); const deleted = await sandbox.delete('lookup', { workspace: 'staging' }); await expect(sandbox.waitReady('lookup', 1, { workspace: 'staging' })).resolves.toMatchObject({ workspace: 'staging', @@ -488,7 +489,7 @@ describe('create', () => { const sandbox = client({ listSandboxes: (req) => { requests.push(req); - if (req.pageToken === '') { + if (req.pageToken === 'resume') { return { sandboxes: [readySandbox('first', 'first-id').sandbox ?? {}], nextPageToken: 'page-2', @@ -501,14 +502,33 @@ describe('create', () => { }, }); - const listed = await sandbox.list({ pageSize: 1, labelSelector: 'team=core' }); - - expect(listed.map((item) => item.name)).toEqual(['first', 'second']); + const pager = sandbox.list({ pageSize: 1, pageToken: 'resume', labelSelector: 'team=core' }); + expect(requests).toHaveLength(0); + const first = await pager.nextPage(); + expect(first?.items.map((item) => item.name)).toEqual(['first']); + expect(first?.nextPageToken).toBe('page-2'); + const second = await pager.nextPage(); + expect(second?.items.map((item) => item.name)).toEqual(['second']); + expect(second?.nextPageToken).toBe(''); + await expect(pager.nextPage()).resolves.toBeUndefined(); expect(requests).toHaveLength(2); - expect(requests[0]).toMatchObject({ pageToken: '', pageSize: 1, labelSelector: 'team=core' }); + expect(requests[0]).toMatchObject({ pageToken: 'resume', pageSize: 1, labelSelector: 'team=core' }); expect(requests[1]).toMatchObject({ pageToken: 'page-2', pageSize: 1, labelSelector: 'team=core' }); }); + it('retries the same page token after a fetch error', async () => { + const tokens: string[] = []; + const pager = new Pager(async (token) => { + tokens.push(token); + if (tokens.length === 1) throw new Error('temporary failure'); + return { items: [1], nextPageToken: '' }; + }, 'resume'); + + await expect(pager.nextPage()).rejects.toThrow('temporary failure'); + await expect(pager.nextPage()).resolves.toEqual({ items: [1], nextPageToken: '' }); + expect(tokens).toEqual(['resume', 'resume']); + }); + it('createFromTemplate rejects an empty template name locally', async () => { const sandbox = client({}); await expect(sandbox.createFromTemplate({ templateName: ' ' })).rejects.toMatchObject({ @@ -653,7 +673,7 @@ describe('sandbox templates', () => { }); const got = await templates.get('gpu-kata', { workspace: 'staging' }); - const listed = await templates.list({ workspace: 'staging', pageSize: 10, labelSelector: 'team=runtime' }); + const listed = await templates.listAll({ workspace: 'staging', pageSize: 10, labelSelector: 'team=runtime' }); const deleted = await templates.delete('gpu-kata', { workspace: 'staging' }); expect(got.metadata?.name).toBe('gpu-kata'); @@ -681,7 +701,7 @@ describe('sandbox templates', () => { }, }); - await templates.list({ allWorkspaces: true }); + await templates.listAll({ allWorkspaces: true }); expect(selectedWorkspace(observed)).toBeUndefined(); expect(selectsAllWorkspaces(observed)).toBe(true); diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 989ce2f0a7..37f145c4c3 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -150,8 +150,10 @@ export interface SandboxWorkloadTemplateProvenance { } interface PaginationOptions { - /** Page size used while collecting every result. */ + /** Maximum resources requested per page. */ pageSize?: number; + /** Opaque token from a previous page. Omit to start at the beginning. */ + pageToken?: string; labelSelector?: string; } @@ -175,8 +177,10 @@ export interface SandboxTemplateWorkspaceOptions { } export type SandboxTemplateListOptions = WorkspaceListScope & { - /** Page size used while collecting every result. */ + /** Maximum templates requested per page. */ pageSize?: number; + /** Opaque token from a previous page. Omit to start at the beginning. */ + pageToken?: string; /** Optional label selector in key=value comma-separated form. */ labelSelector?: string; }; @@ -636,6 +640,47 @@ export class Pushable implements AsyncIterable { } } +/** One response page from a list operation. */ +export interface Page { + readonly items: T[]; + readonly nextPageToken: string; +} + +/** Lazy, single-pass iterator that fetches one RPC page per advance. */ +export class Pager implements AsyncIterable> { + private nextToken: string | undefined; + + constructor( + private readonly fetch: (pageToken: string) => Promise>, + pageToken = '', + ) { + this.nextToken = pageToken; + } + + /** Fetch the next page, or return undefined after the final page. */ + async nextPage(): Promise | undefined> { + if (this.nextToken === undefined) return undefined; + const page = await this.fetch(this.nextToken); + this.nextToken = page.nextPageToken === '' ? undefined : page.nextPageToken; + return page; + } + + /** Consume the pager and collect every remaining item. */ + async all(): Promise { + const items: T[] = []; + for await (const page of this) items.push(...page.items); + return items; + } + + async *[Symbol.asyncIterator](): AsyncIterator> { + for (;;) { + const page = await this.nextPage(); + if (page === undefined) return; + yield page; + } + } +} + // ---- sandbox template client ---------------------------------------------- // Reusable sandbox workload template lifecycle. Templates intentionally return @@ -685,24 +730,25 @@ export class SandboxTemplateClient { } } - async list(options?: SandboxTemplateListOptions | null): Promise { - try { - const templates: SandboxWorkloadTemplate[] = []; - let pageToken = ''; - do { + list(options?: SandboxTemplateListOptions | null): Pager { + return new Pager(async (pageToken) => { + try { const resp = await this.grpc.listSandboxTemplates({ pageSize: options?.pageSize ?? 0, pageToken, labelSelector: options?.labelSelector ?? '', workspaceScope: listWorkspaceScope(options), }); - templates.push(...resp.templates); - pageToken = resp.nextPageToken; - } while (pageToken !== ''); - return templates; - } catch (e) { - throw fromConnect(e); - } + return { items: resp.templates, nextPageToken: resp.nextPageToken }; + } catch (e) { + throw fromConnect(e); + } + }, options?.pageToken ?? ''); + } + + /** List and collect every sandbox template in this scope. */ + async listAll(options?: SandboxTemplateListOptions | null): Promise { + return this.list(options).all(); } async delete(name: string, options?: SandboxTemplateWorkspaceOptions | null): Promise { @@ -815,24 +861,28 @@ export class SandboxClient { } } - async list(options?: ListOptions | null): Promise { - try { - const sandboxes: SandboxRef[] = []; - let pageToken = ''; - do { + list(options?: ListOptions | null): Pager { + return new Pager(async (pageToken) => { + try { const resp = await this.grpc.listSandboxes({ pageSize: options?.pageSize ?? 0, pageToken, labelSelector: options?.labelSelector ?? '', workspaceScope: listWorkspaceScope(options), }); - sandboxes.push(...resp.sandboxes.map((sandbox) => sandboxRef(sandbox))); - pageToken = resp.nextPageToken; - } while (pageToken !== ''); - return sandboxes; - } catch (e) { - throw fromConnect(e); - } + return { + items: resp.sandboxes.map((sandbox) => sandboxRef(sandbox)), + nextPageToken: resp.nextPageToken, + }; + } catch (e) { + throw fromConnect(e); + } + }, options?.pageToken ?? ''); + } + + /** List and collect every sandbox in this scope. */ + async listAll(options?: ListOptions | null): Promise { + return this.list(options).all(); } async delete(name: string, options?: SandboxWorkspaceOptions | null): Promise { diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 5d02fe6b61..4935f49b1d 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -18,6 +18,7 @@ export type { Health, HealthStatus, ListOptions, + Page, PolicySourceName, ProviderChange, ProviderChangeOptions, @@ -45,7 +46,7 @@ export type { WaitOptions, WorkspaceListScope, } from './client.js'; -export { errorCode, OpenShellClient, SandboxClient, SandboxTemplateClient } from './client.js'; +export { errorCode, OpenShellClient, Pager, SandboxClient, SandboxTemplateClient } from './client.js'; export type { SdkErrorCode } from './errors.js'; export { SdkError } from './errors.js'; export type { ClientCredentialsOptions, OidcTokenProvider } from './oidc.js';