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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/build-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,26 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: "Install fresh upstream rustup"
# The macos-15-arm64 runner image (20260511.0048+) ships a broken
# Homebrew rustup whose bundled rustup-init rejects standard argv
# forwarded from the rustup proxy, breaking `rustup component add`
# and other commands maturin-action invokes. Upstream:
# https://github.com/actions/runner-images/issues/14097
# Scrub Homebrew's rustup AND any stale upstream rustup, then
# reinstall cleanly with 1.88 as the default so setup-rust-toolchain
# has nothing left to do but verify.
run: |
brew uninstall --ignore-dependencies rustup || true
rm -f "$HOME/.cargo/bin/rustup" "$HOME/.cargo/bin/rustup-init"
curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain 1.88 --profile minimal -y
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
# Don't restore ~/.cargo/bin from cache — it'd clobber the
# freshly-installed upstream rustup with a stale (and possibly
# Homebrew-linked) binary from a previous run.
cache-bin: false
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
Expand Down Expand Up @@ -102,7 +121,26 @@ jobs:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: "Install fresh upstream rustup"
# The macos-15-arm64 runner image (20260511.0048+) ships a broken
# Homebrew rustup whose bundled rustup-init rejects standard argv
# forwarded from the rustup proxy, breaking `rustup component add`
# and other commands maturin-action invokes. Upstream:
# https://github.com/actions/runner-images/issues/14097
# Scrub Homebrew's rustup AND any stale upstream rustup, then
# reinstall cleanly with 1.88 as the default so setup-rust-toolchain
# has nothing left to do but verify.
run: |
brew uninstall --ignore-dependencies rustup || true
rm -f "$HOME/.cargo/bin/rustup" "$HOME/.cargo/bin/rustup-init"
curl --proto '=https' --tlsv1.2 --retry 10 --retry-connrefused -fsSL https://sh.rustup.rs | sh -s -- --default-toolchain 1.88 --profile minimal -y
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- uses: actions-rust-lang/setup-rust-toolchain@v1.11.0
with:
# Don't restore ~/.cargo/bin from cache — it'd clobber the
# freshly-installed upstream rustup with a stale (and possibly
# Homebrew-linked) binary from a previous run.
cache-bin: false
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
Expand Down
51 changes: 51 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,27 @@ pub struct Config {

// cache_dir is the directory that we should cache uv artifacts within.
pub cache_dir: Option<PathBuf>,

// Override for the page size used when fetching paginated list endpoints.
// None means use the built-in default.
#[serde(skip_serializing, skip_deserializing)]
pub page_size: Option<i64>,

// When false, only the first page of paginated endpoints is fetched.
#[serde(skip_serializing, skip_deserializing)]
pub paginate: bool,

// Hard upper bound on the number of items returned by a paginated list call.
#[serde(skip_serializing, skip_deserializing)]
pub max_items: Option<i64>,

// Starting page (0-indexed) for paginated list calls.
#[serde(skip_serializing, skip_deserializing)]
pub page: Option<i64>,
}

pub const DEFAULT_PAGE_SIZE: i64 = 100;

impl Config {
pub fn default() -> Self {
Self {
Expand All @@ -37,9 +56,17 @@ impl Config {
session: None,
api_key: None,
cache_dir: Some(default_cache_dir()),
page_size: None,
paginate: true,
max_items: None,
page: None,
}
}

pub fn page_size(&self) -> i64 {
self.page_size.unwrap_or(DEFAULT_PAGE_SIZE)
}

pub fn from_env() -> Self {
let debug = std::env::var("TOWER_DEBUG").is_ok();
let tower_url = if let Some(url) = std::env::var("TOWER_URL").ok() {
Expand All @@ -56,6 +83,10 @@ impl Config {
session: None,
api_key,
cache_dir: Some(default_cache_dir()),
page_size: None,
paginate: true,
max_items: None,
page: None,
}
}

Expand All @@ -74,6 +105,22 @@ impl Config {
config.tower_url = Url::parse(tower_url).unwrap();
}

if let Some(page_size) = matches.get_one::<i64>("page_size") {
config.page_size = Some(*page_size);
}

if matches.get_flag("no_paginate") {
config.paginate = false;
}

if let Some(max_items) = matches.get_one::<i64>("max_items") {
config.max_items = Some(*max_items);
}

if let Some(page) = matches.get_one::<i64>("page") {
config.page = Some(*page);
}

config
}

Expand All @@ -85,6 +132,10 @@ impl Config {
session: Some(sess),
api_key: self.api_key,
cache_dir: Some(default_cache_dir()),
page_size: self.page_size,
paginate: self.paginate,
max_items: self.max_items,
page: self.page,
}
}

Expand Down
44 changes: 33 additions & 11 deletions crates/tower-cmd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,24 +64,46 @@ impl PaginatedResponse for tower_api::models::ListSchedulesResponse {
fn into_items(self) -> Vec<Self::Item> { self.schedules }
}

/// Fetches all pages from a paginated API endpoint.
async fn fetch_all_pages<R, E, F, Fut>(fetch: F) -> Result<Vec<R::Item>, Error<E>>
/// Fetches pages from a paginated API endpoint, honoring the caller's
/// page_size / paginate / page / max_items settings on Config.
///
/// Page numbers follow the API convention: pages are 1-indexed, and `page = 0`
/// (or unset on the server side) means "no pagination, return everything in one
/// response". The loop sends `page = 0` exactly once if the caller did not opt
/// into a starting page, otherwise it iterates 1..=num_pages.
async fn fetch_all_pages<R, E, F, Fut>(config: &Config, fetch: F) -> Result<Vec<R::Item>, Error<E>>
where
R: PaginatedResponse,
F: Fn(i64, i64) -> Fut,
Fut: Future<Output = Result<R, Error<E>>>,
{
let page_size: i64 = 100;
let page_size = config.page_size();
let mut all_items = Vec::new();
let mut page: i64 = 0;
let mut page: i64 = config.page.unwrap_or(0);

loop {
let response = fetch(page, page_size).await?;
let num_pages = response.pagination().num_pages;
all_items.extend(response.into_items());

if let Some(max) = config.max_items {
if all_items.len() as i64 >= max {
all_items.truncate(max as usize);
break;
}
}

// page == 0 means the server returned all items in a single response.
if page == 0 {
break;
}

if !config.paginate {
break;
}

page += 1;
if page >= num_pages || page >= 100 {
if page > num_pages || page > 100 {
break;
}
}
Expand Down Expand Up @@ -119,7 +141,7 @@ pub async fn list_apps(
{
let api_config: configuration::Configuration = config.into();

fetch_all_pages(|page, page_size| {
fetch_all_pages(config, |page, page_size| {
let api_config = &api_config;
async move {
let params = tower_api::apis::default_api::ListAppsParams {
Expand Down Expand Up @@ -305,7 +327,7 @@ pub async fn list_catalogs(
let api_config: configuration::Configuration = config.into();
let env = env.to_string();

fetch_all_pages(|page, page_size| {
fetch_all_pages(config, |page, page_size| {
let api_config = &api_config;
let env = &env;
async move {
Expand Down Expand Up @@ -354,7 +376,7 @@ pub async fn list_secrets(
let api_config: configuration::Configuration = config.into();
let env = env.to_string();

fetch_all_pages(|page, page_size| {
fetch_all_pages(config, |page, page_size| {
let api_config = &api_config;
let env = &env;
async move {
Expand Down Expand Up @@ -493,7 +515,7 @@ pub async fn list_teams(
) -> Result<Vec<tower_api::models::Team>, Error<tower_api::apis::default_api::ListTeamsError>> {
let api_config: configuration::Configuration = config.into();

fetch_all_pages(|page, page_size| {
fetch_all_pages(config, |page, page_size| {
let api_config = &api_config;
async move {
let params = tower_api::apis::default_api::ListTeamsParams {
Expand Down Expand Up @@ -971,7 +993,7 @@ pub async fn list_environments(
> {
let api_config: configuration::Configuration = config.into();

fetch_all_pages(|page, page_size| {
fetch_all_pages(config, |page, page_size| {
let api_config = &api_config;
async move {
let params = tower_api::apis::default_api::ListEnvironmentsParams {
Expand Down Expand Up @@ -1018,7 +1040,7 @@ pub async fn list_schedules(
let api_config: configuration::Configuration = config.into();
let environment = environment.map(String::from);

fetch_all_pages(|page, page_size| {
fetch_all_pages(config, |page, page_size| {
let api_config = &api_config;
let environment = &environment;
async move {
Expand Down
31 changes: 31 additions & 0 deletions crates/tower-cmd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,37 @@ fn root_cmd() -> Command {
.value_parser(value_parser!(String))
.action(clap::ArgAction::Set),
)
.arg(
Arg::new("page_size")
.long("page-size")
.hide(true)
.value_parser(value_parser!(i64))
.action(clap::ArgAction::Set)
.global(true),
)
.arg(
Arg::new("no_paginate")
.long("no-paginate")
.help("Fetch only the first page of paginated results")
.action(clap::ArgAction::SetTrue)
.global(true),
)
.arg(
Arg::new("max_items")
.long("max-items")
.help("Maximum number of items to return from paginated list commands")
.value_parser(value_parser!(i64))
.action(clap::ArgAction::Set)
.global(true),
)
.arg(
Arg::new("page")
.long("page")
.help("Page number to start at for paginated list commands; 0 (the default) requests all results without pagination")
.value_parser(value_parser!(i64))
.action(clap::ArgAction::Set)
.global(true),
)
.subcommand_required(false)
.arg_required_else_help(false)
.subcommand(session::login_cmd())
Expand Down
24 changes: 12 additions & 12 deletions tests/integration/features/cli_pagination.feature
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
@serial
Feature: CLI Pagination
@serial @pagination
Feature: CLI Apps List Pagination
As a developer using Tower CLI
I want all items to be returned from list commands
So that I don't miss apps or resources due to pagination limits
I want all of my apps to be returned from list commands
So that I don't miss apps due to pagination limits

Scenario: CLI apps list fetches all pages when results exceed page size
Given the mock API has 105 seeded apps
When I run "tower apps list" via CLI
Then the output should contain all 105 seeded app names
Scenario: CLI apps list returns every app across multiple pages
Given I have created 3 apps via CLI
When I run "tower --page 1 --page-size 2 apps list" via CLI
Then the output should contain all created app names

Scenario: CLI apps list in JSON mode returns all paginated results
Given the mock API has 105 seeded apps
When I run "tower apps list --json" via CLI
Scenario: CLI apps list in JSON mode returns every app across multiple pages
Given I have created 3 apps via CLI
When I run "tower --page 1 --page-size 2 apps list --json" via CLI
Then the output should be valid JSON
And the JSON should contain at least 105 apps
And the JSON should contain all created app names
18 changes: 18 additions & 0 deletions tests/integration/features/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ def before_scenario(context, scenario):
def after_scenario(context, scenario):
import shutil

# Delete any apps created by the scenario via "I have created N apps via CLI".
if getattr(context, "created_app_names", None):
env = os.environ.copy()
env["TOWER_URL"] = context.tower_url
test_home = Path(__file__).parent.parent / "test-home"
env["HOME"] = str(test_home.absolute())
for name in context.created_app_names:
try:
subprocess.run(
[context.tower_binary, "apps", "delete", name],
env=env,
capture_output=True,
timeout=30,
)
except subprocess.TimeoutExpired:
pass
context.created_app_names = []

# Clean up any MCP servers started by this scenario
for attr in ["http_mcp_process", "sse_mcp_process"]:
if hasattr(context, attr):
Expand Down
Loading
Loading