From 78e7584edcfc6e5fcf5842f7ef2442a891c5bea9 Mon Sep 17 00:00:00 2001 From: Joshua Smock Date: Thu, 9 Jul 2026 18:48:39 +0200 Subject: [PATCH 01/18] Use ARM runners (#321) --- .github/workflows/build-binaries.yml | 80 ++++++++++------------------ 1 file changed, 29 insertions(+), 51 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index bf9e6eb5..884d691b 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -42,6 +42,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: @@ -72,6 +73,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - 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 @@ -117,6 +119,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - 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 @@ -170,6 +173,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: @@ -204,16 +208,16 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - architecture: x64 - name: Build wheel uses: PyO3/maturin-action@v1.50.1 with: target: ${{ matrix.platform.target }} - manylinux: auto + manylinux: 2_28 args: --release --locked --out dist -i python${{ matrix.python-version }} before-script-linux: | # If we're running on rhel centos, install needed packages. @@ -237,23 +241,20 @@ jobs: name: wheels-${{ matrix.platform.target }}-py${{ matrix.python-version }} path: dist - linux-cross: + linux-arm: if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm strategy: matrix: platform: - target: aarch64-unknown-linux-gnu - arch: aarch64 - # see https://github.com/astral-sh/ruff/issues/3791 - # and https://github.com/gnzlbg/jemallocator/issues/170#issuecomment-1503228963 - maturin_docker_options: -e JEMALLOC_SYS_WITH_LG_PAGE=16 -e PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 with: submodules: recursive persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -262,35 +263,14 @@ jobs: with: target: ${{ matrix.platform.target }} manylinux: 2_28 - docker-options: ${{ matrix.platform.maturin_docker_options }} - args: --release --locked --out dist -i ${{ format('python{0}', matrix.python-version) }} - env: - CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse - - uses: uraimo/run-on-arch-action@v3 - if: ${{ matrix.platform.arch != 'ppc64' && matrix.platform.arch != 'ppc64le'}} - name: Test wheel - with: - arch: ${{ matrix.platform.arch }} - distro: ubuntu24.04 - githubToken: ${{ github.token }} - install: | - apt-get update - apt-get install -y software-properties-common - add-apt-repository ppa:deadsnakes/ppa - apt-get update - apt-get install -y --no-install-recommends ${{ format('python{0}-full', matrix.python-version) }} ${{ format('python{0}-venv', matrix.python-version) }} cargo libffi-dev - # Create and use a virtual environment to avoid the externally-managed-environment error - run: | - ln -s -f /usr/bin/${{ format('python{0}', matrix.python-version) }} /usr/bin/python3 - ln -s -f /usr/bin/${{ format('pip{0}', matrix.python-version) }} /usr/bin/pip3 - # Workaround for QEMU bug when emulating 32-bit on 64-bit hosts - # See: https://github.com/docker/buildx/issues/395 - # Use /tmp for CARGO_HOME (often tmpfs, which avoids the QEMU filesystem bug) - export CARGO_HOME=/tmp/cargo-home - python3 -m venv /tmp/venv - . /tmp/venv/bin/activate - pip3 install dist/*.whl --force-reinstall - ${{ env.EXECUTABLE_NAME }} --help + args: --release --locked --out dist -i python${{ matrix.python-version }} + - name: Test wheel + if: ${{ startsWith(matrix.platform.target, 'aarch64') }} + run: | + PYTAG="cp$(python -c "import sys; print(f'{sys.version_info.major}{sys.version_info.minor}')")" + pip install dist/*-${PYTAG}-*.whl --force-reinstall + ${{ env.EXECUTABLE_NAME }} --help + python -m ${{ env.MODULE_NAME }} --help - name: Upload wheel uses: actions/upload-artifact@v6 with: @@ -309,6 +289,7 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -337,19 +318,19 @@ jobs: name: wheels-${{ matrix.platform.target }}-py${{ matrix.python-version }} path: dist - musllinux-cross: + musllinux-arm: if: ${{ !contains(github.event.pull_request.labels.*.name, 'no-build') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm strategy: matrix: platform: - target: aarch64-unknown-linux-musl - arch: aarch64 python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -359,18 +340,15 @@ jobs: target: ${{ matrix.platform.target }} manylinux: musllinux_1_2 args: --release --locked --out dist -i python${{ matrix.python-version }} - - uses: uraimo/run-on-arch-action@v2 - name: Test wheel - with: - arch: ${{ matrix.platform.arch }} - distro: alpine_latest - githubToken: ${{ github.token }} - install: | - apk add uv rust + - name: Test wheel + if: matrix.platform.target == 'aarch64-unknown-linux-musl' + uses: addnab/docker-run-action@v3 + with: + image: python:${{ matrix.python-version}}-alpine + options: -v ${{ github.workspace }}:/io -w /io run: | - uv python install ${{ matrix.python-version }} --default - export PATH="/root/.local/bin:$PATH" - python3 -m venv .venv + apk add rust + python -m venv .venv .venv/bin/pip3 install dist/*.whl --force-reinstall .venv/bin/${{ env.EXECUTABLE_NAME }} --help - name: Upload wheel From 6f3863f586601be7726f7cc7019a5632a3b5da20 Mon Sep 17 00:00:00 2001 From: Joshua Smock Date: Fri, 10 Jul 2026 10:48:08 +0200 Subject: [PATCH 02/18] Add Rust cache to all jobs (#323) --- .github/workflows/build-binaries.yml | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 884d691b..fa9006dc 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -44,6 +44,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: sdist - uses: actions/setup-python@v6 with: python-version: ${{ env.PYTHON_VERSION }} @@ -94,6 +98,10 @@ jobs: # freshly-installed upstream rustup with a stale (and possibly # Homebrew-linked) binary from a previous run. cache-bin: false + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-macos-x86_64 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -140,6 +148,10 @@ jobs: # freshly-installed upstream rustup with a stale (and possibly # Homebrew-linked) binary from a previous run. cache-bin: false + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-macos-aarch64 - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -175,6 +187,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -210,6 +226,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -255,6 +275,10 @@ jobs: submodules: recursive persist-credentials: false - uses: actions-rust-lang/setup-rust-toolchain@v1.11.0 + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -293,8 +317,10 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Install OpenSSL - run: sudo apt-get update && sudo apt-get install libssl-dev openssl + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - name: Build wheel uses: PyO3/maturin-action@v1.50.1 with: @@ -334,6 +360,10 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} + - name: Cache rust + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: py${{ matrix.python-version }}-${{ matrix.platform.target }} - name: Build wheel uses: PyO3/maturin-action@v1.50.1 with: From 24b6f146227c9cc9582c6ed216cb3b565c7850e0 Mon Sep 17 00:00:00 2001 From: Konstantinos St Date: Tue, 14 Jul 2026 07:17:46 -0300 Subject: [PATCH 03/18] feat: add beta label to CLI output (#324) --- Cargo.lock | 1 + crates/config/Cargo.toml | 3 + crates/config/src/lib.rs | 4 +- crates/config/src/session.rs | 104 ++++++++++++++++++++++++++++++- crates/tower-cmd/src/beta.rs | 103 ++++++++++++++++++++++++++++++ crates/tower-cmd/src/catalogs.rs | 70 +++++++++++++++++++-- crates/tower-cmd/src/lib.rs | 16 +++++ crates/tower-cmd/src/output.rs | 5 ++ 8 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 crates/tower-cmd/src/beta.rs diff --git a/Cargo.lock b/Cargo.lock index 013fb4b7..0e39ade0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -501,6 +501,7 @@ dependencies = [ "serde", "serde_json", "snafu", + "tempfile", "testutils", "tokio", "tower-api", diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 3cf75e69..60b85e58 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -21,3 +21,6 @@ url = { workspace = true } tower-api = { workspace = true } tower-package = { workspace = true } tower-telemetry = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2e4a48a2..07b36f3d 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -10,7 +10,9 @@ pub use error::Error; pub use session::{default_tower_url, Session, Team, Token, User}; pub use tower_package::{Parameter, Towerfile}; -pub use session::{get_last_version_check_timestamp, set_last_version_check_timestamp}; +pub use session::{ + claim_notice, get_last_version_check_timestamp, set_last_version_check_timestamp, +}; #[derive(Clone, Serialize, Deserialize)] pub struct Config { diff --git a/crates/config/src/session.rs b/crates/config/src/session.rs index 44bea0e4..5537161b 100644 --- a/crates/config/src/session.rs +++ b/crates/config/src/session.rs @@ -1,9 +1,10 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chrono::{DateTime, TimeZone, Utc}; use serde::{Deserialize, Serialize}; -use std::fs; +use std::fs::{self, OpenOptions}; use std::future::Future; -use std::path::PathBuf; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; use url::Url; use crate::error::Error; @@ -87,6 +88,43 @@ fn find_or_create_config_dir() -> Result { Ok(config_dir) } +/// Atomically claims a persistent, user-level CLI notice. +/// +/// Returns `true` only for the first process to claim this notice ID. Notice +/// IDs are internal constants and may contain ASCII letters, digits, `-`, and +/// `_` only. +pub fn claim_notice(id: &str) -> std::io::Result { + let config_dir = find_or_create_config_dir() + .map_err(|err| std::io::Error::other(format!("finding Tower config directory: {err}")))?; + claim_notice_at(&config_dir, id) +} + +fn claim_notice_at(base_dir: &Path, id: &str) -> std::io::Result { + if id.is_empty() + || !id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "invalid notice ID", + )); + } + + let notices_dir = base_dir.join("notices"); + fs::create_dir_all(¬ices_dir)?; + + match OpenOptions::new() + .write(true) + .create_new(true) + .open(notices_dir.join(id)) + { + Ok(_) => Ok(true), + Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(false), + Err(err) => Err(err), + } +} + pub fn get_last_version_check_timestamp() -> DateTime { let default: DateTime = Utc.timestamp_opt(0, 0).unwrap(); @@ -346,3 +384,65 @@ where { tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future)) } + +#[cfg(test)] +mod tests { + use std::{fs, sync::Arc, thread}; + + use tempfile::TempDir; + + use super::claim_notice_at; + + #[test] + fn notice_can_only_be_claimed_once() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + + assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap()); + assert!(!claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap()); + } + + #[test] + fn notice_generations_are_independent() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + + assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap()); + assert!(claim_notice_at(temp_dir.path(), "storage-beta-v2").unwrap()); + } + + #[test] + fn concurrent_notice_claims_have_one_winner() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let base_dir = Arc::new(temp_dir.path().to_path_buf()); + let handles = (0..8) + .map(|_| { + let base_dir = Arc::clone(&base_dir); + thread::spawn(move || claim_notice_at(&base_dir, "storage-beta-v1").unwrap()) + }) + .collect::>(); + + let winners = handles + .into_iter() + .map(|handle| handle.join().expect("claim thread should finish")) + .filter(|claimed| *claimed) + .count(); + + assert_eq!(winners, 1); + } + + #[test] + fn notice_claim_rejects_invalid_ids() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + + assert!(claim_notice_at(temp_dir.path(), "../outside").is_err()); + assert!(claim_notice_at(temp_dir.path(), "").is_err()); + } + + #[test] + fn notice_claim_reports_unavailable_base_directory() { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let base_dir = temp_dir.path().join("config-file"); + fs::write(&base_dir, "not a directory").expect("fixture should be written"); + + assert!(claim_notice_at(&base_dir, "storage-beta-v1").is_err()); + } +} diff --git a/crates/tower-cmd/src/beta.rs b/crates/tower-cmd/src/beta.rs new file mode 100644 index 00000000..fb6c0c29 --- /dev/null +++ b/crates/tower-cmd/src/beta.rs @@ -0,0 +1,103 @@ +use std::io::{self, IsTerminal}; + +use tower_telemetry::debug; + +use crate::output::{self, OutputMode}; + +pub(crate) struct BetaFeature { + id: &'static str, + message: &'static str, + docs_url: Option<&'static str>, +} + +impl BetaFeature { + pub fn short_about(&self, description: &str) -> String { + format!("{description} [beta]") + } + + pub fn notice(&self) -> String { + match self.docs_url { + Some(url) => format!("{} Learn more: {url}", self.message), + None => self.message.to_string(), + } + } +} + +pub(crate) const STORAGE_BETA_MESSAGE: &str = "Tower Storage is in beta. Core functionality is stable, but some featues and interfaces might change before general availability."; + +pub(crate) const STORAGE: BetaFeature = BetaFeature { + id: "storage-beta-v1", + message: STORAGE_BETA_MESSAGE, + docs_url: None, +}; + +pub(crate) fn notify_once(feature: &BetaFeature) { + let output_mode = output::get_output_mode(); + let stdout_is_terminal = io::stdout().is_terminal(); + let stderr_is_terminal = io::stderr().is_terminal(); + + if !should_notify(output_mode, stdout_is_terminal, stderr_is_terminal) { + return; + } + + match config::claim_notice(feature.id) { + Ok(true) => output::notice_to_stderr("Beta:", &feature.notice()), + Ok(false) => {} + Err(err) => debug!("Failed to persist CLI notice {}: {}", feature.id, err), + } +} + +fn should_notify( + output_mode: OutputMode, + stdout_is_terminal: bool, + stderr_is_terminal: bool, +) -> bool { + output_mode.is_normal() && stdout_is_terminal && stderr_is_terminal +} + +#[cfg(test)] +mod tests { + use super::{should_notify, BetaFeature, STORAGE, STORAGE_BETA_MESSAGE}; + use crate::output::OutputMode; + + #[test] + fn short_about_has_one_beta_suffix() { + let about = STORAGE.short_about("Use Tower Storage"); + + assert_eq!(about, "Use Tower Storage [beta]"); + assert_eq!(about.matches("[beta]").count(), 1); + } + + #[test] + fn notice_omits_docs_sentence_without_a_url() { + let notice = STORAGE.notice(); + + assert_eq!(notice, STORAGE_BETA_MESSAGE); + assert!(!notice.contains("Learn more:")); + } + + #[test] + fn notice_includes_docs_url_when_configured() { + let feature = BetaFeature { + id: "example-beta-v1", + message: "Example is in beta. Its interface may change.", + docs_url: Some("https://example.com/beta"), + }; + + assert_eq!( + feature.notice(), + "Example is in beta. Its interface may change. Learn more: https://example.com/beta" + ); + } + + #[test] + fn notice_requires_normal_output_and_two_terminals() { + assert!(should_notify(OutputMode::Normal, true, true)); + assert!(!should_notify(OutputMode::Normal, false, true)); + assert!(!should_notify(OutputMode::Normal, true, false)); + assert!(!should_notify(OutputMode::Normal, false, false)); + assert!(!should_notify(OutputMode::Json, true, true)); + assert!(!should_notify(OutputMode::McpStdio, true, true)); + assert!(!should_notify(OutputMode::McpStreaming, true, true)); + } +} diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index e52a3f11..4c0a3801 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -5,13 +5,17 @@ use tower_api::models::{ vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse, }; -use crate::{api, output, util::cmd}; +use crate::{api, beta, output, util::cmd}; const STORAGE_CATALOG_TYPE: &str = "tower-catalog"; pub fn catalogs_cmd() -> Command { Command::new("catalogs") - .about("Interact with the catalogs in your Tower account") + .about(format!( + "Interact with the catalogs in your Tower account (includes {})", + beta::STORAGE.short_about("Storage") + )) + .after_help(beta::STORAGE.notice()) .arg_required_else_help(true) .subcommand( Command::new("list") @@ -41,7 +45,7 @@ pub fn catalogs_cmd() -> Command { .arg( Arg::new("storage") .long("storage") - .help("List Tower-managed storage catalogs") + .help(beta::STORAGE.short_about("List Tower-managed storage catalogs")) .conflicts_with("type") .action(ArgAction::SetTrue), ) @@ -107,7 +111,10 @@ pub fn catalogs_cmd() -> Command { .help("Print the vended OAuth token in normal output") .action(ArgAction::SetTrue), ) - .about("Vend short-lived catalog credentials for external tools"), + .about( + beta::STORAGE + .short_about("Vend short-lived catalog credentials for external tools"), + ), ) } @@ -120,6 +127,10 @@ pub async fn do_list(config: Config, args: &ArgMatches) { args.get_one::("type").map(String::as_str) }; + if is_storage_catalog_type(catalog_type) { + beta::notify_once(&beta::STORAGE); + } + let catalogs = output::with_spinner( "Listing catalogs", api::list_catalogs(&config, &env, all, catalog_type), @@ -144,6 +155,8 @@ pub async fn do_list(config: Config, args: &ArgMatches) { } pub async fn do_credentials(config: Config, args: &ArgMatches) { + beta::notify_once(&beta::STORAGE); + let name = args .get_one::("catalog_name") .expect("catalog_name is required"); @@ -184,6 +197,9 @@ pub async fn do_show(config: Config, args: &ArgMatches) { match api::describe_catalog(&config, name, &env).await { Ok(response) => { + if is_storage_catalog_type(Some(&response.catalog.r#type)) { + beta::notify_once(&beta::STORAGE); + } let human = catalog_details_text(&response); output::text(&human, &response); } @@ -191,6 +207,10 @@ pub async fn do_show(config: Config, args: &ArgMatches) { } } +fn is_storage_catalog_type(catalog_type: Option<&str>) -> bool { + catalog_type == Some(STORAGE_CATALOG_TYPE) +} + fn parse_mode(mode: &str) -> vend_catalog_credentials_body::Mode { match mode { "read-write" => vend_catalog_credentials_body::Mode::ReadWrite, @@ -420,7 +440,9 @@ fn snippets( #[cfg(test)] mod tests { - use super::{catalogs_cmd, parse_mode, snippets, token_export_command}; + use super::{ + catalogs_cmd, is_storage_catalog_type, parse_mode, snippets, token_export_command, + }; use tower_api::models::{vend_catalog_credentials_body, CatalogCredentials}; #[test] @@ -495,6 +517,44 @@ mod tests { assert!(result.is_err()); } + #[test] + fn storage_catalog_type_detection_is_scoped() { + assert!(is_storage_catalog_type(Some("tower-catalog"))); + assert!(!is_storage_catalog_type(Some("snowflake-open-catalog"))); + assert!(!is_storage_catalog_type(None)); + } + + #[test] + fn catalog_help_marks_storage_beta_in_short_and_long_help() { + let short_help = catalogs_cmd().render_help().to_string(); + let long_help = catalogs_cmd().render_long_help().to_string(); + + for help in [short_help, long_help] { + assert!(help.contains("includes Storage [beta]")); + assert!(help.contains("Tower Storage is in beta.")); + } + } + + #[test] + fn storage_specific_command_and_flag_are_marked_beta() { + let mut command = catalogs_cmd(); + let credentials_help = command + .find_subcommand_mut("credentials") + .expect("credentials command should exist") + .render_help() + .to_string(); + assert!(credentials_help + .contains("Vend short-lived catalog credentials for external tools [beta]")); + + let mut command = catalogs_cmd(); + let list_help = command + .find_subcommand_mut("list") + .expect("list command should exist") + .render_help() + .to_string(); + assert!(list_help.contains("List Tower-managed storage catalogs [beta]")); + } + #[test] fn show_requires_catalog_name() { let result = catalogs_cmd().try_get_matches_from(["catalogs", "show"]); diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index 9eb84280..b45bc38e 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -3,6 +3,7 @@ use config::{Config, Session}; pub mod api; mod apps; +mod beta; mod catalogs; mod deploy; mod environments; @@ -304,3 +305,18 @@ fn root_cmd() -> Command { .subcommand(mcp::mcp_cmd()) .subcommand(skill::skill_cmd()) } + +#[cfg(test)] +mod tests { + use super::root_cmd; + + #[test] + fn root_help_scopes_beta_label_to_storage() { + let help = root_cmd().render_help().to_string(); + + assert!(help.contains( + "Interact with the catalogs in your Tower account (includes Storage [beta])" + )); + assert!(!help.contains("catalogs [beta]")); + } +} diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index 862e8839..12743ec1 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -68,6 +68,11 @@ fn write_to_stderr(msg: &str) { stderr.flush().ok(); } +pub(crate) fn notice_to_stderr(label: &str, msg: &str) { + let line = format!("{} {}\n", label.bold().yellow(), msg); + write_to_stderr(&line); +} + pub fn set_output_mode(mode: OutputMode) { OUTPUT_MODE.set(mode).ok(); if mode.is_mcp() { From e64b782b79d363f481cc844e58cf805c5269e3b9 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Tue, 14 Jul 2026 14:30:06 +0100 Subject: [PATCH 04/18] feat: send X-Tower-Idempotency-Key on deploy (#303) * feat: send X-Tower-Idempotency-Key on deploy Wire up the deploy command to send an idempotency key so consecutive deploys of unchanged source (e.g. to staging then production) collapse to a single AppVersion server-side instead of creating a new version each time. - Auto-populate the key from the git HEAD SHA when the working tree is clean; omit it on a dirty tree so provenance is never misrepresented. - Add --idempotency-key to override detection (useful for CI building outside a checkout) and --no-idempotency-key to opt out on a clean tree. - Print a hint when the server reuses an existing version so the user understands why no new version was created. - Apply the same git auto-detection to the MCP deploy tool. * test: add deploy idempotency key integration coverage BDD regression tests for the X-Tower-Idempotency-Key deploy behavior: explicit --idempotency-key, --no-idempotency-key opt-out, git auto-detect on a clean tree, header omission on a dirty tree, and the reuse hint on a repeat deploy with the same key. The mock API server now records the idempotency key seen on each deploy and reuses a stored (backdated) version when the same key recurs, exposing both via test-only inspection endpoints. * style: black-format deploy idempotency test files * chore: Cleanup to how the reuse hint is presented * chore: Don't really need to check output mode any longer * fix(test): match reuse-hint assertion to the new CLI wording Commit 560872b2 changed the deploy reuse hint from 'Reusing version ...' to 'No changes since commit ...' and updated the Rust unit test, but the behave step still asserted the old copy, failing the integration suite. --- crates/tower-cmd/src/deploy.rs | 170 +++++++++++++++++- crates/tower-cmd/src/mcp.rs | 15 +- crates/tower-cmd/src/output.rs | 13 ++ crates/tower-cmd/src/util/deploy.rs | 10 ++ crates/tower-cmd/src/util/git.rs | 136 ++++++++++++++ crates/tower-cmd/src/util/mod.rs | 1 + plugin/skills/tower/SKILL.md | 2 + .../features/cli_deploy_idempotency.feature | 40 +++++ tests/integration/features/steps/cli_steps.py | 81 +++++++++ tests/mock-api-server/main.py | 51 +++++- 10 files changed, 512 insertions(+), 7 deletions(-) create mode 100644 crates/tower-cmd/src/util/git.rs create mode 100644 tests/integration/features/cli_deploy_idempotency.feature diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index c5328f38..81cb4d7f 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -1,13 +1,20 @@ +use chrono::{DateTime, Duration, Utc}; use clap::{Arg, ArgMatches, Command}; use config::{Config, Towerfile}; use std::convert::From; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::{output, util}; use tower_api::apis::configuration::Configuration; use tower_package::{Package, PackageSpec}; use tower_telemetry::debug; +/// How far in the past a returned version's `created_at` must be before we treat +/// it as a reuse (rather than a fresh insert) for the stdout hint. A fresh +/// deploy stamps `created_at` at request time, so anything older than this was +/// created by an earlier deploy that shared the same idempotency key. +const REUSE_AGE_THRESHOLD: Duration = Duration::seconds(60); + pub fn deploy_cmd() -> Command { Command::new("deploy") .arg( @@ -38,6 +45,22 @@ pub fn deploy_cmd() -> Command { .action(clap::ArgAction::SetTrue) .conflicts_with("environment"), ) + .arg( + Arg::new("idempotency-key") + .long("idempotency-key") + .help( + "Reuse the existing version deployed with this key instead of creating a new one. \ + Defaults to the current git commit SHA when the working tree is clean.", + ) + .conflicts_with("no-idempotency-key"), + ) + .arg( + Arg::new("no-idempotency-key") + .long("no-idempotency-key") + .help("Never send an idempotency key, even on a clean git tree") + .action(clap::ArgAction::SetTrue) + .conflicts_with("idempotency-key"), + ) .about("Deploy your latest code to Tower") } @@ -59,9 +82,29 @@ pub enum DeployTarget { All, } +/// Resolves the idempotency key to send with the deploy, in precedence order: +/// +/// 1. `--no-idempotency-key` → never send a key. +/// 2. `--idempotency-key ` → send that exact value. +/// 3. Otherwise auto-detect the git `HEAD` SHA, but only when the working tree +/// is clean (a dirty tree gets no key, so every deploy creates a new version). +fn resolve_idempotency_key(args: &ArgMatches, dir: &Path) -> Option { + if args.get_flag("no-idempotency-key") { + debug!("--no-idempotency-key set; suppressing idempotency key"); + return None; + } + + if let Some(key) = args.get_one::("idempotency-key") { + return Some(key.clone()); + } + + util::git::clean_head_sha(dir) +} + pub async fn do_deploy(config: Config, args: &ArgMatches) { let dir = resolve_path(args); let create_app = args.get_flag("create"); + let idempotency_key = resolve_idempotency_key(args, &dir); let target = if args.get_flag("all") { DeployTarget::All @@ -71,7 +114,7 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { DeployTarget::Environment("default".to_string()) }; - if let Err(err) = deploy_from_dir(config, dir, create_app, target).await { + if let Err(err) = deploy_from_dir(config, dir, create_app, target, idempotency_key).await { match err { crate::Error::ApiDeployError { source } => { output::tower_error_and_die(source, "Deploying app failed") @@ -100,6 +143,7 @@ pub async fn deploy_from_dir( dir: PathBuf, create_app: bool, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { debug!("Building package from directory: {:?}", dir); @@ -135,7 +179,7 @@ pub async fn deploy_from_dir( }; spinner.success(); - do_deploy_package(api_config, package, &towerfile, target).await + do_deploy_package(api_config, package, &towerfile, target, idempotency_key).await } async fn do_deploy_package( @@ -143,6 +187,7 @@ async fn do_deploy_package( package: Package, towerfile: &Towerfile, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { let (environment, all_environments) = match &target { DeployTarget::All => (None, true), @@ -155,6 +200,7 @@ async fn do_deploy_package( package, environment, all_environments, + idempotency_key.as_deref(), ) .await; @@ -172,15 +218,49 @@ async fn do_deploy_package( ), }; output::success(&line); + + if let Some(hint) = reuse_hint(&version, idempotency_key.as_deref()) { + output::muted(&hint); + } + Ok(()) } Err(err) => Err(crate::Error::ApiDeployError { source: err }), } } +/// Builds the "reused an existing version" hint, or `None` when the deploy +/// created a fresh version. +/// +/// We treat the returned version as a reuse when (a) we sent an idempotency key, +/// (b) the server echoed it back on the version, and (c) the version's +/// `created_at` is meaningfully older than now — i.e. it predates this deploy. +fn reuse_hint(version: &tower_api::models::AppVersion, sent_key: Option<&str>) -> Option { + let sent_key = sent_key?; + + if version.idempotency_key.as_deref() != Some(sent_key) { + return None; + } + + let created_at = DateTime::parse_from_rfc3339(&version.created_at) + .ok()? + .with_timezone(&Utc); + + if Utc::now().signed_duration_since(created_at) < REUSE_AGE_THRESHOLD { + return None; + } + + Some(format!( + "No changes since commit {} (deployed on {})", + sent_key, + util::dates::format(created_at), + )) +} + #[cfg(test)] mod tests { - use super::deploy_cmd; + use super::*; + use tower_api::models::AppVersion; fn parse(args: &[&str]) -> Result { let mut full = vec!["deploy"]; @@ -188,6 +268,17 @@ mod tests { deploy_cmd().try_get_matches_from(full) } + fn version_with(idempotency_key: Option<&str>, created_at: &str) -> AppVersion { + AppVersion { + created_at: created_at.to_string(), + parameters: vec![], + towerfile: String::new(), + version: "v3".to_string(), + idempotency_key: idempotency_key.map(|s| s.to_string()), + content_checksum: None, + } + } + #[test] fn no_args_uses_defaults() { let m = parse(&[]).unwrap(); @@ -276,4 +367,75 @@ mod tests { let err = parse(&["--help"]).unwrap_err(); assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); } + + #[test] + fn idempotency_key_flag() { + let m = parse(&["--idempotency-key", "abc123"]).unwrap(); + assert_eq!( + m.get_one::("idempotency-key").map(|s| s.as_str()), + Some("abc123") + ); + } + + #[test] + fn no_idempotency_key_flag() { + let m = parse(&["--no-idempotency-key"]).unwrap(); + assert!(m.get_flag("no-idempotency-key")); + } + + #[test] + fn idempotency_key_and_no_idempotency_key_conflict() { + let err = parse(&["--idempotency-key", "abc", "--no-idempotency-key"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn resolve_key_prefers_explicit_flag() { + let m = parse(&["--idempotency-key", "explicit"]).unwrap(); + // A non-existent path guarantees git auto-detection can't interfere. + let key = resolve_idempotency_key(&m, Path::new("/nonexistent/path")); + assert_eq!(key.as_deref(), Some("explicit")); + } + + #[test] + fn resolve_key_opt_out_wins_over_git() { + let m = parse(&["--no-idempotency-key"]).unwrap(); + // Even pointed at the (clean-or-dirty) repo we're in, opt-out yields None. + let key = resolve_idempotency_key(&m, Path::new(".")); + assert_eq!(key, None); + } + + #[test] + fn resolve_key_none_outside_git() { + let m = parse(&[]).unwrap(); + let key = resolve_idempotency_key(&m, Path::new("/nonexistent/path")); + assert_eq!(key, None); + } + + #[test] + fn reuse_hint_none_without_sent_key() { + let version = version_with(Some("abc"), "2020-01-01T00:00:00Z"); + assert_eq!(reuse_hint(&version, None), None); + } + + #[test] + fn reuse_hint_none_when_key_mismatch() { + let version = version_with(Some("other"), "2020-01-01T00:00:00Z"); + assert_eq!(reuse_hint(&version, Some("abc")), None); + } + + #[test] + fn reuse_hint_none_for_freshly_created_version() { + // created_at "now" → not a reuse, no hint. + let now = Utc::now().to_rfc3339(); + let version = version_with(Some("abc"), &now); + assert_eq!(reuse_hint(&version, Some("abc")), None); + } + + #[test] + fn reuse_hint_present_for_old_matching_version() { + let version = version_with(Some("abc123"), "2020-01-01T00:00:00Z"); + let hint = reuse_hint(&version, Some("abc123")).expect("expected a reuse hint"); + assert!(hint.contains("No changes since commit abc123")); + } } diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index 6d406c22..7d69056f 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -812,7 +812,20 @@ impl TowerService { let env = request.environment.unwrap_or_else(|| "default".to_string()); let deploy_target = deploy::DeployTarget::Environment(env); - match deploy::deploy_from_dir(self.config.clone(), working_dir, true, deploy_target).await { + // Auto-detect the idempotency key from git (clean tree HEAD) just like + // the CLI deploy command does, so repeated deploys of unchanged source + // collapse to a single AppVersion server-side. + let idempotency_key = crate::util::git::clean_head_sha(&working_dir); + + match deploy::deploy_from_dir( + self.config.clone(), + working_dir, + true, + deploy_target, + idempotency_key, + ) + .await + { Ok(_) => Self::text_success("Deploy completed successfully".to_string()), Err(e) => Self::error_result("Deploy failed", e), } diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index 12743ec1..bd8b28ba 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -115,6 +115,19 @@ pub fn success(msg: &str) { success_with_data(msg, None::); } +pub fn muted(msg: &str) { + if get_output_mode().is_json() { + let response = serde_json::json!({ + "result": "muted", + "message": msg + }); + json(&response); + } else { + let line = msg.dimmed(); + write(&line); + } +} + pub fn success_with_data(msg: &str, data: Option) { let mut response = serde_json::json!({ "result": "success", diff --git a/crates/tower-cmd/src/util/deploy.rs b/crates/tower-cmd/src/util/deploy.rs index cc19fcb9..9931a395 100644 --- a/crates/tower-cmd/src/util/deploy.rs +++ b/crates/tower-cmd/src/util/deploy.rs @@ -19,6 +19,7 @@ pub async fn upload_file_with_progress( endpoint_url: String, file_path: PathBuf, content_type: &str, + idempotency_key: Option<&str>, progress_cb: Box, ) -> Result> { let package_hash = match compute_sha256_file(&file_path).await { @@ -57,6 +58,13 @@ pub async fn upload_file_with_progress( .header("Content-Encoding", "gzip") .body(Body::wrap_stream(progress_stream)); + // When supplied, ask the server to reuse an existing AppVersion that was + // deployed with the same key (e.g. a git commit SHA) instead of creating a + // new one. + if let Some(key) = idempotency_key { + req = req.header("X-Tower-Idempotency-Key", key); + } + // Add authorization if available. Mirrors the generated tower-api client: prefer a // bearer token (interactive session), otherwise fall back to the API key header set // when TOWER_API_KEY is configured. @@ -107,6 +115,7 @@ pub async fn deploy_app_package( package: Package, environment: Option<&str>, all_environments: bool, + idempotency_key: Option<&str>, ) -> Result> { let progress_bar = Arc::new(Mutex::new(output::progress_bar("Deploying to Tower..."))); @@ -150,6 +159,7 @@ pub async fn deploy_app_package( url, package_path, "application/tar", + idempotency_key, progress_callback, ) .await?; diff --git a/crates/tower-cmd/src/util/git.rs b/crates/tower-cmd/src/util/git.rs new file mode 100644 index 00000000..d39181ee --- /dev/null +++ b/crates/tower-cmd/src/util/git.rs @@ -0,0 +1,136 @@ +use std::path::Path; +use std::process::Command; +use tower_telemetry::debug; + +/// Resolves the commit SHA of `HEAD` for the git worktree containing `dir`, but +/// only when the working tree is clean. Returns `None` when: +/// +/// - `dir` is not inside a git worktree, +/// - the working tree has uncommitted changes (we must not claim provenance the +/// bundle doesn't actually have), +/// - `git` isn't installed or any git invocation fails. +/// +/// This is used to auto-populate the `X-Tower-Idempotency-Key` header on deploy. +pub fn clean_head_sha(dir: &Path) -> Option { + if !is_inside_work_tree(dir) { + debug!("{:?} is not inside a git worktree; skipping idempotency key", dir); + return None; + } + + if is_dirty(dir) { + debug!("git worktree at {:?} is dirty; omitting idempotency key", dir); + return None; + } + + let sha = head_sha(dir)?; + debug!("resolved clean git HEAD {} for {:?}", sha, dir); + Some(sha) +} + +fn git(dir: &Path, args: &[&str]) -> Option { + match Command::new("git").arg("-C").arg(dir).args(args).output() { + Ok(output) => Some(output), + Err(err) => { + debug!("failed to invoke git {:?}: {}", args, err); + None + } + } +} + +fn is_inside_work_tree(dir: &Path) -> bool { + let output = match git(dir, &["rev-parse", "--is-inside-work-tree"]) { + Some(output) => output, + None => return false, + }; + + output.status.success() + && String::from_utf8_lossy(&output.stdout).trim() == "true" +} + +/// Returns `true` when there are staged, unstaged, or untracked changes in the +/// worktree. Anything other than a confidently-clean tree is treated as dirty. +fn is_dirty(dir: &Path) -> bool { + let output = match git(dir, &["status", "--porcelain"]) { + Some(output) => output, + None => return true, + }; + + if !output.status.success() { + return true; + } + + !String::from_utf8_lossy(&output.stdout).trim().is_empty() +} + +fn head_sha(dir: &Path) -> Option { + let output = git(dir, &["rev-parse", "HEAD"])?; + + if !output.status.success() { + return None; + } + + let sha = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if sha.is_empty() { + None + } else { + Some(sha) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use tempfile::TempDir; + + fn run(dir: &Path, args: &[&str]) { + let status = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .status() + .expect("git invocation failed"); + assert!(status.success(), "git {:?} failed", args); + } + + fn init_repo() -> TempDir { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + run(dir, &["init", "--quiet"]); + run(dir, &["config", "user.email", "test@example.com"]); + run(dir, &["config", "user.name", "Test User"]); + tmp + } + + #[test] + fn non_git_dir_returns_none() { + let tmp = TempDir::new().unwrap(); + assert_eq!(clean_head_sha(tmp.path()), None); + } + + #[test] + fn clean_tree_returns_head_sha() { + let tmp = init_repo(); + let dir = tmp.path(); + std::fs::write(dir.join("file.txt"), "hello").unwrap(); + run(dir, &["add", "."]); + run(dir, &["commit", "--quiet", "-m", "initial"]); + + let sha = clean_head_sha(dir).expect("expected a sha on a clean tree"); + assert_eq!(sha.len(), 40, "expected a full 40-char sha, got {sha}"); + } + + #[test] + fn dirty_tree_returns_none() { + let tmp = init_repo(); + let dir = tmp.path(); + std::fs::write(dir.join("file.txt"), "hello").unwrap(); + run(dir, &["add", "."]); + run(dir, &["commit", "--quiet", "-m", "initial"]); + + // Untracked file makes the tree dirty. + std::fs::write(dir.join("dirty.txt"), "uncommitted").unwrap(); + assert_eq!(clean_head_sha(dir), None); + } +} diff --git a/crates/tower-cmd/src/util/mod.rs b/crates/tower-cmd/src/util/mod.rs index 3ce9f5bc..f947e70e 100644 --- a/crates/tower-cmd/src/util/mod.rs +++ b/crates/tower-cmd/src/util/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod cmd; pub mod dates; pub mod deploy; +pub mod git; pub mod progress; pub mod prompt; pub mod text; diff --git a/plugin/skills/tower/SKILL.md b/plugin/skills/tower/SKILL.md index 760e46e1..8d1b31e9 100644 --- a/plugin/skills/tower/SKILL.md +++ b/plugin/skills/tower/SKILL.md @@ -346,6 +346,8 @@ Deploy your latest code to Tower - `-f`, `--create` — Automatically force creation of the app if it doesn't already exist - `-e`, `--environment` — The environment to deploy to - `--all` — Deploy to all environments +- `--idempotency-key ` — Reuse the existing version deployed with this key instead of creating a new one. Defaults to the current git commit SHA when the working tree is clean, so deploying unchanged source to multiple environments collapses to a single version. +- `--no-idempotency-key` — Never send an idempotency key, even on a clean git tree ### `tower run` diff --git a/tests/integration/features/cli_deploy_idempotency.feature b/tests/integration/features/cli_deploy_idempotency.feature new file mode 100644 index 00000000..0fe6ca2f --- /dev/null +++ b/tests/integration/features/cli_deploy_idempotency.feature @@ -0,0 +1,40 @@ +@serial @deploy +Feature: CLI Deploy Idempotency Key + As a developer promoting unchanged source across environments + I want tower deploy to send an X-Tower-Idempotency-Key + So that consecutive deploys of identical source reuse a single version + + Scenario: Deploy forwards an explicit idempotency key to the server + Given I have a valid Towerfile in the current directory + And the deploy log is reset + When I run "tower deploy --create --idempotency-key explicit-key-123" via CLI + Then the last deploy should have been sent with idempotency key "explicit-key-123" + + Scenario: Deploy with --no-idempotency-key suppresses the header + Given I have a valid Towerfile in the current directory + And the current directory is a clean git repository + And the deploy log is reset + When I run "tower deploy --create --no-idempotency-key" via CLI + Then the last deploy should have been sent without an idempotency key + + Scenario: Deploy auto-detects the git commit SHA on a clean tree + Given I have a valid Towerfile in the current directory + And the current directory is a clean git repository + And the deploy log is reset + When I run "tower deploy --create" via CLI + Then the last deploy should have been sent with the current git commit SHA + + Scenario: Deploy omits the key on a dirty git tree + Given I have a valid Towerfile in the current directory + And the current directory is a clean git repository + And the working tree has uncommitted changes + And the deploy log is reset + When I run "tower deploy --create" via CLI + Then the last deploy should have been sent without an idempotency key + + Scenario: Re-deploying with the same key reuses the existing version + Given I have a valid Towerfile in the current directory + And the deploy log is reset + When I run "tower deploy --create --idempotency-key reuse-key" via CLI + And I run "tower deploy --idempotency-key reuse-key" via CLI + Then the CLI output should indicate the version was reused diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index 24105289..a6f2d471 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -526,3 +526,84 @@ def step_json_should_contain_all_created_apps(context): listed.add(entry.get("name")) missing = [name for name in context.created_app_names if name not in listed] assert not missing, f"Missing {len(missing)} apps from JSON: {missing}" + + +# Deploy idempotency key steps (X-Tower-Idempotency-Key) + + +def _strip_ansi(text): + return re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", text) + + +def _git(context, *args): + """Run a git command in the scenario's temp working directory.""" + result = subprocess.run( + ["git", "-C", context.temp_dir, *args], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, f"git {args} failed: {result.stderr}" + return result.stdout.strip() + + +def _last_deploy(context): + resp = requests.get(f"{context.tower_url}/test/deploy-log", timeout=10) + resp.raise_for_status() + deploys = resp.json().get("deploys", []) + # Filter to this scenario's app (names are unique per scenario) so the + # assertion can't pick up a deploy from another scenario. + if hasattr(context, "app_name"): + deploys = [d for d in deploys if d.get("name") == context.app_name] + assert ( + deploys + ), "Expected at least one deploy to have been recorded by the mock server" + return deploys[-1] + + +@given("the deploy log is reset") +def step_reset_deploy_log(context): + """Clear the mock server's record of received deploys/idempotency keys.""" + resp = requests.post(f"{context.tower_url}/test/reset-deploy-log", timeout=10) + resp.raise_for_status() + + +@given("the current directory is a clean git repository") +def step_init_clean_git_repo(context): + """Initialize a git repo in the temp dir and commit everything so the tree is clean.""" + _git(context, "init", "--quiet") + _git(context, "config", "user.email", "test@example.com") + _git(context, "config", "user.name", "Test User") + _git(context, "add", "-A") + _git(context, "commit", "--quiet", "-m", "initial") + + +@given("the working tree has uncommitted changes") +def step_make_working_tree_dirty(context): + """Add an untracked file so the git working tree is dirty.""" + Path(context.temp_dir, "uncommitted.txt").write_text("dirty") + + +@then('the last deploy should have been sent with idempotency key "{key}"') +def step_last_deploy_has_key(context, key): + actual = _last_deploy(context).get("idempotency_key") + assert actual == key, f"Expected idempotency key '{key}', got '{actual}'" + + +@then("the last deploy should have been sent without an idempotency key") +def step_last_deploy_has_no_key(context): + actual = _last_deploy(context).get("idempotency_key") + assert actual is None, f"Expected no idempotency key, got '{actual}'" + + +@then("the last deploy should have been sent with the current git commit SHA") +def step_last_deploy_has_git_sha(context): + expected = _git(context, "rev-parse", "HEAD") + actual = _last_deploy(context).get("idempotency_key") + assert actual == expected, f"Expected git SHA '{expected}', got '{actual}'" + + +@then("the CLI output should indicate the version was reused") +def step_output_indicates_reuse(context): + output = _strip_ansi(context.cli_output) + assert "No changes since commit" in output, f"Expected a reuse hint, got: {output}" diff --git a/tests/mock-api-server/main.py b/tests/mock-api-server/main.py index ce477eec..2cce0c64 100644 --- a/tests/mock-api-server/main.py +++ b/tests/mock-api-server/main.py @@ -49,6 +49,14 @@ async def log_requests(request: Request, call_next): mock_schedules_db = {} mock_deployed_apps = set() # Track which apps have been deployed +# Idempotency support (mirrors the real server's X-Tower-Idempotency-Key behavior). +# mock_deploy_log records the idempotency key seen on every deploy (None when the +# header was absent) so tests can assert exactly what the CLI sent. +mock_deploy_log = [] # list of {"name": str, "idempotency_key": Optional[str]} +# mock_idempotent_versions maps (app_name, key) -> a stored version dict that is +# returned verbatim on a repeat deploy with the same key. +mock_idempotent_versions = {} + # Pre-populate with test-app for CLI validation/spinner tests mock_apps_db["predeployed-test-app"] = { "name": "predeployed-test-app", @@ -219,23 +227,62 @@ async def delete_app(name: str): @app.post("/v1/apps/{name}/deploy") -async def deploy_app(name: str, response: Response): +async def deploy_app(name: str, request: Request, response: Response): if name not in mock_apps_db: raise HTTPException(status_code=404, detail=f"App '{name}' not found") + + # Capture the idempotency key the CLI sent (None when the header is absent) + # so tests can assert provenance behavior. + idempotency_key = request.headers.get("x-tower-idempotency-key") + mock_deploy_log.append({"name": name, "idempotency_key": idempotency_key}) + + # Idempotency hit: a prior deploy supplied the same key for this app. Return + # the stored version verbatim, including its original (past) created_at, which + # is what lets the CLI recognize the reuse and print its hint. + if idempotency_key and (name, idempotency_key) in mock_idempotent_versions: + return {"version": mock_idempotent_versions[(name, idempotency_key)]} + # Simulate a successful deployment version_num = "1.0.0" # Simplified versioning deployed_version = { "version": version_num, "parameters": [], - "created_at": datetime.datetime.now().isoformat(), + "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "towerfile": "mock_towerfile_content", + "idempotency_key": idempotency_key, + "content_checksum": "mock-content-checksum", } # Update app's version and mark as deployed mock_apps_db[name]["version"] = version_num mock_deployed_apps.add(name) + + # Remember this version under its key so the next deploy with the same key is + # treated as a reuse. The stored created_at is backdated so the reuse is + # unambiguously "older than now" regardless of how fast the test runs. + if idempotency_key: + stored = dict(deployed_version) + stored["created_at"] = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1) + ).isoformat() + mock_idempotent_versions[(name, idempotency_key)] = stored + return {"version": deployed_version} +@app.get("/test/deploy-log") +async def get_deploy_log(): + """Test-only: return the idempotency key seen on every deploy so far.""" + return {"deploys": mock_deploy_log} + + +@app.post("/test/reset-deploy-log") +async def reset_deploy_log(): + """Test-only: clear deploy bookkeeping so each scenario starts clean.""" + mock_deploy_log.clear() + mock_idempotent_versions.clear() + return {"ok": True} + + @app.post("/v1/apps/{name}/runs", status_code=201) async def run_app(name: str, run_params: Dict[str, Any]): if name not in mock_apps_db: From 1b5dfd6dee79fe2fe0306dcca354b41aaabca8b8 Mon Sep 17 00:00:00 2001 From: Ben Lovell Date: Wed, 15 Jul 2026 14:54:58 +0200 Subject: [PATCH 05/18] refactor(cli): replace the output globals with an explicit `Out` (#322) * refactor(cli): replace the output globals with an explicit `Out` Where CLI output went was controlled by two mutable globals: an OUTPUT_MODE and a CURRENT_SENDER that the MCP server set and cleared around each tool call. Every output:: function read them to choose between stdout, JSON, and the MCP peer, so a command never said where its own output went. Pass the destination in as a value instead: commands take an Out and the writing functions become methods on it. The CLI builds one over stdout; an MCP tool builds one whose writer forwards each line to that call's channel. The sender and the notification flag live on the value, not in globals. * chore: rustfmt util/git.rs --- crates/tower-cmd/src/apps.rs | 241 +++---- crates/tower-cmd/src/beta.rs | 37 +- crates/tower-cmd/src/catalogs.rs | 42 +- crates/tower-cmd/src/deploy.rs | 40 +- crates/tower-cmd/src/environments.rs | 46 +- crates/tower-cmd/src/lib.rs | 76 ++- crates/tower-cmd/src/mcp.rs | 84 +-- crates/tower-cmd/src/output.rs | 980 ++++++++++++++------------- crates/tower-cmd/src/package.rs | 20 +- crates/tower-cmd/src/run.rs | 165 +++-- crates/tower-cmd/src/schedules.rs | 68 +- crates/tower-cmd/src/secrets.rs | 47 +- crates/tower-cmd/src/session.rs | 45 +- crates/tower-cmd/src/teams.rs | 54 +- crates/tower-cmd/src/util/apps.rs | 15 +- crates/tower-cmd/src/util/cmd.rs | 4 +- crates/tower-cmd/src/util/deploy.rs | 11 +- crates/tower-cmd/src/util/git.rs | 13 +- crates/tower-cmd/src/version.rs | 6 +- 19 files changed, 1056 insertions(+), 938 deletions(-) diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index 5fc707c1..ad7256b2 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -1,4 +1,5 @@ use clap::{value_parser, Arg, ArgMatches, Command}; +use colored::Colorize; use config::Config; use tokio::sync::oneshot; use tokio::time::{sleep, Duration, Instant}; @@ -116,123 +117,118 @@ pub fn apps_cmd() -> Command { ) } -pub async fn do_logs(config: Config, cmd: &ArgMatches) { +pub async fn do_logs(out: &output::Out, config: Config, cmd: &ArgMatches) { let app_name_raw = cmd .get_one::("app_name") .expect("app_name is required"); let (name, seq) = if let Some((name, num_str)) = app_name_raw.split_once('#') { let num = num_str .parse::() - .unwrap_or_else(|_| output::die("Run number must be a number")); + .unwrap_or_else(|_| out.die("Run number must be a number")); (name.to_string(), num) } else { let num = match cmd.get_one::("run_number").copied() { Some(n) => n, - None => latest_run_number(&config, app_name_raw).await, + None => latest_run_number(out, &config, app_name_raw).await, }; (app_name_raw.clone(), num) }; let follow = cmd.get_one::("follow").copied().unwrap_or(false); if follow { - follow_logs(config, name, seq).await; + follow_logs(out, config, name, seq).await; return; } if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await { for line in resp.log_lines { - output::remote_log_event(&line); + out.remote_log_event(&line); } } } -pub async fn do_show(config: Config, cmd: &ArgMatches) { +pub async fn do_show(out: &output::Out, config: Config, cmd: &ArgMatches) { let name = cmd .get_one::("app_name") .expect("app_name is required"); let env = cmd::get_string_flag(cmd, "environment"); match api::describe_app(&config, &name, Some(&env)).await { - Ok(app_response) => { - if output::get_output_mode().is_json() { - output::json(&app_response); - return; - } + Ok(app_response) => out.text(&app_details_text(&app_response), &app_response), + Err(err) => out.tower_error_and_die(err, "Fetching app details failed"), + } +} - let app = &app_response.app; - let runs = &app_response.runs; - - output::detail("Name", &app.name); - output::header("Description"); - let line = output::paragraph(&app.short_description); - output::write(&line); - output::newline(); - output::newline(); - output::header("Recent runs"); - - let headers = vec!["#", "Status", "Start Time", "Elapsed Time"] - .into_iter() - .map(str::to_string) - .collect(); - - let rows = runs - .iter() - .map(|run: &Run| { - let status = &run.status; - let status_str = format!("{:?}", status); - - // Format start time - let start_time = if let Some(started_at) = &run.started_at { - if !started_at.is_empty() { - started_at.to_string() - } else { - format!("Scheduled at {}", &run.scheduled_at) - } - } else { - format!("Scheduled at {}", &run.scheduled_at) - }; - - // Calculate elapsed time - let elapsed_time = if let Some(ended_at) = &run.ended_at { - if !ended_at.is_empty() { - if let (Some(started_at), Some(ended_at)) = - (&run.started_at, &run.ended_at) - { - let start = - started_at.parse::>().ok(); - let end = ended_at.parse::>().ok(); - if let (Some(start), Some(end)) = (start, end) { - format!("{:.1}s", (end - start).num_seconds()) - } else { - "Invalid time".into() - } - } else { - "Invalid time".into() - } - } else if run.started_at.is_some() { - "Running".into() +fn app_details_text(response: &tower_api::models::DescribeAppResponse) -> String { + let app = &response.app; + let mut text = String::new(); + + text.push_str(&format!("{} {}\n", "Name:".bold().green(), app.name)); + text.push_str(&format!("{}\n", "Description".bold().green())); + text.push_str(&output::paragraph(&app.short_description)); + text.push_str("\n\n"); + text.push_str(&format!("{}\n", "Recent runs".bold().green())); + + let headers = vec!["#", "Status", "Start Time", "Elapsed Time"] + .into_iter() + .map(str::to_string) + .collect(); + + let rows = response + .runs + .iter() + .map(|run: &Run| { + let status_str = format!("{:?}", &run.status); + + // Format start time + let start_time = if let Some(started_at) = &run.started_at { + if !started_at.is_empty() { + started_at.to_string() + } else { + format!("Scheduled at {}", &run.scheduled_at) + } + } else { + format!("Scheduled at {}", &run.scheduled_at) + }; + + // Calculate elapsed time + let elapsed_time = if let Some(ended_at) = &run.ended_at { + if !ended_at.is_empty() { + if let (Some(started_at), Some(ended_at)) = (&run.started_at, &run.ended_at) { + let start = started_at.parse::>().ok(); + let end = ended_at.parse::>().ok(); + if let (Some(start), Some(end)) = (start, end) { + format!("{:.1}s", (end - start).num_seconds()) } else { - "Pending".into() + "Invalid time".into() } - } else if run.started_at.is_some() { - "Running".into() } else { - "Pending".into() - }; + "Invalid time".into() + } + } else if run.started_at.is_some() { + "Running".into() + } else { + "Pending".into() + } + } else if run.started_at.is_some() { + "Running".into() + } else { + "Pending".into() + }; - vec![run.number.to_string(), status_str, start_time, elapsed_time] - }) - .collect(); + vec![run.number.to_string(), status_str, start_time, elapsed_time] + }) + .collect(); - output::table(headers, rows, Some(&app_response)); - } - Err(err) => output::tower_error_and_die(err, "Fetching app details failed"), - } + text.push_str(&format!("{}\n", output::table_text(headers, rows))); + text } -pub async fn do_list_apps(config: Config, args: &ArgMatches) { +pub async fn do_list_apps(out: &output::Out, config: Config, args: &ArgMatches) { let env = args.get_one::("environment").map(|s| s.as_str()); - let apps = output::with_spinner("Listing apps", api::list_apps(&config, env)).await; + let apps = out + .with_spinner("Listing apps", api::list_apps(&config, env)) + .await; let items = apps .iter() @@ -246,31 +242,33 @@ pub async fn do_list_apps(config: Config, args: &ArgMatches) { format!("{}\n{}", output::title(&app.name), desc) }) .collect(); - output::list(items, Some(&apps)); + out.list(items, Some(&apps)); } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &output::Out, config: Config, args: &ArgMatches) { let name = args.get_one::("name").unwrap_or_else(|| { - output::die("App name (--name) is required"); + out.die("App name (--name) is required"); }); let description = args.get_one::("description").unwrap(); - let app = - output::with_spinner("Creating app", api::create_app(&config, name, description)).await; + let app = out + .with_spinner("Creating app", api::create_app(&config, name, description)) + .await; - output::success_with_data(&format!("App '{}' created", name), Some(app)); + out.success_with_data(&format!("App '{}' created", name), Some(app)); } -pub async fn do_delete(config: Config, cmd: &ArgMatches) { +pub async fn do_delete(out: &output::Out, config: Config, cmd: &ArgMatches) { let name = cmd .get_one::("app_name") .expect("app_name is required"); - output::with_spinner("Deleting app", api::delete_app(&config, name)).await; + out.with_spinner("Deleting app", api::delete_app(&config, name)) + .await; } -pub async fn do_cancel(config: Config, cmd: &ArgMatches) { +pub async fn do_cancel(out: &output::Out, config: Config, cmd: &ArgMatches) { let name = cmd .get_one::("app_name") .expect("app_name should be required"); @@ -279,26 +277,27 @@ pub async fn do_cancel(config: Config, cmd: &ArgMatches) { .copied() .expect("run_number should be required"); - let response = - output::with_spinner("Cancelling run", api::cancel_run(&config, name, seq)).await; + let response = out + .with_spinner("Cancelling run", api::cancel_run(&config, name, seq)) + .await; let run = &response.run; let status = format!("{:?}", run.status); - output::success_with_data( + out.success_with_data( &format!("Run #{} for '{}' cancelled (status: {})", seq, name, status), Some(response), ); } -async fn latest_run_number(config: &Config, name: &str) -> i64 { +async fn latest_run_number(out: &output::Out, config: &Config, name: &str) -> i64 { match api::describe_app(config, name, None).await { Ok(resp) => resp .runs .iter() .map(|r| r.number) .max() - .unwrap_or_else(|| output::die(&format!("No runs found for app '{}'", name))), - Err(err) => output::tower_error_and_die(err, "Fetching app details failed"), + .unwrap_or_else(|| out.die(&format!("No runs found for app '{}'", name))), + Err(err) => out.tower_error_and_die(err, "Fetching app details failed"), } } @@ -309,8 +308,7 @@ const RUN_START_POLL_INTERVAL: Duration = Duration::from_millis(500); const RUN_START_MESSAGE_DELAY: Duration = Duration::from_secs(3); const RUN_START_TIMEOUT: Duration = Duration::from_secs(30); -async fn follow_logs(config: Config, name: String, seq: i64) { - let enable_ctrl_c = !output::get_output_mode().is_mcp(); +async fn follow_logs(out: &output::Out, config: Config, name: String, seq: i64) { let mut backoff = FOLLOW_BACKOFF_INITIAL; let mut cancel_monitor: Option> = None; let mut last_line_num: Option = None; @@ -318,13 +316,13 @@ async fn follow_logs(config: Config, name: String, seq: i64) { loop { let mut run = match api::describe_run(&config, &name, seq).await { Ok(res) => res.run, - Err(err) => output::tower_error_and_die(err, "Fetching run details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching run details failed"), }; if is_run_finished(&run) { if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await { for line in resp.log_lines { - emit_log_if_new(&line, &mut last_line_num); + emit_log_if_new(out, &line, &mut last_line_num); } } return; @@ -337,25 +335,23 @@ async fn follow_logs(config: Config, name: String, seq: i64) { sleep(RUN_START_POLL_INTERVAL).await; if wait_started.elapsed() > RUN_START_TIMEOUT { - output::error( - "Timed out waiting for run to start. The runner may be unavailable.", - ); + out.error("Timed out waiting for run to start. The runner may be unavailable."); return; } // Avoid blank output on slow starts while keeping fast starts quiet. if should_notify_run_wait(notified, wait_started.elapsed()) { - output::write("Waiting for run to start...\n"); + out.write("Waiting for run to start...\n"); notified = true; } run = match api::describe_run(&config, &name, seq).await { Ok(res) => res.run, - Err(err) => output::tower_error_and_die(err, "Fetching run details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching run details failed"), }; if is_run_finished(&run) { if let Ok(resp) = api::describe_run_logs(&config, &name, seq).await { for line in resp.log_lines { - emit_log_if_new(&line, &mut last_line_num); + emit_log_if_new(out, &line, &mut last_line_num); } } return; @@ -378,9 +374,10 @@ async fn follow_logs(config: Config, name: String, seq: i64) { // Reset after a successful connection so transient drops recover quickly. backoff = FOLLOW_BACKOFF_INITIAL; match stream_logs_until_complete( + out, log_stream, run_complete, - enable_ctrl_c, + out.foreground(), &run.dollar_link, &mut last_line_num, ) @@ -409,10 +406,10 @@ async fn follow_logs(config: Config, name: String, seq: i64) { } Err(err) => { if is_fatal_stream_error(&err) { - output::error(&format!("Failed to stream run logs: {}", err)); + out.error(&format!("Failed to stream run logs: {}", err)); return; } - output::error(&format!("Failed to stream run logs: {}", err)); + out.error(&format!("Failed to stream run logs: {}", err)); sleep(backoff).await; backoff = next_backoff(backoff); continue; @@ -421,7 +418,7 @@ async fn follow_logs(config: Config, name: String, seq: i64) { let latest = match api::describe_run(&config, &name, seq).await { Ok(res) => res.run, - Err(err) => output::tower_error_and_die(err, "Fetching run details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching run details failed"), }; if is_run_finished(&latest) { return; @@ -448,6 +445,7 @@ enum LogFollowOutcome { } async fn stream_logs_until_complete( + out: &output::Out, mut log_stream: tokio::sync::mpsc::Receiver, mut run_complete: oneshot::Receiver, enable_ctrl_c: bool, @@ -458,17 +456,17 @@ async fn stream_logs_until_complete( tokio::select! { event = log_stream.recv() => match event { Some(api::LogStreamEvent::EventLog(log)) => { - emit_log_if_new(&log, last_line_num); + emit_log_if_new(out, &log, last_line_num); }, Some(api::LogStreamEvent::EventWarning(warning)) => { - output::write(&format!("Warning: {}\n", warning.data.content)); + out.write(&format!("Warning: {}\n", warning.data.content)); } None => return Ok(LogFollowOutcome::Disconnected), }, res = &mut run_complete => { match res { Ok(_) => { - drain_remaining_logs(log_stream, last_line_num).await; + drain_remaining_logs(out, log_stream, last_line_num).await; return Ok(LogFollowOutcome::Completed); } // If monitoring failed, keep following and let the caller retry. @@ -476,9 +474,9 @@ async fn stream_logs_until_complete( } }, _ = tokio::signal::ctrl_c(), if enable_ctrl_c => { - output::write("Received Ctrl+C, stopping log streaming...\n"); - output::write("Note: The run will continue in Tower cloud\n"); - output::write(&format!(" See more: {}\n", run_link)); + out.write("Received Ctrl+C, stopping log streaming...\n"); + out.write("Note: The run will continue in Tower cloud\n"); + out.write(&format!(" See more: {}\n", run_link)); return Ok(LogFollowOutcome::Interrupted); }, } @@ -486,6 +484,7 @@ async fn stream_logs_until_complete( } async fn drain_remaining_logs( + out: &output::Out, mut log_stream: tokio::sync::mpsc::Receiver, last_line_num: &mut Option, ) { @@ -493,10 +492,10 @@ async fn drain_remaining_logs( while let Some(event) = log_stream.recv().await { match event { api::LogStreamEvent::EventLog(log) => { - emit_log_if_new(&log, last_line_num); + emit_log_if_new(out, &log, last_line_num); } api::LogStreamEvent::EventWarning(warning) => { - output::write(&format!("Warning: {}\n", warning.data.content)); + out.write(&format!("Warning: {}\n", warning.data.content)); } } } @@ -504,9 +503,9 @@ async fn drain_remaining_logs( .await; } -fn emit_log_if_new(log: &RunLogLine, last_line_num: &mut Option) { +fn emit_log_if_new(out: &output::Out, log: &RunLogLine, last_line_num: &mut Option) { if should_emit_line(last_line_num, log.line_num) { - output::remote_log_event(log); + out.remote_log_event(log); } } @@ -555,7 +554,7 @@ fn monitor_run_completion( Err(_) => { failures += 1; if failures >= 5 { - output::error( + output::background_error( "Failed to monitor run completion after repeated errors", ); return; @@ -744,7 +743,9 @@ mod tests { drop(tx); }); - let res = stream_logs_until_complete(rx, done_rx, false, "link", &mut last_line_num).await; + let out = crate::output::Out::sink(); + let res = + stream_logs_until_complete(&out, rx, done_rx, false, "link", &mut last_line_num).await; done_task.await.unwrap(); assert!(matches!(res, Ok(LogFollowOutcome::Completed))); @@ -757,7 +758,9 @@ mod tests { let (_done_tx, done_rx) = oneshot::channel::(); let mut last_line_num = None; - let res = stream_logs_until_complete(rx, done_rx, false, "link", &mut last_line_num).await; + let out = crate::output::Out::sink(); + let res = + stream_logs_until_complete(&out, rx, done_rx, false, "link", &mut last_line_num).await; assert!(matches!(res, Ok(LogFollowOutcome::Disconnected))); } diff --git a/crates/tower-cmd/src/beta.rs b/crates/tower-cmd/src/beta.rs index fb6c0c29..09e746c4 100644 --- a/crates/tower-cmd/src/beta.rs +++ b/crates/tower-cmd/src/beta.rs @@ -2,7 +2,7 @@ use std::io::{self, IsTerminal}; use tower_telemetry::debug; -use crate::output::{self, OutputMode}; +use crate::output::{self, Out}; pub(crate) struct BetaFeature { id: &'static str, @@ -31,12 +31,10 @@ pub(crate) const STORAGE: BetaFeature = BetaFeature { docs_url: None, }; -pub(crate) fn notify_once(feature: &BetaFeature) { - let output_mode = output::get_output_mode(); - let stdout_is_terminal = io::stdout().is_terminal(); +pub(crate) fn notify_once(out: &Out, feature: &BetaFeature) { let stderr_is_terminal = io::stderr().is_terminal(); - if !should_notify(output_mode, stdout_is_terminal, stderr_is_terminal) { + if !should_notify(out.interactive(), out.foreground(), stderr_is_terminal) { return; } @@ -47,18 +45,16 @@ pub(crate) fn notify_once(feature: &BetaFeature) { } } -fn should_notify( - output_mode: OutputMode, - stdout_is_terminal: bool, - stderr_is_terminal: bool, -) -> bool { - output_mode.is_normal() && stdout_is_terminal && stderr_is_terminal +/// The notice only goes out for a foreground CLI driving an interactive terminal: +/// human output on a stdout TTY (never JSON or MCP capture), with stderr also a +/// TTY so the notice itself is seen. +fn should_notify(interactive: bool, foreground: bool, stderr_is_terminal: bool) -> bool { + interactive && foreground && stderr_is_terminal } #[cfg(test)] mod tests { use super::{should_notify, BetaFeature, STORAGE, STORAGE_BETA_MESSAGE}; - use crate::output::OutputMode; #[test] fn short_about_has_one_beta_suffix() { @@ -91,13 +87,14 @@ mod tests { } #[test] - fn notice_requires_normal_output_and_two_terminals() { - assert!(should_notify(OutputMode::Normal, true, true)); - assert!(!should_notify(OutputMode::Normal, false, true)); - assert!(!should_notify(OutputMode::Normal, true, false)); - assert!(!should_notify(OutputMode::Normal, false, false)); - assert!(!should_notify(OutputMode::Json, true, true)); - assert!(!should_notify(OutputMode::McpStdio, true, true)); - assert!(!should_notify(OutputMode::McpStreaming, true, true)); + fn notice_requires_interactive_foreground_and_stderr_terminal() { + assert!(should_notify(true, true, true)); + // stdout not an interactive terminal (redirected, JSON, or MCP capture) + assert!(!should_notify(false, true, true)); + // not a foreground CLI (MCP or discarded output) + assert!(!should_notify(true, false, true)); + // stderr not a terminal + assert!(!should_notify(true, true, false)); + assert!(!should_notify(false, false, false)); } } diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index 4c0a3801..4c1677fb 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -118,7 +118,7 @@ pub fn catalogs_cmd() -> Command { ) } -pub async fn do_list(config: Config, args: &ArgMatches) { +pub async fn do_list(out: &output::Out, config: Config, args: &ArgMatches) { let all = cmd::get_bool_flag(args, "all"); let env = cmd::get_string_flag(args, "environment"); let catalog_type = if cmd::get_bool_flag(args, "storage") { @@ -128,14 +128,15 @@ pub async fn do_list(config: Config, args: &ArgMatches) { }; if is_storage_catalog_type(catalog_type) { - beta::notify_once(&beta::STORAGE); + beta::notify_once(out, &beta::STORAGE); } - let catalogs = output::with_spinner( - "Listing catalogs", - api::list_catalogs(&config, &env, all, catalog_type), - ) - .await; + let catalogs = out + .with_spinner( + "Listing catalogs", + api::list_catalogs(&config, &env, all, catalog_type), + ) + .await; let headers = vec!["Name", "Type", "Environment"] .into_iter() @@ -151,11 +152,11 @@ pub async fn do_list(config: Config, args: &ArgMatches) { ] }) .collect(); - output::table(headers, data, Some(&catalogs)); + out.table(headers, data, Some(&catalogs)); } -pub async fn do_credentials(config: Config, args: &ArgMatches) { - beta::notify_once(&beta::STORAGE); +pub async fn do_credentials(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); let name = args .get_one::("catalog_name") @@ -171,11 +172,12 @@ pub async fn do_credentials(config: Config, args: &ArgMatches) { .unwrap_or("all"); let show_token = cmd::get_bool_flag(args, "show_token"); - let response = output::with_spinner( - "Vending catalog credentials", - api::vend_catalog_credentials(&config, name, &env, parse_mode(mode)), - ) - .await; + let response = out + .with_spinner( + "Vending catalog credentials", + api::vend_catalog_credentials(&config, name, &env, parse_mode(mode)), + ) + .await; let human = credentials_text( name, @@ -186,10 +188,10 @@ pub async fn do_credentials(config: Config, args: &ArgMatches) { format, show_token, ); - output::text(&human, &response); + out.text(&human, &response); } -pub async fn do_show(config: Config, args: &ArgMatches) { +pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { let name = args .get_one::("catalog_name") .expect("catalog_name is required"); @@ -198,12 +200,12 @@ pub async fn do_show(config: Config, args: &ArgMatches) { match api::describe_catalog(&config, name, &env).await { Ok(response) => { if is_storage_catalog_type(Some(&response.catalog.r#type)) { - beta::notify_once(&beta::STORAGE); + beta::notify_once(out, &beta::STORAGE); } let human = catalog_details_text(&response); - output::text(&human, &response); + out.text(&human, &response); } - Err(err) => output::tower_error_and_die(err, "Fetching catalog details failed"), + Err(err) => out.tower_error_and_die(err, "Fetching catalog details failed"), } } diff --git a/crates/tower-cmd/src/deploy.rs b/crates/tower-cmd/src/deploy.rs index 81cb4d7f..74c864d9 100644 --- a/crates/tower-cmd/src/deploy.rs +++ b/crates/tower-cmd/src/deploy.rs @@ -101,7 +101,7 @@ fn resolve_idempotency_key(args: &ArgMatches, dir: &Path) -> Option { util::git::clean_head_sha(dir) } -pub async fn do_deploy(config: Config, args: &ArgMatches) { +pub async fn do_deploy(out: &output::Out, config: Config, args: &ArgMatches) { let dir = resolve_path(args); let create_app = args.get_flag("create"); let idempotency_key = resolve_idempotency_key(args, &dir); @@ -114,31 +114,32 @@ pub async fn do_deploy(config: Config, args: &ArgMatches) { DeployTarget::Environment("default".to_string()) }; - if let Err(err) = deploy_from_dir(config, dir, create_app, target, idempotency_key).await { + if let Err(err) = deploy_from_dir(out, config, dir, create_app, target, idempotency_key).await { match err { crate::Error::ApiDeployError { source } => { - output::tower_error_and_die(source, "Deploying app failed") + out.tower_error_and_die(source, "Deploying app failed") } crate::Error::ApiCreateAppError { source } => { - output::tower_error_and_die(source, "Creating app failed") + out.tower_error_and_die(source, "Creating app failed") } crate::Error::ApiDescribeAppError { source } => { - output::tower_error_and_die(source, "Fetching app details failed") + out.tower_error_and_die(source, "Fetching app details failed") } crate::Error::PackageError { source } => { - output::package_error(source); + out.package_error(source); std::process::exit(1); } crate::Error::TowerfileLoadFailed { source, .. } => { - output::package_error(source); + out.package_error(source); std::process::exit(1); } - _ => output::die(&err.to_string()), + _ => out.die(&err.to_string()), } } } pub async fn deploy_from_dir( + out: &output::Out, config: Config, dir: PathBuf, create_app: bool, @@ -159,6 +160,7 @@ pub async fn deploy_from_dir( // Add app existence check before proceeding util::apps::ensure_app_exists( + out, &api_config, &towerfile.app.name, towerfile.app.description.as_deref(), @@ -167,22 +169,31 @@ pub async fn deploy_from_dir( .await?; let spec = PackageSpec::from_towerfile(&towerfile); - let mut spinner = output::spinner("Building package..."); + let mut spinner = out.spinner("Building package..."); let package = match Package::build(spec).await { Ok(package) => package, Err(err) => { - spinner.failure(); + spinner.failure(out); let error = crate::Error::PackageError { source: err }; return Err(error); } }; - spinner.success(); - do_deploy_package(api_config, package, &towerfile, target, idempotency_key).await + spinner.success(out); + do_deploy_package( + out, + api_config, + package, + &towerfile, + target, + idempotency_key, + ) + .await } async fn do_deploy_package( + out: &output::Out, api_config: Configuration, package: Package, towerfile: &Towerfile, @@ -195,6 +206,7 @@ async fn do_deploy_package( }; let res = util::deploy::deploy_app_package( + out, &api_config, &towerfile.app.name, package, @@ -217,10 +229,10 @@ async fn do_deploy_package( version.version, env ), }; - output::success(&line); + out.success(&line); if let Some(hint) = reuse_hint(&version, idempotency_key.as_deref()) { - output::muted(&hint); + out.muted(&hint); } Ok(()) diff --git a/crates/tower-cmd/src/environments.rs b/crates/tower-cmd/src/environments.rs index 87e2b1fb..aef9369c 100644 --- a/crates/tower-cmd/src/environments.rs +++ b/crates/tower-cmd/src/environments.rs @@ -41,9 +41,10 @@ pub fn environments_cmd() -> Command { ) } -pub async fn do_list(config: Config) { - let environments = - output::with_spinner("Listing environments", api::list_environments(&config)).await; +pub async fn do_list(out: &output::Out, config: Config) { + let environments = out + .with_spinner("Listing environments", api::list_environments(&config)) + .await; let headers = vec!["Name".to_string()]; @@ -53,21 +54,21 @@ pub async fn do_list(config: Config) { .collect(); // Display the table using the existing table function - output::table(headers, envs_data, Some(&environments)); + out.table(headers, envs_data, Some(&environments)); } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &output::Out, config: Config, args: &ArgMatches) { let name = args.get_one::("name").unwrap_or_else(|| { - output::die("Environment name (--name) is required"); + out.die("Environment name (--name) is required"); }); - output::with_spinner( + out.with_spinner( "Creating environment", api::create_environment(&config, name), ) .await; - output::success(&format!("Environment '{}' created", name)); + out.success(&format!("Environment '{}' created", name)); } fn env_resources_description(env: DescribeEnvironmentResponse) -> Option { @@ -88,24 +89,25 @@ fn env_resources_description(env: DescribeEnvironmentResponse) -> Option return None; } -pub async fn do_delete(config: Config, args: &ArgMatches) { +pub async fn do_delete(out: &output::Out, config: Config, args: &ArgMatches) { let name = args.get_one::("name").unwrap_or_else(|| { - output::die("Environment name (--name) is required"); + out.die("Environment name (--name) is required"); }); - let env = output::with_spinner( - "Retrieving environment...", - api::describe_environment(&config, name), - ) - .await; + let env = out + .with_spinner( + "Retrieving environment...", + api::describe_environment(&config, name), + ) + .await; if !env.environment.is_deletable { - output::error(&format!("You cannot delete the {name} environment.")); + out.error(&format!("You cannot delete the {name} environment.")); return; } if let Some(desc) = env_resources_description(env) { - output::write(&format!("Warning! Your environment contains {desc}.\n")) + out.write(&format!("Warning! Your environment contains {desc}.\n")) } let ans = prompt::confirm( @@ -115,20 +117,20 @@ pub async fn do_delete(config: Config, args: &ArgMatches) { match ans { Ok(true) => { - output::with_spinner( + out.with_spinner( &format!("Deleting environment {name}"), api::delete_environment(&config, name), ) .await; - output::success(&format!("Environment '{name}' deleted")); + out.success(&format!("Environment '{name}' deleted")); } - Ok(false) => output::write("Aborting environment deletion.\n"), + Ok(false) => out.write("Aborting environment deletion.\n"), Err(prompt::Error::ConfirmationPromptCancelled) => { - output::write("Aborting environment deletion.\n") + out.write("Aborting environment deletion.\n") } Err(_) => { - output::error( + out.error( "Something went wrong. Please try again, and contact us if the issue persists.", ); } diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index b45bc38e..2fb916d1 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -83,9 +83,11 @@ impl App { config.clone() }; - if config.json { - output::set_output_mode(output::OutputMode::Json); - } + let out = if config.json { + output::Out::json_stdout() + } else { + output::Out::human() + }; if config.debug { // Set log level to "DEBUG" @@ -120,18 +122,20 @@ impl App { } match matches.subcommand() { - Some(("login", args)) => session::do_login(config, args).await, - Some(("version", _)) => version::do_version().await, + Some(("login", args)) => session::do_login(&out, config, args).await, + Some(("version", _)) => version::do_version(&out).await, Some(("apps", sub_matches)) => { let apps_command = sub_matches.subcommand(); match apps_command { - Some(("list", args)) => apps::do_list_apps(sessionized_config, args).await, - Some(("create", args)) => apps::do_create(sessionized_config, args).await, - Some(("show", args)) => apps::do_show(sessionized_config, args).await, - Some(("logs", args)) => apps::do_logs(sessionized_config, args).await, - Some(("delete", args)) => apps::do_delete(sessionized_config, args).await, - Some(("cancel", args)) => apps::do_cancel(sessionized_config, args).await, + Some(("list", args)) => { + apps::do_list_apps(&out, sessionized_config, args).await + } + Some(("create", args)) => apps::do_create(&out, sessionized_config, args).await, + Some(("show", args)) => apps::do_show(&out, sessionized_config, args).await, + Some(("logs", args)) => apps::do_logs(&out, sessionized_config, args).await, + Some(("delete", args)) => apps::do_delete(&out, sessionized_config, args).await, + Some(("cancel", args)) => apps::do_cancel(&out, sessionized_config, args).await, _ => { apps::apps_cmd().print_help().unwrap(); std::process::exit(2); @@ -142,10 +146,10 @@ impl App { let catalogs_command = sub_matches.subcommand(); match catalogs_command { - Some(("list", args)) => catalogs::do_list(sessionized_config, args).await, - Some(("show", args)) => catalogs::do_show(sessionized_config, args).await, + Some(("list", args)) => catalogs::do_list(&out, sessionized_config, args).await, + Some(("show", args)) => catalogs::do_show(&out, sessionized_config, args).await, Some(("credentials", args)) => { - catalogs::do_credentials(sessionized_config, args).await + catalogs::do_credentials(&out, sessionized_config, args).await } _ => { catalogs::catalogs_cmd().print_help().unwrap(); @@ -157,9 +161,13 @@ impl App { let secrets_command = sub_matches.subcommand(); match secrets_command { - Some(("list", args)) => secrets::do_list(sessionized_config, args).await, - Some(("create", args)) => secrets::do_create(sessionized_config, args).await, - Some(("delete", args)) => secrets::do_delete(sessionized_config, args).await, + Some(("list", args)) => secrets::do_list(&out, sessionized_config, args).await, + Some(("create", args)) => { + secrets::do_create(&out, sessionized_config, args).await + } + Some(("delete", args)) => { + secrets::do_delete(&out, sessionized_config, args).await + } _ => { secrets::secrets_cmd().print_help().unwrap(); std::process::exit(2); @@ -170,12 +178,12 @@ impl App { let environments_command = sub_matches.subcommand(); match environments_command { - Some(("list", _)) => environments::do_list(sessionized_config).await, + Some(("list", _)) => environments::do_list(&out, sessionized_config).await, Some(("create", args)) => { - environments::do_create(sessionized_config, args).await + environments::do_create(&out, sessionized_config, args).await } Some(("delete", args)) => { - environments::do_delete(sessionized_config, args).await + environments::do_delete(&out, sessionized_config, args).await } _ => { environments::environments_cmd().print_help().unwrap(); @@ -186,25 +194,35 @@ impl App { let schedules_command = sub_matches.subcommand(); match schedules_command { - Some(("list", args)) => schedules::do_list(sessionized_config, args).await, - Some(("create", args)) => schedules::do_create(sessionized_config, args).await, - Some(("update", args)) => schedules::do_update(sessionized_config, args).await, - Some(("delete", args)) => schedules::do_delete(sessionized_config, args).await, + Some(("list", args)) => { + schedules::do_list(&out, sessionized_config, args).await + } + Some(("create", args)) => { + schedules::do_create(&out, sessionized_config, args).await + } + Some(("update", args)) => { + schedules::do_update(&out, sessionized_config, args).await + } + Some(("delete", args)) => { + schedules::do_delete(&out, sessionized_config, args).await + } _ => { schedules::schedules_cmd().print_help().unwrap(); std::process::exit(2); } } } - Some(("deploy", args)) => deploy::do_deploy(sessionized_config, args).await, - Some(("package", args)) => package::do_package(sessionized_config, args).await, - Some(("run", args)) => run::do_run(sessionized_config, args).await, + Some(("deploy", args)) => deploy::do_deploy(&out, sessionized_config, args).await, + Some(("package", args)) => package::do_package(&out, sessionized_config, args).await, + Some(("run", args)) => run::do_run(&out, sessionized_config, args).await, Some(("teams", sub_matches)) => { let teams_command = sub_matches.subcommand(); match teams_command { - Some(("list", _)) => teams::do_list(sessionized_config).await, - Some(("switch", args)) => teams::do_switch(sessionized_config, args).await, + Some(("list", _)) => teams::do_list(&out, sessionized_config).await, + Some(("switch", args)) => { + teams::do_switch(&out, sessionized_config, args).await + } _ => { teams::teams_cmd().print_help().unwrap(); std::process::exit(2); diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index 7d69056f..fc301457 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -236,11 +236,10 @@ pub async fn do_mcp_server(config: Config, args: &clap::ArgMatches) -> Result<() } async fn run_stdio_server(config: Config) -> Result<(), Error> { - // Set stdio MCP mode to prevent any non-JSON-RPC output from corrupting the protocol - crate::output::set_output_mode(crate::output::OutputMode::McpStdio); - + // stdio transport only collects tool output (no notifications) so nothing pollutes + // the JSON-RPC stream on stdout. let (stdin, stdout) = stdio(); - let service = TowerService::new(config); + let service = TowerService::new(config, false); let server = service.serve((stdin, stdout)).await.map_err(|e| { Error::from(std::io::Error::new( std::io::ErrorKind::Other, @@ -253,14 +252,11 @@ async fn run_stdio_server(config: Config) -> Result<(), Error> { async fn run_sse_server(config: Config, port: u16) -> Result<(), Error> { let bind_addr = format!("127.0.0.1:{}", port); - crate::output::write(&format!("SSE MCP server running on http://{}\n", bind_addr)); - - // Set streaming MCP mode to enable logging notifications - crate::output::set_output_mode(crate::output::OutputMode::McpStreaming); + println!("SSE MCP server running on http://{}", bind_addr); let ct = SseServer::serve(bind_addr.parse()?) .await? - .with_service_directly(move || TowerService::new(config.clone())); + .with_service_directly(move || TowerService::new(config.clone(), true)); tokio::signal::ctrl_c().await?; ct.cancel(); @@ -269,16 +265,10 @@ async fn run_sse_server(config: Config, port: u16) -> Result<(), Error> { async fn run_http_server(config: Config, port: u16) -> Result<(), Error> { let bind_addr = format!("127.0.0.1:{}", port); - crate::output::write(&format!( - "Streamable HTTP MCP server running on http://{}\n", - bind_addr - )); - - // Set streaming MCP mode to enable logging notifications - crate::output::set_output_mode(crate::output::OutputMode::McpStreaming); + println!("Streamable HTTP MCP server running on http://{}", bind_addr); let service = StreamableHttpService::new( - move || Ok(TowerService::new(config.clone())), + move || Ok(TowerService::new(config.clone(), true)), LocalSessionManager::default().into(), StreamableHttpServerConfig::default(), ); @@ -296,18 +286,24 @@ async fn run_http_server(config: Config, port: u16) -> Result<(), Error> { #[derive(Clone)] pub struct TowerService { config: Config, + /// Whether tool output should be streamed to the peer as logging notifications. + /// True for SSE/HTTP transports, false for stdio (which only collects output). + send_notifications: bool, tool_router: ToolRouter, } #[tool_router] impl TowerService { - pub fn new(config: Config) -> Self { + pub fn new(config: Config, send_notifications: bool) -> Self { + // MCP output is captured as plain text, so disable ANSI colours globally. + colored::control::set_override(false); Self { config: std::env::var("TOWER_JWT") .ok() .and_then(|token| Session::from_jwt(&token).ok()) .map(|session| config.clone().with_session(session)) .unwrap_or(config), + send_notifications, tool_router: Self::tool_router(), } } @@ -466,25 +462,23 @@ impl TowerService { } async fn execute_with_streaming( + &self, ctx: &RequestContext, operation: F, ) -> (Result, String) where - F: FnOnce() -> Fut, + F: FnOnce(crate::output::Out) -> Fut, Fut: std::future::Future>, { - // Check if we're in streaming mode (SSE/HTTP) where we send notifications - // vs stdio mode where we don't - let mode = crate::output::get_output_mode(); - let send_notifications = mode == crate::output::OutputMode::McpStreaming; - let streaming = Self::setup_output_capture(ctx, send_notifications); - - crate::output::set_current_sender(streaming.sender.clone()); + let streaming = Self::setup_output_capture(ctx, self.send_notifications); - let result = operation().await; + // The command writes through this `Out`; each line it produces is forwarded to + // the capture channel (and, in streaming mode, to the peer as a notification). + // The operation owns the `Out` and drops it when finished, which closes the + // channel so the drain task below can complete. + let out = crate::output::Out::mcp(streaming.sender); + let result = operation(out).await; - crate::output::clear_current_sender(); - drop(streaming.sender); streaming.task.await.ok(); let output = streaming @@ -817,7 +811,10 @@ impl TowerService { // collapse to a single AppVersion server-side. let idempotency_key = crate::util::git::clean_head_sha(&working_dir); + // The tool builds its own result message, so the deploy's own progress is discarded. + let out = crate::output::Out::sink(); match deploy::deploy_from_dir( + &out, self.config.clone(), working_dir, true, @@ -842,15 +839,17 @@ impl TowerService { let working_dir = Self::resolve_working_directory(&request.common); let config = self.config.clone(); - let (result, output) = Self::execute_with_streaming(&ctx, || { - run::do_run_local( - config, - working_dir, - "default", - std::collections::HashMap::new(), - ) - }) - .await; + let (result, output) = self + .execute_with_streaming(&ctx, |out| { + run::do_run_local( + out, + config, + working_dir, + "default", + std::collections::HashMap::new(), + ) + }) + .await; match result { Ok(_) => { if output.trim().is_empty() { @@ -894,10 +893,11 @@ impl TowerService { let app_name = towerfile.app.name.clone(); - let (result, output) = Self::execute_with_streaming(&ctx, || { - run::do_run_remote(config, path, &env, params, None, true) - }) - .await; + let (result, output) = self + .execute_with_streaming(&ctx, |out| { + run::do_run_remote(out, config, path, &env, params, None, true) + }) + .await; match result { Ok(_) => { if output.trim().is_empty() { diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index bd8b28ba..cb395739 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -1,13 +1,13 @@ pub use cli_table::{format::Justify, Cell}; use cli_table::{ format::{Border, HorizontalLine, Separator}, - print_stdout, Table, TableStruct, + Table, TableStruct, }; -use colored::{control, Colorize}; +use colored::Colorize; use http::StatusCode; use serde::Serialize; -use std::io::{self, Write}; -use std::sync::{Mutex, OnceLock}; +use std::io::{self, IsTerminal, Write}; +use std::sync::{Arc, Mutex}; use tokio::sync::mpsc::UnboundedSender; use tower_api::{ apis::{Error as ApiError, ResponseContent}, @@ -17,435 +17,546 @@ use tower_telemetry::debug; const BANNER_TEXT: &str = include_str!("./banner.txt"); +/// How results are rendered. `Human` produces the coloured, formatted output; +/// `Json` produces machine-parseable JSON. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputMode { - /// Normal CLI output with colors and formatting - Normal, - /// JSON output mode +pub enum Format { + Human, Json, - /// MCP server with stdio transport (no notifications, capture output) - McpStdio, - /// MCP server with streaming transport like SSE/HTTP (send notifications) - McpStreaming, } -impl OutputMode { - pub fn is_mcp(self) -> bool { - matches!(self, Self::McpStdio | Self::McpStreaming) - } +/// `Out` is the explicit output destination for a command. It carries the render +/// format and the writer to send results to, so nothing needs to consult a global +/// to decide where output goes. The CLI builds one over stdout; the MCP server +/// builds one over a channel that forwards each line to the connected peer. +/// +/// `Out` is cheap to clone and safe to send between tasks — clones share the same +/// underlying writer through an `Arc>`, so a spawned task (e.g. a log +/// monitor) can own a clone and write to the same destination as the foreground. +#[derive(Clone)] +pub struct Out { + format: Format, + writer: Arc>>, + /// True only when driving an interactive terminal, where spinners animate. + /// False for JSON and for MCP capture. + interactive: bool, + /// True when this is a foreground CLI process that owns terminal signals + /// (so it may install a Ctrl+C handler). False for MCP/captured output. + foreground: bool, +} - pub fn is_json(self) -> bool { - matches!(self, Self::Json) +impl Out { + fn new( + format: Format, + writer: Box, + interactive: bool, + foreground: bool, + ) -> Self { + Self { + format, + writer: Arc::new(Mutex::new(writer)), + interactive, + foreground, + } } - pub fn is_normal(self) -> bool { - matches!(self, Self::Normal) + /// Human-formatted output to stdout. Spinners animate when stdout is a TTY. + pub fn human() -> Self { + let interactive = io::stdout().is_terminal(); + Self::new(Format::Human, Box::new(io::stdout()), interactive, true) } -} -static OUTPUT_MODE: OnceLock = OnceLock::new(); -static CURRENT_SENDER: Mutex>> = Mutex::new(None); + /// JSON output to stdout. No spinner animation so stdout stays parseable. + pub fn json_stdout() -> Self { + Self::new(Format::Json, Box::new(io::stdout()), false, true) + } -fn write_to_stdout(msg: &str) { - let mut stdout = io::stdout(); - if let Err(err) = stdout.write_all(msg.as_bytes()) { - if err.kind() == io::ErrorKind::BrokenPipe { - std::process::exit(0); - } - panic!("failed writing to stdout: {err}"); + /// Output captured for an MCP tool call. Each written line is forwarded to the + /// supplied channel; the MCP server collects the lines and optionally streams + /// them to the peer as logging notifications. + pub fn mcp(sender: UnboundedSender) -> Self { + Self::new(Format::Human, Box::new(McpWriter { sender }), false, false) } - stdout.flush().ok(); -} -fn write_to_stderr(msg: &str) { - let mut stderr = io::stderr(); - if let Err(err) = stderr.write_all(msg.as_bytes()) { - if err.kind() == io::ErrorKind::BrokenPipe { - std::process::exit(0); - } - panic!("failed writing to stderr: {err}"); + /// Output that goes nowhere. Used where a command emits progress that the caller + /// discards (e.g. the MCP deploy tool builds its own result message). + pub fn sink() -> Self { + Self::new(Format::Human, Box::new(io::sink()), false, false) } - stderr.flush().ok(); -} -pub(crate) fn notice_to_stderr(label: &str, msg: &str) { - let line = format!("{} {}\n", label.bold().yellow(), msg); - write_to_stderr(&line); -} + fn is_json(&self) -> bool { + self.format == Format::Json + } -pub fn set_output_mode(mode: OutputMode) { - OUTPUT_MODE.set(mode).ok(); - if mode.is_mcp() { - control::set_override(false); + /// Whether this process owns terminal signal handling (CLI foreground). MCP and + /// other captured outputs return false, so they don't trap Ctrl+C. + pub fn foreground(&self) -> bool { + self.foreground } -} -pub fn get_output_mode() -> OutputMode { - OUTPUT_MODE.get().copied().unwrap_or(OutputMode::Normal) -} + /// Whether this output drives an interactive terminal: human format on a + /// stdout TTY. False for JSON, MCP capture, and redirected output. + pub fn interactive(&self) -> bool { + self.interactive + } -pub fn set_current_sender(sender: UnboundedSender) { - *CURRENT_SENDER.lock().unwrap() = Some(sender); -} + /// Writes raw text to the destination. On a broken pipe the process exits + /// cleanly, matching classic Unix tool behaviour. + pub fn write(&self, msg: &str) { + let mut writer = self.writer.lock().unwrap(); + if let Err(err) = writer.write_all(msg.as_bytes()) { + if err.kind() == io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + panic!("failed writing output: {err}"); + } + writer.flush().ok(); + } -pub fn clear_current_sender() { - *CURRENT_SENDER.lock().unwrap() = None; -} + pub fn newline(&self) { + self.write("\n"); + } -fn send_to_current_sender(msg: String) { - if let Ok(sender_guard) = CURRENT_SENDER.lock() { - if let Some(tx) = sender_guard.as_ref() { - tx.send(msg).ok(); - } + pub fn banner(&self) { + self.write(BANNER_TEXT); } -} -pub fn json(data: &T) { - match serde_json::to_string_pretty(data) { - Ok(json_str) => { - write(&format!("{}\n", json_str)); - } - Err(e) => { - error(&format!("Failed to serialize to JSON: {}", e)); + fn json(&self, data: &T) { + match serde_json::to_string_pretty(data) { + Ok(json_str) => { + let line = format!("{}\n", json_str); + self.write(&line); + } + Err(e) => { + self.error(&format!("Failed to serialize to JSON: {}", e)); + } } } -} -pub fn success(msg: &str) { - success_with_data(msg, None::); -} + pub fn success(&self, msg: &str) { + self.success_with_data(msg, None::); + } -pub fn muted(msg: &str) { - if get_output_mode().is_json() { - let response = serde_json::json!({ - "result": "muted", + pub fn success_with_data(&self, msg: &str, data: Option) { + let mut response = serde_json::json!({ + "result": "success", "message": msg }); - json(&response); - } else { - let line = msg.dimmed(); - write(&line); - } -} -pub fn success_with_data(msg: &str, data: Option) { - let mut response = serde_json::json!({ - "result": "success", - "message": msg - }); + if let Some(data) = data { + response["data"] = serde_json::to_value(data).unwrap(); + } - if let Some(data) = data { - response["data"] = serde_json::to_value(data).unwrap(); + let line = format!("{} {}\n", "Success!".green(), msg); + self.text(&line, &response); } - let line = format!("{} {}\n", "Success!".green(), msg); - text(&line, &response); -} - -pub enum LogLineType { - Remote, - Local, -} - -fn format_timestamp(timestamp: &str, t: LogLineType) -> String { - let ts = timestamp.bold(); - - let sep = "|".bold(); - - match t { - LogLineType::Remote => format!("{} {}", ts.yellow(), sep.yellow()), - LogLineType::Local => format!("{} {}", ts.green(), sep.green()), + /// Writes a low-emphasis informational line, dimmed in human output. + pub fn muted(&self, msg: &str) { + if self.is_json() { + let response = serde_json::json!({ + "result": "muted", + "message": msg + }); + self.json(&response); + } else { + let line = format!("{}\n", msg.dimmed()); + self.write(&line); + } } -} -pub fn log_line(timestamp: &str, message: &str, t: LogLineType) { - let line = format!("{} {}\n", format_timestamp(timestamp, t), message); - write(&line); -} - -pub fn remote_log_event(log: &tower_api::models::RunLogLine) { - let ts = crate::util::dates::format_str(&log.reported_at); - log_line(&ts, &log.content, LogLineType::Remote); -} - -pub fn package_error(err: tower_package::Error) { - let msg = match err { - tower_package::Error::NoManifest => "No manifest was found".to_string(), - tower_package::Error::InvalidManifest => { - "Invalid manifest was found or created".to_string() - } - tower_package::Error::InvalidPath => { - "There was a problem determining exactly where your Towerfile was stored on disk" - .to_string() - } - tower_package::Error::InvalidGlob { message } => { - format!("Invalid file glob pattern: {}", message) - } - tower_package::Error::InvalidTowerfile { message } => { - format!("Invalid Towerfile: {}", message) - } - tower_package::Error::MissingTowerfile => { - "No Towerfile was found in the target directory".to_string() - } - tower_package::Error::MissingRequiredAppField { field } => { - format!("Missing required app field `{}` in Towerfile", field) - } - tower_package::Error::Io { source } => format!("IO error: {}", source), - tower_package::Error::MissingScript { script } => { - format!("Script '{}' not found. Check that the 'script' field in your Towerfile points to a file that exists in your project.", script) + pub fn error(&self, msg: &str) { + if self.is_json() { + let response = serde_json::json!({ + "result": "error", + "message": msg + }); + self.json(&response); + } else { + let line = format!("{} {}\n", "Oh no!".red(), msg); + self.write(&line); } - }; - - let line = format!("{} {}\n", "Package error:".red(), msg); - write(&line); -} - -pub fn header(text: &str) { - let line = format!("{}\n", text.bold().green()); - write(&line); -} + } -pub fn detail(label: &str, value: &str) { - let line = format!("{} {}\n", format!("{}:", label).bold().green(), value); - write(&line); -} + pub fn log_line(&self, timestamp: &str, message: &str, t: LogLineType) { + let line = format!("{} {}\n", format_timestamp(timestamp, t), message); + self.write(&line); + } -pub fn title(text: &str) -> String { - text.bold().green().to_string() -} + pub fn remote_log_event(&self, log: &tower_api::models::RunLogLine) { + let ts = crate::util::dates::format_str(&log.reported_at); + self.log_line(&ts, &log.content, LogLineType::Remote); + } -pub fn placeholder(text: &str) -> String { - text.white().dimmed().italic().to_string() -} + pub fn package_error(&self, err: tower_package::Error) { + let msg = match err { + tower_package::Error::NoManifest => "No manifest was found".to_string(), + tower_package::Error::InvalidManifest => { + "Invalid manifest was found or created".to_string() + } + tower_package::Error::InvalidPath => { + "There was a problem determining exactly where your Towerfile was stored on disk" + .to_string() + } + tower_package::Error::InvalidGlob { message } => { + format!("Invalid file glob pattern: {}", message) + } + tower_package::Error::InvalidTowerfile { message } => { + format!("Invalid Towerfile: {}", message) + } + tower_package::Error::MissingTowerfile => { + "No Towerfile was found in the target directory".to_string() + } + tower_package::Error::MissingRequiredAppField { field } => { + format!("Missing required app field `{}` in Towerfile", field) + } + tower_package::Error::Io { source } => format!("IO error: {}", source), + tower_package::Error::MissingScript { script } => { + format!("Script '{}' not found. Check that the 'script' field in your Towerfile points to a file that exists in your project.", script) + } + }; -pub fn paragraph(msg: &str) -> String { - msg.chars() - .collect::>() - .chunks(78) - .map(|c| c.iter().collect::()) - .map(|li| format!(" {}", li)) - .collect::>() - .join("\n") -} + let line = format!("{} {}\n", "Package error:".red(), msg); + self.write(&line); + } -pub fn config_error(err: config::Error) { - let msg = match err { - config::Error::ConfigDirNotFound => "Config directory not found".to_string(), - config::Error::NoHomeDir => "No home directory found".to_string(), - config::Error::Io { ref source } => format!("IO error: {}", source), - config::Error::NoSession => "No session".to_string(), - config::Error::TeamNotFound { ref team_name } => { - format!("Team with name `{}` not found!", team_name) - } - config::Error::UnknownDescribeSessionValue { value: _ } => { - "An error occured while describing the session associated with the JWT you provided. Maybe your CLI is out of date?".to_string() - } - config::Error::DescribeSessionError { ref err } => { - format!("An error occured while describing the session associated with the JWT you provided: {}", err) + pub fn config_error(&self, err: config::Error) { + let msg = match err { + config::Error::ConfigDirNotFound => "Config directory not found".to_string(), + config::Error::NoHomeDir => "No home directory found".to_string(), + config::Error::Io { ref source } => format!("IO error: {}", source), + config::Error::NoSession => "No session".to_string(), + config::Error::TeamNotFound { ref team_name } => { + format!("Team with name `{}` not found!", team_name) + } + config::Error::UnknownDescribeSessionValue { value: _ } => { + "An error occured while describing the session associated with the JWT you provided. Maybe your CLI is out of date?".to_string() + } + config::Error::DescribeSessionError { ref err } => { + format!("An error occured while describing the session associated with the JWT you provided: {}", err) + } + }; + + let line = format!("{} {}\n", "Config error:".red(), msg); + self.write(&line); + } + + // Outputs both the model.detail and the model.errors fields in a human readable format. + fn output_full_error_details(&self, model: &ErrorModel) { + // Show the main detail message if available + if let Some(detail) = &model.detail { + self.write(&format!("\n{}\n", "Error details:".yellow())); + self.write(&format!("{}\n", detail.red())); + } + + // Show any additional error details from the errors field + if let Some(errors) = &model.errors { + if !errors.is_empty() { + if model.detail.is_none() { + self.write(&format!("\n{}\n", "Error details:".yellow())); + } + for error in errors { + let msg = format!( + " • {}", + error.message.as_deref().unwrap_or("Unknown error") + ); + self.write(&format!("{}\n", msg.red())); + } + } } - }; + } - let line = format!("{} {}\n", "Config error:".red(), msg); - write(&line); -} + fn output_response_content_error(&self, err: ResponseContent) { + // Attempt to deserialize the error content into an ErrorModel. + let error_model = match serde_json::from_str::(&err.content) { + Ok(model) => { + debug!("Error model (status: {}): {:?}", err.status, model); + model + } + Err(e) => { + debug!("Failed to parse error content as JSON: {}", e); + debug!("Raw error content: {}", err.content); + // Show the raw error content if JSON parsing fails + self.write(&format!("\n{}\n", "API Error:".yellow())); + self.write(&format!("{}\n", err.content.red())); + return; + } + }; -pub fn write(msg: &str) { - if get_output_mode().is_mcp() { - let clean_msg = msg.trim_end().to_string(); - send_to_current_sender(clean_msg); - } else { - write_to_stdout(msg); + match err.status { + StatusCode::CONFLICT => { + self.output_full_error_details(&error_model); + } + StatusCode::UNPROCESSABLE_ENTITY => { + self.output_full_error_details(&error_model); + } + StatusCode::INTERNAL_SERVER_ERROR => { + self.error( + "The Tower API encountered an internal error. Maybe try again later on.", + ); + } + StatusCode::NOT_FOUND => { + self.output_full_error_details(&error_model); + } + StatusCode::UNAUTHORIZED => { + self.error( + "You aren't authorized to do that! Are you logged in? Run `tower login` to login.", + ); + } + _ => { + if error_model.detail.is_none() && error_model.errors.is_none() { + self.error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit."); + } + self.output_full_error_details(&error_model); + } + } } -} -pub fn error(msg: &str) { - if get_output_mode().is_json() { - let response = serde_json::json!({ - "result": "error", - "message": msg - }); - json(&response); - } else { - let line = format!("{} {}\n", "Oh no!".red(), msg); - write(&line); + fn tower_error(&self, err: ApiError) { + match err { + ApiError::ResponseError(resp) => { + self.output_response_content_error(resp); + } + ApiError::Reqwest(e) => { + debug!("Reqwest error: {:?}", e); + self.error("The Tower CLI wasn't able to talk to the Tower API! Are you offline? Try again later."); + } + ApiError::Serde(e) => { + debug!("Serde error: {:?}", e); + self.error("The Tower API returned something that the Tower CLI didn't understand. Maybe you need to upgrade Tower CLI?"); + } + ApiError::Io(e) => { + debug!("Io error: {:?}", e); + self.error("An error happened while talking to the Tower API. You can try that again in a bit."); + } + } } -} -pub fn runtime_error(err: tower_runtime::errors::Error) { - let line = format!("{} {}\n", "Runtime Error:".red(), err.to_string()); - write(&line); -} + /// Handles Tower API errors with context-specific authentication messages. + /// If the error is a 401 Unauthorized, provides a helpful message mentioning + /// the operation that failed and suggests running 'tower login'. + /// Always exits the process with error code 1. + pub fn tower_error_and_die(&self, err: ApiError, operation: &str) -> ! { + // Check if this is an authentication error + if let ApiError::ResponseError(ref resp) = err { + if resp.status == StatusCode::UNAUTHORIZED { + self.die(&format!( + "{} because you are not logged into Tower. Please run 'tower login' first.", + operation + )); + } + } -// Outputs both the model.detail and the model.errors fields in a human readable format. -pub fn output_full_error_details(model: &ErrorModel) { - // Show the main detail message if available - if let Some(detail) = &model.detail { - write(&format!("\n{}\n", "Error details:".yellow())); - write(&format!("{}\n", detail.red())); + // Show the detailed error first + self.tower_error(err); + self.die(operation); + } + + pub fn table( + &self, + headers: Vec, + data: Vec>, + json_data: Option<&T>, + ) { + if self.is_json() { + if let Some(data) = json_data { + self.json(data); + } else { + // Fallback: convert table data to JSON structure + let json_output: Vec> = data + .iter() + .map(|row| { + let mut obj = serde_json::Map::new(); + for (i, value) in row.iter().enumerate() { + let key = headers + .get(i) + .expect("header should have same number of columns as row"); + obj.insert(key.to_string(), serde_json::Value::String(value.clone())); + } + obj + }) + .collect(); + self.json(&json_output); + } + } else { + let line = format!("{}\n", table_text(headers, data)); + self.write(&line); + } } - // Show any additional error details from the errors field - if let Some(errors) = &model.errors { - if !errors.is_empty() { - if model.detail.is_none() { - write(&format!("\n{}\n", "Error details:".yellow())); + pub fn list(&self, items: Vec, json_data: Option<&T>) { + if self.is_json() { + if let Some(data) = json_data { + self.json(data); + } else { + self.json(&items); } - for error in errors { - let msg = format!( - " • {}", - error.message.as_deref().unwrap_or("Unknown error") - ); - write(&format!("{}\n", msg.red())); + } else { + for item in items { + let line = format!(" * {}\n", item); + let line = line.replace("\n", "\n "); + let line = format!("{}\n", line); + self.write(&line); } } } -} -fn output_response_content_error(err: ResponseContent) { - // Attempt to deserialize the error content into an ErrorModel. - let error_model = match serde_json::from_str::(&err.content) { - Ok(model) => { - debug!("Error model (status: {}): {:?}", err.status, model); - model - } - Err(e) => { - debug!("Failed to parse error content as JSON: {}", e); - debug!("Raw error content: {}", err.content); - // Show the raw error content if JSON parsing fails - write(&format!("\n{}\n", "API Error:".yellow())); - write(&format!("{}\n", err.content.red())); - return; + /// Writes a human-readable rendering of some data, or the data itself as JSON when + /// in JSON mode. Use this when a command's output is data that has both a plain text + /// and a JSON representation, mirroring `table` and `list`. + pub fn text(&self, msg: &str, json_data: &T) { + if self.is_json() { + self.json(json_data); + } else { + self.write(msg); } - }; + } - match err.status { - StatusCode::CONFLICT => { - output_full_error_details(&error_model); - } - StatusCode::UNPROCESSABLE_ENTITY => { - output_full_error_details(&error_model); + /// Writes presentation-only text that accompanies human-formatted output, like table + /// legends or hints. Suppressed in JSON mode so stdout stays machine-parseable. + pub fn note(&self, msg: &str) { + if !self.is_json() { + self.write(msg); } - StatusCode::INTERNAL_SERVER_ERROR => { - error("The Tower API encountered an internal error. Maybe try again later on."); - } - StatusCode::NOT_FOUND => { - output_full_error_details(&error_model); - } - StatusCode::UNAUTHORIZED => { - error( - "You aren't authorized to do that! Are you logged in? Run `tower login` to login.", - ); - } - _ => { - if error_model.detail.is_none() && error_model.errors.is_none() { - error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit."); + } + + pub fn die(&self, msg: &str) -> ! { + io::stdout().flush().ok(); + io::stderr().flush().ok(); + let line = format!("{} {}\n", "Error:".red(), msg); + self.write(&line); + // Flush output before exit to ensure "Error:" message is displayed + io::stdout().flush().ok(); + io::stderr().flush().ok(); + std::process::exit(1); + } + + /// Starts a spinner for a long running task. It animates only on an interactive + /// terminal; otherwise its completion messages are written like any other line so + /// they are still captured (e.g. by the MCP server). + pub fn spinner(&self, msg: &str) -> Spinner { + let anim = if self.interactive { + Some(spinners::Spinner::new( + spinners::Spinners::Dots, + msg.to_string(), + )) + } else { + None + }; + Spinner { + msg: msg.to_string(), + anim, + } + } + + /// Runs an async operation with a spinner and proper error handling. + /// + /// - Shows a spinner with "{operation}..." while the operation runs + /// - On success: stops the spinner with success indicator and returns the result + /// - On error: stops the spinner with failure indicator and shows an auth-aware + /// error message, then exits the process. + pub async fn with_spinner(&self, operation: &str, future: F) -> T + where + F: std::future::Future>>, + { + let spinner_msg = format!("{}...", operation); + let mut spinner = self.spinner(&spinner_msg); + match future.await { + Ok(result) => { + spinner.success(self); + result + } + Err(err) => { + spinner.failure(self); + let error_msg = format!("{} failed", operation); + self.tower_error_and_die(err, &error_msg); } - output_full_error_details(&error_model); } } -} -pub fn tower_error(err: ApiError) { - match err { - ApiError::ResponseError(resp) => { - output_response_content_error(resp); - } - ApiError::Reqwest(e) => { - debug!("Reqwest error: {:?}", e); - error("The Tower CLI wasn't able to talk to the Tower API! Are you offline? Try again later."); - } - ApiError::Serde(e) => { - debug!("Serde error: {:?}", e); - error("The Tower API returned something that the Tower CLI didn't understand. Maybe you need to upgrade Tower CLI?"); - } - ApiError::Io(e) => { - debug!("Io error: {:?}", e); - error("An error happened while talking to the Tower API. You can try that again in a bit."); + /// The MCP-safe version of `with_spinner`: returns errors instead of exiting. + /// Use this for operations that may be called from MCP or other contexts where + /// process exit is not acceptable. Returns the error without displaying it, so + /// the caller decides how to handle and display it. + pub async fn try_with_spinner( + &self, + operation: &str, + future: F, + ) -> Result> + where + F: std::future::Future>>, + { + let spinner_msg = format!("{}...", operation); + let mut spinner = self.spinner(&spinner_msg); + match future.await { + Ok(result) => { + spinner.success(self); + Ok(result) + } + Err(err) => { + spinner.failure(self); + // Just return the error - let the caller decide how to handle it + Err(err) + } } } } -/// Handles Tower API errors with context-specific authentication messages. -/// If the error is a 401 Unauthorized, provides a helpful message mentioning -/// the operation that failed and suggests running 'tower login'. -/// Always exits the process with error code 1. -pub fn tower_error_and_die(err: ApiError, operation: &str) -> ! { - // Check if this is an authentication error - if let ApiError::ResponseError(ref resp) = err { - if resp.status == StatusCode::UNAUTHORIZED { - die(&format!( - "{} because you are not logged into Tower. Please run 'tower login' first.", - operation - )); +/// A writer that forwards each written chunk to an MCP channel as one message +/// (trailing whitespace trimmed). This replaces the previous global sender: the +/// destination now lives in the `Out`. +struct McpWriter { + sender: UnboundedSender, +} + +impl Write for McpWriter { + fn write(&mut self, data: &[u8]) -> io::Result { + let text = String::from_utf8_lossy(data).trim_end().to_string(); + if !text.is_empty() { + self.sender.send(text).ok(); } + Ok(data.len()) } - // Show the detailed error first - tower_error(err); - die(operation); + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } } -/// Runs an async operation with a spinner and proper error handling. -/// -/// This helper provides consistent spinner behavior across all commands: -/// - Shows a spinner with "{operation}..." while the operation runs -/// - On success: stops the spinner with success indicator and returns the result -/// - On error: stops the spinner with failure indicator and shows auth-aware error message -/// -/// # Examples -/// -/// ```ignore -/// let envs = output::with_spinner( -/// "Listing environments", -/// api::list_environments(&config) -/// ).await; -/// ``` -pub async fn with_spinner(operation: &str, future: F) -> T -where - F: std::future::Future>>, -{ - let spinner_msg = format!("{}...", operation); - let mut spinner = self::spinner(&spinner_msg); - match future.await { - Ok(result) => { - spinner.success(); - result - } - Err(err) => { - spinner.failure(); - let error_msg = format!("{} failed", operation); - tower_error_and_die(err, &error_msg); - } - } +pub enum LogLineType { + Remote, + Local, } -/// Runs an async operation with a spinner, returning Result instead of exiting. -/// -/// This is the MCP-safe version of with_spinner that returns errors instead of exiting. -/// Use this for operations that may be called from MCP or other contexts where -/// process exit is not acceptable. Returns the error without displaying it, allowing -/// the caller to decide how to handle and display the error. -/// -/// Shows "{operation}..." during execution and stops the spinner on completion. -pub async fn try_with_spinner(operation: &str, future: F) -> Result> -where - F: std::future::Future>>, -{ - let spinner_msg = format!("{}...", operation); - let mut spinner = self::spinner(&spinner_msg); - match future.await { - Ok(result) => { - spinner.success(); - Ok(result) - } - Err(err) => { - spinner.failure(); - // Just return the error - let the caller decide how to handle it - Err(err) - } +fn format_timestamp(timestamp: &str, t: LogLineType) -> String { + let ts = timestamp.bold(); + + let sep = "|".bold(); + + match t { + LogLineType::Remote => format!("{} {}", ts.yellow(), sep.yellow()), + LogLineType::Local => format!("{} {}", ts.green(), sep.green()), } } +pub fn title(text: &str) -> String { + text.bold().green().to_string() +} + +pub fn placeholder(text: &str) -> String { + text.white().dimmed().italic().to_string() +} + +pub fn paragraph(msg: &str) -> String { + msg.chars() + .collect::>() + .chunks(78) + .map(|c| c.iter().collect::()) + .map(|li| format!(" {}", li)) + .collect::>() + .join("\n") +} + fn formatted_table(headers: Vec, data: Vec>) -> TableStruct { let separator = Separator::builder() .title(Some(HorizontalLine::default())) @@ -464,120 +575,62 @@ pub fn table_text(headers: Vec, data: Vec>) -> String { } } -pub fn table(headers: Vec, data: Vec>, json_data: Option<&T>) { - if get_output_mode().is_json() { - if let Some(data) = json_data { - json(data); - } else { - // Fallback: convert table data to JSON structure - let json_output: Vec> = data - .iter() - .map(|row| { - let mut obj = serde_json::Map::new(); - for (i, value) in row.iter().enumerate() { - let key = headers - .get(i) - .expect("header should have same number of columns as row"); - obj.insert(key.to_string(), serde_json::Value::String(value.clone())); - } - obj - }) - .collect(); - json(&json_output); - } - } else { - let table = formatted_table(headers, data); - - if let Err(err) = print_stdout(table) { - if err.kind() == io::ErrorKind::BrokenPipe { - std::process::exit(0); - } - panic!("failed writing table to stdout: {err}"); - } - } -} - -pub fn list(items: Vec, json_data: Option<&T>) { - if get_output_mode().is_json() { - if let Some(data) = json_data { - json(data); - } else { - json(&items); - } - } else { - for item in items { - let line = format!(" * {}\n", item); - let line = line.replace("\n", "\n "); - let line = format!("{}\n", line); - write(&line); - } - } -} - -/// Writes a human-readable rendering of some data, or the data itself as JSON when -/// in JSON mode. Use this when a command's output is data that has both a plain text -/// and a JSON representation, mirroring `table` and `list`. -pub fn text(msg: &str, json_data: &T) { - if get_output_mode().is_json() { - json(json_data); - } else { - write(msg); - } -} - -/// Writes presentation-only text that accompanies human-formatted output, like table -/// legends or hints. Suppressed in JSON mode so stdout stays machine-parseable. -pub fn note(msg: &str) { - if !get_output_mode().is_json() { - write(msg); - } -} - -pub fn banner() { - write(&BANNER_TEXT); -} - pub struct Spinner { msg: String, - spinner: Option, + anim: Option, } impl Spinner { - pub fn new(msg: String) -> Spinner { - if get_output_mode().is_normal() { - let spinner = spinners::Spinner::new(spinners::Spinners::Dots, msg.clone()); - Spinner { - spinner: Some(spinner), - msg, - } - } else { - Spinner { spinner: None, msg } - } - } - - pub fn success(&mut self) { - if let Some(ref mut spinner) = self.spinner { + pub fn success(&mut self, out: &Out) { + if let Some(ref mut spinner) = self.anim { let sym = "✔".bold().green().to_string(); spinner.stop_and_persist(&sym, format!("{} Done!", self.msg)); - } else if get_output_mode().is_mcp() { - send_to_current_sender(format!("{} Done!", self.msg)); + } else if out.format == Format::Human { + out.write(&format!("{} Done!\n", self.msg)); } } - pub fn failure(&mut self) { - if let Some(ref mut spinner) = self.spinner { + pub fn failure(&mut self, out: &Out) { + if let Some(ref mut spinner) = self.anim { let sym = "✘".bold().red().to_string(); spinner.stop_and_persist(&sym, format!("{} Failed!", self.msg)); - } else if get_output_mode().is_mcp() { - send_to_current_sender(format!("{} Failed!", self.msg)); + } else if out.format == Format::Human { + out.write(&format!("{} Failed!\n", self.msg)); } } } -/// spinner starts and returns a Spinner object. This is useful for long running tasks where you -/// want to demonstrate there's something happening. -pub fn spinner(msg: &str) -> Spinner { - Spinner::new(msg.into()) +/// Reports a fatal CLI usage error (such as a missing required flag) and exits. +/// Used during argument parsing, before a command's `Out` is in play. +pub fn die_usage(msg: &str) -> ! { + let line = format!("{} {}\n", "Error:".red(), msg); + let mut stdout = io::stdout(); + let _ = stdout.write_all(line.as_bytes()); + let _ = stdout.flush(); + std::process::exit(1); +} + +/// Writes a diagnostic error to stderr. For background tasks that have no `Out` +/// handle to write through (e.g. a spawned run-completion monitor). +pub fn background_error(msg: &str) { + write_to_stderr(&format!("{} {}\n", "Oh no!".red(), msg)); +} + +/// Writes a labelled notice to stderr, keeping stdout clean for command output. +pub(crate) fn notice_to_stderr(label: &str, msg: &str) { + let line = format!("{} {}\n", label.bold().yellow(), msg); + write_to_stderr(&line); +} + +fn write_to_stderr(msg: &str) { + let mut stderr = io::stderr(); + if let Err(err) = stderr.write_all(msg.as_bytes()) { + if err.kind() == io::ErrorKind::BrokenPipe { + std::process::exit(0); + } + panic!("failed writing to stderr: {err}"); + } + stderr.flush().ok(); } pub fn write_update_available_message(latest: &str, current: &str) { @@ -608,23 +661,6 @@ pub fn write_dev_version_message(current: &str, latest: &str) { write_to_stderr(&line); } -/// newline just outputs a newline. This is useful when you have a very specific formatting you -/// want to maintain and you don't want to use println!. -pub fn newline() { - write("\n"); -} - -pub fn die(msg: &str) -> ! { - io::stdout().flush().ok(); - io::stderr().flush().ok(); - let line = format!("{} {}\n", "Error:".red(), msg); - write(&line); - // Flush output before exit to ensure "Error:" message is displayed - io::stdout().flush().ok(); - io::stderr().flush().ok(); - std::process::exit(1); -} - pub struct ProgressBar { inner: indicatif::ProgressBar, } diff --git a/crates/tower-cmd/src/package.rs b/crates/tower-cmd/src/package.rs index c325bd26..b01b3f13 100644 --- a/crates/tower-cmd/src/package.rs +++ b/crates/tower-cmd/src/package.rs @@ -3,7 +3,6 @@ use config::{Config, Towerfile}; use std::path::PathBuf; use tokio::fs; -use crate::output; use tower_package::{Package, PackageSpec}; use tower_telemetry::debug; @@ -28,7 +27,7 @@ pub fn package_cmd() -> Command { .about("Create a package tar.gz file from your app without deploying") } -pub async fn do_package(_config: Config, args: &ArgMatches) { +pub async fn do_package(out: &crate::output::Out, _config: Config, args: &ArgMatches) { // Determine the directory to build the package from let dir = PathBuf::from( args.get_one::("dir") @@ -41,11 +40,11 @@ pub async fn do_package(_config: Config, args: &ArgMatches) { match Towerfile::from_path(path) { Ok(towerfile) => { let spec = PackageSpec::from_towerfile(&towerfile); - let mut spinner = output::spinner("Building package..."); + let mut spinner = out.spinner("Building package..."); match Package::build(spec).await { Ok(package) => { - spinner.success(); + spinner.success(out); // Get the output path let output_path = args @@ -55,24 +54,21 @@ pub async fn do_package(_config: Config, args: &ArgMatches) { // Save the package match save_package(&package, output_path).await { Ok(_) => { - output::success(&format!( - "Package created successfully: {}", - output_path - )); + out.success(&format!("Package created successfully: {}", output_path)); } Err(err) => { - output::error(&format!("Failed to save package: {}", err)); + out.error(&format!("Failed to save package: {}", err)); } } } Err(err) => { - spinner.failure(); - output::package_error(err); + spinner.failure(out); + out.package_error(err); } } } Err(err) => { - output::package_error(err); + out.package_error(err); } } } diff --git a/crates/tower-cmd/src/run.rs b/crates/tower-cmd/src/run.rs index 9d909a7b..514df320 100644 --- a/crates/tower-cmd/src/run.rs +++ b/crates/tower-cmd/src/run.rs @@ -77,8 +77,8 @@ pub fn run_cmd() -> Command { .about("Run your code in Tower or locally") } -pub async fn do_run(config: Config, args: &ArgMatches) { - if let Err(e) = do_run_inner(config, args).await { +pub async fn do_run(out: &output::Out, config: Config, args: &ArgMatches) { + if let Err(e) = do_run_inner(out, config, args).await { match e { Error::ApiRunError { ref source } => { let is_not_found = matches!( @@ -86,7 +86,7 @@ pub async fn do_run(config: Config, args: &ArgMatches) { tower_api::apis::Error::ResponseError(resp) if resp.status == reqwest::StatusCode::NOT_FOUND ); if is_not_found { - output::error(concat!( + out.error(concat!( "App not found. It may not exist or hasn't been deployed yet.\n", "\n", "To fix this:\n", @@ -97,12 +97,12 @@ pub async fn do_run(config: Config, args: &ArgMatches) { std::process::exit(1); } if let Error::ApiRunError { source } = e { - output::tower_error_and_die(source, "Scheduling run failed"); + out.tower_error_and_die(source, "Scheduling run failed"); } unreachable!(); } _ => { - output::die(&e.to_string()); + out.die(&e.to_string()); } } } @@ -110,8 +110,12 @@ pub async fn do_run(config: Config, args: &ArgMatches) { /// do_run is the primary entrypoint into running apps both locally and remotely in Tower. It will /// use the configuration to determine the requested way of running a Tower app. -pub async fn do_run_inner(config: Config, args: &ArgMatches) -> Result<(), Error> { - let res = get_run_parameters(args); +pub async fn do_run_inner( + out: &output::Out, + config: Config, + args: &ArgMatches, +) -> Result<(), Error> { + let res = get_run_parameters(out, args); // We always expect there to be an environment due to the fact that there is a // default value. @@ -128,13 +132,13 @@ pub async fn do_run_inner(config: Config, args: &ArgMatches) -> Result<(), Error if local { // For the time being, we should report that we can't run an app by name locally. if app_name.is_some() { - output::die("Running apps by name locally is not supported yet."); + out.die("Running apps by name locally is not supported yet."); } else { - do_run_local(config, path, env, params).await + do_run_local(out.clone(), config, path, env, params).await } } else { let follow = should_follow_run(args); - do_run_remote(config, path, env, params, app_name, follow).await + do_run_remote(out.clone(), config, path, env, params, app_name, follow).await } } Err(err) => Err(err.into()), @@ -143,6 +147,7 @@ pub async fn do_run_inner(config: Config, args: &ArgMatches) -> Result<(), Error /// Core implementation for running an app locally with configurable output handling async fn do_run_local_impl( + out: &output::Out, config: Config, path: PathBuf, env: &str, @@ -155,25 +160,25 @@ where T: Send + 'static, { // Load all the secrets and catalogs from the server - let mut spinner = output::spinner("Setting up runtime environment..."); + let mut spinner = out.spinner("Setting up runtime environment..."); let secrets = match get_secrets(&config, &env).await { Ok(s) => s, Err(err) => { - spinner.failure(); - output::tower_error_and_die(err, "Fetching secrets failed"); + spinner.failure(out); + out.tower_error_and_die(err, "Fetching secrets failed"); } }; let catalogs = match get_catalogs(&config, &env).await { Ok(c) => c, Err(err) => { - spinner.failure(); - output::tower_error_and_die(err, "Fetching catalogs failed"); + spinner.failure(out); + out.tower_error_and_die(err, "Fetching catalogs failed"); } }; - spinner.success(); + spinner.success(out); // We prepare all the other misc environment variables that we need to inject let mut env_vars = HashMap::new(); @@ -198,8 +203,8 @@ where } // Build the package (creates tar.gz) - let package = build_package(&towerfile).await?; - output::success(&format!("Launching app `{}`", towerfile.app.name)); + let package = build_package(out, &towerfile).await?; + out.success(&format!("Launching app `{}`", towerfile.app.name)); // Open the tar.gz file as a stream let package_path = package @@ -232,7 +237,7 @@ where // Monitor app status concurrently let handle = Arc::new(Mutex::new(handle)); - let status_task = tokio::spawn(monitor_cli_status(Arc::clone(&handle))); + let status_task = tokio::spawn(monitor_cli_status(out.clone(), Arc::clone(&handle))); // Wait for app to complete or SIGTERM let status_result = tokio::select! { @@ -240,8 +245,8 @@ where debug!("Status task completed, result: {:?}", status); status.unwrap() }, - _ = tokio::signal::ctrl_c(), if !output::get_output_mode().is_mcp() => { - output::write("\nReceived Ctrl+C, stopping local run...\n"); + _ = tokio::signal::ctrl_c(), if out.foreground() => { + out.write("\nReceived Ctrl+C, stopping local run...\n"); handle.lock().await.terminate().await.ok(); return Ok(output_task.await.unwrap()); } @@ -250,13 +255,13 @@ where // And if we crashed, err out match status_result { - Status::Exited => output::success("Your local run exited cleanly."), + Status::Exited => out.success("Your local run exited cleanly."), Status::Crashed { code } => { - output::error(&format!("Your local run crashed with exit code: {}", code)); + out.error(&format!("Your local run crashed with exit code: {}", code)); return Err(Error::AppCrashed); } Status::Cancelled => { - output::error("Your local run was cancelled."); + out.error("Your local run was cancelled."); return Err(Error::AppCrashed); } Status::Failed(failure) => { @@ -268,7 +273,7 @@ where error_message, } => format!("{} ({})", error_message, error_code), }; - output::error(&format!( + out.error(&format!( "Your local run failed due to a platform error: {}", detail )); @@ -276,7 +281,7 @@ where } _ => { debug!("Unexpected status after monitoring: {:?}", status_result); - output::error("An unexpected error occurred while monitoring your local run status!"); + out.error("An unexpected error occurred while monitoring your local run status!"); return Err(Error::AppCrashed); } } @@ -334,21 +339,30 @@ fn build_cli_execution_spec( /// the package, and launch the app. The relevant package is cleaned up after execution is /// complete. pub async fn do_run_local( + out: output::Out, config: Config, path: PathBuf, env: &str, params: HashMap, ) -> Result<(), Error> { - do_run_local_impl(config, path, env, params, |receiver| async { - monitor_output(receiver).await; - () - }) + let task_out = out.clone(); + do_run_local_impl( + &out, + config, + path, + env, + params, + move |receiver| async move { + monitor_output(task_out, receiver).await; + }, + ) .await } /// do_run_remote is the entrypoint for running an app remotely. It uses the Towerfile in the /// supplied directory (locally or remotely) to sort out what application to run exactly. pub async fn do_run_remote( + out: output::Out, config: Config, path: PathBuf, env: &str, @@ -365,28 +379,30 @@ pub async fn do_run_remote( towerfile.app.name }; - let res = output::try_with_spinner( - "Scheduling run", - api::run_app(&config, &app_slug, env, params), - ) - .await - .map_err(|source| Error::ApiRunError { source })?; + let res = out + .try_with_spinner( + "Scheduling run", + api::run_app(&config, &app_slug, env, params), + ) + .await + .map_err(|source| Error::ApiRunError { source })?; let run = res.run; if should_follow_run { - do_follow_run(config, &run).await?; + do_follow_run(&out, config, &run).await?; } else { let line = format!( "Run #{} for app `{}` has been scheduled\n See more: {}", run.number, app_slug, run.dollar_link ); - output::success_with_data(&line, Some(&run)); + out.success_with_data(&line, Some(&run)); } Ok(()) } async fn stream_logs_until_complete( + out: &output::Out, mut log_stream: MpscReceiver, mut run_complete: OneshotReceiver, enable_ctrl_c: bool, @@ -396,49 +412,52 @@ async fn stream_logs_until_complete( tokio::select! { event = log_stream.recv() => match event { Some(api::LogStreamEvent::EventLog(log)) => { - output::remote_log_event(&log); + out.remote_log_event(&log); }, None => return Ok(None), _ => {}, }, res = &mut run_complete => { let completed_run = res?; - drain_remaining_logs(log_stream).await; + drain_remaining_logs(out, log_stream).await; return Ok(Some(completed_run)); }, _ = tokio::signal::ctrl_c(), if enable_ctrl_c => { - output::write("Received Ctrl+C, stopping log streaming...\n"); - output::write("Note: The run will continue in Tower cloud\n"); - output::write(&format!(" See more: {}\n", run_link)); + out.write("Received Ctrl+C, stopping log streaming...\n"); + out.write("Note: The run will continue in Tower cloud\n"); + out.write(&format!(" See more: {}\n", run_link)); return Ok(None); }, } } } -async fn drain_remaining_logs(mut log_stream: MpscReceiver) { +async fn drain_remaining_logs( + out: &output::Out, + mut log_stream: MpscReceiver, +) { let drain_duration = Duration::from_secs(5); let _ = timeout(drain_duration, async { while let Some(event) = log_stream.recv().await { if let api::LogStreamEvent::EventLog(log) = event { - output::remote_log_event(&log); + out.remote_log_event(&log); } } }) .await; } -async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { - let enable_ctrl_c = !output::get_output_mode().is_mcp(); - let mut spinner = output::spinner("Waiting for run to start..."); +async fn do_follow_run(out: &output::Out, config: Config, run: &Run) -> Result<(), Error> { + let enable_ctrl_c = out.foreground(); + let mut spinner = out.spinner("Waiting for run to start..."); match wait_for_run_start(&config, &run).await { Err(err) => { - spinner.failure(); + spinner.failure(out); return Err(err); } Ok(()) => { - spinner.success(); - output::write("Run started, streaming logs...\n"); + spinner.success(out); + out.write("Run started, streaming logs...\n"); // We do this here, explicitly, to not double-monitor our API via the // `wait_for_run_start` function above. @@ -449,6 +468,7 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { match api::stream_run_logs(&config, &run.app_name, run.number).await { Ok(log_stream) => { let completed_run = stream_logs_until_complete( + out, log_stream, run_complete, enable_ctrl_c, @@ -457,11 +477,11 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { .await?; if let Some(run) = completed_run { - handle_run_completion(Ok(run))?; + handle_run_completion(out, Ok(run))?; } } Err(err) => { - output::error(&format!("Failed to stream run logs: {:?}", err)); + out.error(&format!("Failed to stream run logs: {:?}", err)); return Err(Error::LogStreamFailed); } } @@ -471,32 +491,35 @@ async fn do_follow_run(config: Config, run: &Run) -> Result<(), Error> { Ok(()) } -fn handle_run_completion(res: Result) -> Result<(), Error> { +fn handle_run_completion( + out: &output::Out, + res: Result, +) -> Result<(), Error> { match res { Ok(completed_run) => match completed_run.status { tower_api::models::run::Status::Errored => { - output::error(&format!( + out.error(&format!( "Run #{} for app '{}' had an error", completed_run.number, completed_run.app_name )); Err(Error::RunFailed) } tower_api::models::run::Status::Crashed => { - output::error(&format!( + out.error(&format!( "Run #{} for app '{}' crashed", completed_run.number, completed_run.app_name )); Err(Error::RunCrashed) } tower_api::models::run::Status::Cancelled => { - output::error(&format!( + out.error(&format!( "Run #{} for app '{}' was cancelled", completed_run.number, completed_run.app_name )); Err(Error::RunCancelled) } _ => { - output::success(&format!( + out.success(&format!( "Run #{} for app '{}' completed successfully", completed_run.number, completed_run.app_name )); @@ -504,7 +527,7 @@ fn handle_run_completion(res: Result) -> Result< } }, Err(err) => { - output::error(&format!("Failed to monitor run completion: {:?}", err)); + out.error(&format!("Failed to monitor run completion: {:?}", err)); Err(err.into()) } } @@ -513,11 +536,12 @@ fn handle_run_completion(res: Result) -> Result< /// Extracts the local/remote flag, Towerfile directory, parameters, and optional app name /// from the parsed CLI args. fn get_run_parameters( + out: &output::Out, args: &ArgMatches, ) -> Result<(bool, PathBuf, HashMap, Option), crate::Error> { let local = *args.get_one::("local").unwrap(); let path = resolve_path(args); - let params = parse_parameters(args); + let params = parse_parameters(out, args); let app_name = args.get_one::("app_name").cloned(); Ok((local, path, params, app_name)) @@ -530,7 +554,7 @@ fn should_follow_run(args: &ArgMatches) -> bool { /// Parses `--parameter` arguments into a HashMap of key-value pairs. /// Handles format like "--parameter key=value" -fn parse_parameters(args: &ArgMatches) -> HashMap { +fn parse_parameters(out: &output::Out, args: &ArgMatches) -> HashMap { let mut param_map = HashMap::new(); if let Some(parameters) = args.get_many::("parameters") { @@ -538,7 +562,7 @@ fn parse_parameters(args: &ArgMatches) -> HashMap { match param.split_once('=') { Some((key, value)) => { if key.is_empty() { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Key cannot be empty.", param )); @@ -547,7 +571,7 @@ fn parse_parameters(args: &ArgMatches) -> HashMap { param_map.insert(key.to_string(), value.to_string()); } None => { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Expected 'key=value'.", param )); @@ -664,16 +688,16 @@ fn load_towerfile(path: &PathBuf) -> Result { /// build_package manages the process of building a package in an interactive way for local app /// execution. If the pacakge fails to build for wahatever reason, the app will exit. -async fn build_package(towerfile: &Towerfile) -> Result { - let mut spinner = output::spinner("Building package..."); +async fn build_package(out: &output::Out, towerfile: &Towerfile) -> Result { + let mut spinner = out.spinner("Building package..."); let package_spec = PackageSpec::from_towerfile(towerfile); match Package::build(package_spec).await { Ok(package) => { - spinner.success(); + spinner.success(out); Ok(package) } Err(err) => { - spinner.failure(); + spinner.failure(out); debug!("Failed to build package: {}", err); Err(err.into()) } @@ -682,11 +706,11 @@ async fn build_package(towerfile: &Towerfile) -> Result { /// monitor_output is a helper function that will monitor the output of a given output channel and /// plops it down on stdout. -async fn monitor_output(mut output: OutputReceiver) { +async fn monitor_output(out: output::Out, mut output: OutputReceiver) { loop { if let Some(line) = output.recv().await { let ts = dates::format(line.time); - output::log_line(&ts, &line.line, output::LogLineType::Local); + out.log_line(&ts, &line.line, output::LogLineType::Local); } else { break; } @@ -696,6 +720,7 @@ async fn monitor_output(mut output: OutputReceiver) { /// monitor_local_status is a helper function that will monitor the status of a given app and waits for /// it to progress to a terminal state. async fn monitor_cli_status( + out: output::Out, handle: Arc>, ) -> Status { use tower_runtime::execution::ExecutionHandle as _; @@ -744,7 +769,7 @@ async fn monitor_cli_status( // If we get five errors in a row, we abandon monitoring. if err_count >= 5 { debug!("Failed to get handle status after 5 attempts, giving up"); - output::error("An error occured while monitoring your local run status!"); + out.error("An error occured while monitoring your local run status!"); return Status::Crashed { code: -1 }; } diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index 7acc75ce..493d39e8 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -3,7 +3,7 @@ use colored::Colorize; use config::Config; use std::collections::HashMap; -use crate::{api, output}; +use crate::api; use tower_api::models::schedule::Status; @@ -110,18 +110,19 @@ pub fn schedules_cmd() -> Command { ) } -pub async fn do_list(config: Config, args: &ArgMatches) { +pub async fn do_list(out: &crate::output::Out, config: Config, args: &ArgMatches) { let app = args.get_one::("app").map(|s| s.as_str()); let environment = args.get_one::("environment").map(|s| s.as_str()); - let schedules = output::with_spinner( - "Listing schedules", - api::list_schedules(&config, app, environment), - ) - .await; + let schedules = out + .with_spinner( + "Listing schedules", + api::list_schedules(&config, app, environment), + ) + .await; if schedules.is_empty() { - output::text("No schedules found.\n", &schedules); + out.text("No schedules found.\n", &schedules); return; } @@ -148,60 +149,64 @@ pub async fn do_list(config: Config, args: &ArgMatches) { }) .collect(); - output::table(headers, rows, Some(&schedules)); + out.table(headers, rows, Some(&schedules)); } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &crate::output::Out, config: Config, args: &ArgMatches) { let app_name = args.get_one::("app").unwrap(); let environment = args.get_one::("environment").unwrap(); let cron = args.get_one::("cron").unwrap(); - let parameters = parse_parameters(args); + let parameters = parse_parameters(out, args); - let response = output::with_spinner( - "Creating schedule", - api::create_schedule(&config, app_name, environment, cron, parameters), - ) - .await; + let response = out + .with_spinner( + "Creating schedule", + api::create_schedule(&config, app_name, environment, cron, parameters), + ) + .await; - output::success(&format!( + out.success(&format!( "Schedule created with ID: {}", response.schedule.id )); } -pub async fn do_update(config: Config, args: &ArgMatches) { +pub async fn do_update(out: &crate::output::Out, config: Config, args: &ArgMatches) { let id_or_name = args .get_one::("id_or_name") .expect("id_or_name is required"); let cron = args.get_one::("cron"); - let parameters = parse_parameters(args); + let parameters = parse_parameters(out, args); - output::with_spinner( + out.with_spinner( "Updating schedule", api::update_schedule(&config, id_or_name, cron, parameters), ) .await; - output::success(&format!("Schedule {} updated", id_or_name)); + out.success(&format!("Schedule {} updated", id_or_name)); } -pub async fn do_delete(config: Config, args: &ArgMatches) { +pub async fn do_delete(out: &crate::output::Out, config: Config, args: &ArgMatches) { let schedule_id = args .get_one::("schedule_id") .expect("schedule_id is required"); - output::with_spinner( + out.with_spinner( "Deleting schedule", api::delete_schedule(&config, schedule_id), ) .await; - output::success(&format!("Schedule {} deleted", schedule_id)); + out.success(&format!("Schedule {} deleted", schedule_id)); } /// Parses `--parameter` arguments into a HashMap of key-value pairs. /// Handles format like "--parameter key=value" -fn parse_parameters(args: &ArgMatches) -> Option> { +fn parse_parameters( + out: &crate::output::Out, + args: &ArgMatches, +) -> Option> { let mut param_map = HashMap::new(); if let Some(parameters) = args.get_many::("parameters") { @@ -209,7 +214,7 @@ fn parse_parameters(args: &ArgMatches) -> Option> { match param.split_once('=') { Some((key, value)) => { if key.is_empty() { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Key cannot be empty.", param )); @@ -218,7 +223,7 @@ fn parse_parameters(args: &ArgMatches) -> Option> { param_map.insert(key.to_string(), value.to_string()); } None => { - output::error(&format!( + out.error(&format!( "Invalid parameter format: '{}'. Expected 'key=value'.", param )); @@ -239,6 +244,7 @@ fn parse_parameters(args: &ArgMatches) -> Option> { #[cfg(test)] mod tests { use super::{parse_parameters, schedules_cmd}; + use crate::output::Out; #[test] fn update_accepts_positional_schedule_id_and_flags() { @@ -349,7 +355,8 @@ mod tests { panic!("expected update subcommand"); }; - let params = parse_parameters(update_args).expect("expected parsed parameters"); + let params = + parse_parameters(&Out::sink(), update_args).expect("expected parsed parameters"); assert_eq!(params.get("env"), Some(&"prod".to_string())); assert_eq!(params.get("team"), Some(&"platform".to_string())); } @@ -373,7 +380,7 @@ mod tests { panic!("expected update subcommand"); }; - assert_eq!(parse_parameters(update_args), None); + assert_eq!(parse_parameters(&Out::sink(), update_args), None); } #[test] @@ -395,7 +402,8 @@ mod tests { panic!("expected update subcommand"); }; - let params = parse_parameters(update_args).expect("expected parsed parameters"); + let params = + parse_parameters(&Out::sink(), update_args).expect("expected parsed parameters"); assert_eq!(params.get("env"), Some(&"prod".to_string())); assert_eq!(params.len(), 1); } diff --git a/crates/tower-cmd/src/secrets.rs b/crates/tower-cmd/src/secrets.rs index 16c67009..25acafbb 100644 --- a/crates/tower-cmd/src/secrets.rs +++ b/crates/tower-cmd/src/secrets.rs @@ -7,7 +7,7 @@ use rsa::pkcs1::DecodeRsaPublicKey; use tower_api::models::CreateSecretResponse; use tower_telemetry::debug; -use crate::{api, output, util::cmd}; +use crate::{api, util::cmd}; pub fn secrets_cmd() -> Command { Command::new("secrets") @@ -93,7 +93,7 @@ pub fn secrets_cmd() -> Command { ) } -pub async fn do_list(config: Config, args: &ArgMatches) { +pub async fn do_list(out: &crate::output::Out, config: Config, args: &ArgMatches) { let all = cmd::get_bool_flag(args, "all"); let show = cmd::get_bool_flag(args, "show"); let env = cmd::get_string_flag(args, "environment"); @@ -106,11 +106,12 @@ pub async fn do_list(config: Config, args: &ArgMatches) { if show { let (private_key, public_key) = crypto::generate_key_pair(); - let list_response = output::with_spinner( - "Listing secrets", - api::export_secrets(&config, &env, all, public_key), - ) - .await; + let list_response = out + .with_spinner( + "Listing secrets", + api::export_secrets(&config, &env, all, public_key), + ) + .await; let headers = vec!["Secret", "Environment", "Value"] .into_iter() @@ -131,10 +132,11 @@ pub async fn do_list(config: Config, args: &ArgMatches) { ] }) .collect(); - output::table(headers, data, Some(&list_response.secrets)); + out.table(headers, data, Some(&list_response.secrets)); } else { - let secrets = - output::with_spinner("Listing secrets", api::list_secrets(&config, &env, all)).await; + let secrets = out + .with_spinner("Listing secrets", api::list_secrets(&config, &env, all)) + .await; let headers = vec!["Secret", "Environment", "Preview"] .into_iter() @@ -150,40 +152,40 @@ pub async fn do_list(config: Config, args: &ArgMatches) { ] }) .collect(); - output::table(headers, data, Some(&secrets)); + out.table(headers, data, Some(&secrets)); } } -pub async fn do_create(config: Config, args: &ArgMatches) { +pub async fn do_create(out: &crate::output::Out, config: Config, args: &ArgMatches) { let name = cmd::get_string_flag(args, "name"); let environment = cmd::get_string_flag(args, "environment"); let value = cmd::get_string_flag(args, "value"); - let mut spinner = output::spinner("Creating secret..."); + let mut spinner = out.spinner("Creating secret..."); - match encrypt_and_create_secret(&config, &name, &value, &environment).await { + match encrypt_and_create_secret(out, &config, &name, &value, &environment).await { Ok(_) => { - spinner.success(); + spinner.success(out); let line = format!("Secret {} created in environment {}", name, environment,); - output::success(&line); + out.success(&line); } Err(err) => { - spinner.failure(); + spinner.failure(out); match err { SecretCreationError::FetchKeyFailed(e) => { - output::tower_error_and_die(e, "Fetching secrets key failed"); + out.tower_error_and_die(e, "Fetching secrets key failed"); } SecretCreationError::CreateFailed(e) => { - output::tower_error_and_die(e, "Creating secret failed"); + out.tower_error_and_die(e, "Creating secret failed"); } } } } } -pub async fn do_delete(config: Config, args: &ArgMatches) { +pub async fn do_delete(out: &crate::output::Out, config: Config, args: &ArgMatches) { let secret_name_arg = args .get_one::("secret_name") .expect("secret_name is required"); @@ -197,7 +199,7 @@ pub async fn do_delete(config: Config, args: &ArgMatches) { }; debug!("deleting secret, environment={} name={}", environment, name); - output::with_spinner( + out.with_spinner( "Deleting secret", api::delete_secret(&config, &name, &environment), ) @@ -223,6 +225,7 @@ enum SecretCreationError { } async fn encrypt_and_create_secret( + out: &crate::output::Out, config: &Config, name: &str, value: &str, @@ -233,7 +236,7 @@ async fn encrypt_and_create_secret( .map_err(SecretCreationError::FetchKeyFailed)?; let public_key = rsa::RsaPublicKey::from_pkcs1_pem(&res.public_key).unwrap_or_else(|_| { - output::die("Failed to parse public key"); + out.die("Failed to parse public key"); }); let encrypted_value = encrypt(public_key, value.to_string()).unwrap(); diff --git a/crates/tower-cmd/src/session.rs b/crates/tower-cmd/src/session.rs index 32d88987..509b7bd1 100644 --- a/crates/tower-cmd/src/session.rs +++ b/crates/tower-cmd/src/session.rs @@ -20,11 +20,11 @@ pub fn login_cmd() -> Command { .about("Create a session with Tower") } -pub async fn do_login(config: Config, args: &ArgMatches) { - output::banner(); +pub async fn do_login(out: &crate::output::Out, config: Config, args: &ArgMatches) { + out.banner(); if std::env::var("TOWER_API_KEY").is_ok() { - output::write(&format!( + out.write(&format!( "{} TOWER_API_KEY is set. As long as this environment variable is present, \ the CLI will authenticate using the API key and ignore the session \ created by this login flow.\n", @@ -43,21 +43,22 @@ pub async fn do_login(config: Config, args: &ArgMatches) { // Open a browser by default, unless the --no-browser flag is set. let open_browser = !args.get_flag("no-browser"); - let mut spinner = output::spinner("Starting device login..."); + let mut spinner = out.spinner("Starting device login..."); match api::create_device_login_ticket(&config).await { Ok(resp) => { - spinner.success(); - handle_device_login(config, open_browser, resp).await; + spinner.success(out); + handle_device_login(out, config, open_browser, resp).await; } Err(err) => { - spinner.failure(); - output::error(&format!("Failed to create device login ticket: {}", err)); + spinner.failure(out); + out.error(&format!("Failed to create device login ticket: {}", err)); } } } async fn handle_device_login( + out: &crate::output::Out, config: Config, open_browser: bool, claim: CreateDeviceLoginTicketResponse, @@ -74,23 +75,24 @@ async fn handle_device_login( if open_browser { if let Err(err) = webbrowser::open(&claim.login_url) { debug!("failed to open web browser: {}", err); - output::write(&login_instructions); + out.write(&login_instructions); } else { debug!("opened browser to {}", claim.login_url); } } else { - output::write(&login_instructions); + out.write(&login_instructions); } - let mut spinner = output::spinner("Waiting for login..."); + let mut spinner = out.spinner("Waiting for login..."); - if !poll_for_login(&config, &claim, &mut spinner).await { - spinner.failure(); - output::error("Login request expired. Please try again."); + if !poll_for_login(out, &config, &claim, &mut spinner).await { + spinner.failure(out); + out.error("Login request expired. Please try again."); } } async fn poll_for_login( + out: &crate::output::Out, config: &Config, claim: &CreateDeviceLoginTicketResponse, spinner: &mut output::Spinner, @@ -104,17 +106,17 @@ async fn poll_for_login( while chrono::Utc::now() < expires_at { match api::describe_device_login_session(&config, &claim.device_code).await { Ok(resp) => { - finalize_session(config, &resp, spinner); + finalize_session(out, config, &resp, spinner); return true; } Err(err) => { if let Some(api_err) = extract_api_error(&err) { if api_err.status != 404 && !api_err.is_incomplete_device_login { - output::error(&format!("{}", api_err.content)); + out.error(&format!("{}", api_err.content)); return false; } } else { - output::error(&format!("An unexpected error happened! Error: {}", err)); + out.error(&format!("An unexpected error happened! Error: {}", err)); return false; } } @@ -127,6 +129,7 @@ async fn poll_for_login( } fn finalize_session( + out: &crate::output::Out, config: &Config, session_response: &tower_api::models::DescribeDeviceLoginSessionResponse, spinner: &mut output::Spinner, @@ -141,12 +144,12 @@ fn finalize_session( session.tower_url = url; if let Err(err) = session.save() { - spinner.failure(); - output::error(&format!("Failed to save session: {}", err)); + spinner.failure(out); + out.error(&format!("Failed to save session: {}", err)); } else { - spinner.success(); + spinner.success(out); let message = format!("Hello, {}!", session_response.session.user.email.clone()); - output::success(&message); + out.success(&message); } } diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs index 7e180a70..d607d9ad 100644 --- a/crates/tower-cmd/src/teams.rs +++ b/crates/tower-cmd/src/teams.rs @@ -2,7 +2,7 @@ use clap::{value_parser, Arg, ArgMatches, Command}; use colored::*; use config::Config; -use crate::{api, output}; +use crate::api; pub fn teams_cmd() -> Command { Command::new("teams") @@ -23,53 +23,57 @@ pub fn teams_cmd() -> Command { } /// Refreshes the session with the Tower API and returns the updated session -async fn refresh_session(config: &Config) -> config::Session { +async fn refresh_session(out: &crate::output::Out, config: &Config) -> config::Session { // First get the current session let current_session = match config.get_current_session() { Ok(session) => session, Err(e) => { - output::config_error(e); + out.config_error(e); std::process::exit(1); } }; - let resp = output::with_spinner("Refreshing session", api::refresh_session(&config)).await; + let resp = out + .with_spinner("Refreshing session", api::refresh_session(&config)) + .await; // Create a mutable copy of the session to update let mut session = current_session; // Update it with the API response if let Err(e) = session.update_from_api_response(&resp) { - output::config_error(e); + out.config_error(e); std::process::exit(1); } session } -pub async fn do_list(config: Config) { +pub async fn do_list(out: &crate::output::Out, config: Config) { if config.api_key.is_some() { - do_list_via_api(&config).await; + do_list_via_api(out, &config).await; } else { - do_list_via_session(&config).await; + do_list_via_session(out, &config).await; } } -async fn do_list_via_api(config: &Config) { - let teams = output::with_spinner("Fetching teams", api::list_teams(config)).await; +async fn do_list_via_api(out: &crate::output::Out, config: &Config) { + let teams = out + .with_spinner("Fetching teams", api::list_teams(config)) + .await; let headers = vec!["Name".to_string()]; let teams_data: Vec> = teams.iter().map(|team| vec![team.name.clone()]).collect(); - output::newline(); - output::table(headers, teams_data, None::<&Vec>); - output::newline(); + out.newline(); + out.table(headers, teams_data, None::<&Vec>); + out.newline(); } -async fn do_list_via_session(config: &Config) { +async fn do_list_via_session(out: &crate::output::Out, config: &Config) { // Refresh the session and get the updated data - let session = refresh_session(config).await; + let session = refresh_session(out, config).await; // Get the current active team from the session let active_team = session.active_team.clone(); @@ -94,26 +98,26 @@ async fn do_list_via_session(config: &Config) { }) .collect(); - output::newline(); + out.newline(); // Display the table using the existing table function - output::table(headers, teams_data, Some(&teams)); - output::newline(); + out.table(headers, teams_data, Some(&teams)); + out.newline(); // Add a legend for the asterisk - output::note(&format!( + out.note(&format!( "{}\n", "* indicates currently active team".dimmed() )); - output::newline(); + out.newline(); } -pub async fn do_switch(config: Config, args: &ArgMatches) { +pub async fn do_switch(out: &crate::output::Out, config: Config, args: &ArgMatches) { let name = args .get_one::("team_name") .expect("team_name is required"); // Refresh the session first to ensure we have the latest teams data - let session = refresh_session(&config).await; + let session = refresh_session(out, &config).await; // Check if the provided team name exists in the refreshed session let team = session.teams.iter().find(|team| team.name == *name); @@ -123,17 +127,17 @@ pub async fn do_switch(config: Config, args: &ArgMatches) { // Team found, set it as active match config.set_active_team_by_name(name) { Ok(_) => { - output::success(&format!("Switched to team: {}", team.name)); + out.success(&format!("Switched to team: {}", team.name)); } Err(e) => { - output::config_error(e); + out.config_error(e); std::process::exit(1); } } } None => { // Team not found - output::error(&format!( + out.error(&format!( "Team '{}' not found. Use 'tower teams list' to see all your teams.", name, )); diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 3b6cf49c..4cadce8a 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -7,13 +7,14 @@ use tower_api::apis::{ use tower_api::models::CreateAppParams as CreateAppParamsModel; pub async fn ensure_app_exists( + out: &output::Out, api_config: &Configuration, app_name: &str, description: Option<&str>, create_app: bool, ) -> Result<(), crate::Error> { // Try to describe the app first (with spinner) - let mut spinner = output::spinner("Checking app..."); + let mut spinner = out.spinner("Checking app..."); let describe_result = default_api::describe_app( api_config, DescribeAppParams { @@ -29,7 +30,7 @@ pub async fn ensure_app_exists( // If the app exists, return Ok (description is create-only). if describe_result.is_ok() { - spinner.success(); + spinner.success(out); return Ok(()); } @@ -48,7 +49,7 @@ pub async fn ensure_app_exists( // If it's not a 404 error, fail the spinner and return the error if !is_not_found { - spinner.failure(); + spinner.failure(out); return Err(crate::Error::ApiDescribeAppError { source: err }); } @@ -72,7 +73,7 @@ pub async fn ensure_app_exists( } // Try to create the app (with a new spinner) - let mut spinner = output::spinner("Creating app..."); + let mut spinner = out.spinner("Creating app..."); let create_result = default_api::create_app( api_config, CreateAppParams { @@ -91,12 +92,12 @@ pub async fn ensure_app_exists( match create_result { Ok(_) => { - spinner.success(); - output::success(&format!("Created app '{}'", app_name)); + spinner.success(out); + out.success(&format!("Created app '{}'", app_name)); Ok(()) } Err(create_err) => { - spinner.failure(); + spinner.failure(out); Err(crate::Error::ApiCreateAppError { source: create_err }) } } diff --git a/crates/tower-cmd/src/util/cmd.rs b/crates/tower-cmd/src/util/cmd.rs index 49148410..22ae71d5 100644 --- a/crates/tower-cmd/src/util/cmd.rs +++ b/crates/tower-cmd/src/util/cmd.rs @@ -4,7 +4,7 @@ use clap::ArgMatches; pub fn get_string_flag(args: &ArgMatches, name: &str) -> String { args.get_one::(name) .unwrap_or_else(|| { - output::die(&format!("{} is required", name)); + output::die_usage(&format!("{} is required", name)); }) .to_string() } @@ -12,7 +12,7 @@ pub fn get_string_flag(args: &ArgMatches, name: &str) -> String { pub fn get_bool_flag(args: &ArgMatches, name: &str) -> bool { args.get_one::(name) .unwrap_or_else(|| { - output::die(&format!("{} is required", name)); + output::die_usage(&format!("{} is required", name)); }) .to_owned() } diff --git a/crates/tower-cmd/src/util/deploy.rs b/crates/tower-cmd/src/util/deploy.rs index 9931a395..3ce5c5de 100644 --- a/crates/tower-cmd/src/util/deploy.rs +++ b/crates/tower-cmd/src/util/deploy.rs @@ -15,6 +15,7 @@ use tower_api::apis::ResponseContent; use tower_api::models::DeployAppResponse; pub async fn upload_file_with_progress( + out: &output::Out, api_config: &Configuration, endpoint_url: String, file_path: PathBuf, @@ -26,7 +27,7 @@ pub async fn upload_file_with_progress( Ok(hash) => hash, Err(e) => { debug!("Failed to compute package hash: {}", e); - output::die("Tower CLI failed to properly prepare your package for deployment. Check that you have permissions to read/write to your temporary directory, and if it keeps happening contact Tower support at https://tower.dev"); + out.die("Tower CLI failed to properly prepare your package for deployment. Check that you have permissions to read/write to your temporary directory, and if it keeps happening contact Tower support at https://tower.dev"); } }; @@ -39,7 +40,7 @@ pub async fn upload_file_with_progress( if file_size > tower_package::MAX_PACKAGE_SIZE { let size_mb = file_size as f64 / (1024.0 * 1024.0); let max_mb = tower_package::MAX_PACKAGE_SIZE as f64 / (1024.0 * 1024.0); - output::die(&format!( + out.die(&format!( "Your App is too big! ({:.2} MB) exceeds maximum allowed size ({:.0} MB). Please consider reducing app size by removing unnecessary files or import_paths in the Towerfile.", size_mb, max_mb )); @@ -110,6 +111,7 @@ pub async fn upload_file_with_progress( } pub async fn deploy_app_package( + out: &output::Out, api_config: &tower_api::apis::configuration::Configuration, app_name: &str, package: Package, @@ -131,7 +133,7 @@ pub async fn deploy_app_package( // Get the package file path let package_path = package.package_file_path.unwrap_or_else(|| { debug!("No package file path found"); - output::die("An error happened in Tower CLI that it couldn't recover from."); + out.die("An error happened in Tower CLI that it couldn't recover from."); }); // Create the URL for the API endpoint @@ -155,6 +157,7 @@ pub async fn deploy_app_package( // Upload the package let response = upload_file_with_progress( + out, api_config, url, package_path, @@ -167,7 +170,7 @@ pub async fn deploy_app_package( // Finish the progress bar let progress_bar = progress_bar.lock().unwrap(); progress_bar.finish(); - output::newline(); + out.newline(); Ok(response) } diff --git a/crates/tower-cmd/src/util/git.rs b/crates/tower-cmd/src/util/git.rs index d39181ee..7ae6955b 100644 --- a/crates/tower-cmd/src/util/git.rs +++ b/crates/tower-cmd/src/util/git.rs @@ -13,12 +13,18 @@ use tower_telemetry::debug; /// This is used to auto-populate the `X-Tower-Idempotency-Key` header on deploy. pub fn clean_head_sha(dir: &Path) -> Option { if !is_inside_work_tree(dir) { - debug!("{:?} is not inside a git worktree; skipping idempotency key", dir); + debug!( + "{:?} is not inside a git worktree; skipping idempotency key", + dir + ); return None; } if is_dirty(dir) { - debug!("git worktree at {:?} is dirty; omitting idempotency key", dir); + debug!( + "git worktree at {:?} is dirty; omitting idempotency key", + dir + ); return None; } @@ -43,8 +49,7 @@ fn is_inside_work_tree(dir: &Path) -> bool { None => return false, }; - output.status.success() - && String::from_utf8_lossy(&output.stdout).trim() == "true" + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" } /// Returns `true` when there are staged, unstaged, or untracked changes in the diff --git a/crates/tower-cmd/src/version.rs b/crates/tower-cmd/src/version.rs index 92682dcb..b356931e 100644 --- a/crates/tower-cmd/src/version.rs +++ b/crates/tower-cmd/src/version.rs @@ -1,13 +1,13 @@ -use crate::output; +use crate::output::Out; use clap::Command; pub fn version_cmd() -> Command { Command::new("version").about("Print the current version of Tower") } -pub async fn do_version() { +pub async fn do_version(out: &Out) { let version = tower_version::current_version(); - output::text( + out.text( &format!("v{}\n", version), &serde_json::json!({ "version": version }), ); From 0377f82fbca0af8c085fc168442235a493ce1f15 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 16 Jul 2026 15:02:56 +0200 Subject: [PATCH 06/18] Add support for querying Iceberg from the CLI (#325) * chore: Add support for querying Iceberg from the CLI * chore: Combine stages together in spinners, and require queries be read only * chore: Add explicit write mode for queries, so they can't be done accidentally * chore: Address some feedback from CodeRabbit * refactor: harden catalog SQL generation against injection Escaping now lives in SqlLiteral/SqlIdent Display wrappers instead of free functions each call site must remember, the table listing binds the catalog name as a real prepared-statement parameter, and attach_statements takes the vended credential Mode so call sites no longer pass an invertible read_only bool. * fix: convert nested DuckDB values to JSON and tilde-pin duckdb List/Array/Struct/Map/Union/Enum columns now render as proper JSON arrays and objects in --json output instead of Rust debug strings. The duckdb requirement uses a tilde constraint per upstream guidance, since the crate's second version component encodes the bundled DuckDB engine and a caret requirement would let cargo update swap engines silently. --- Cargo.lock | 950 ++++++++++++++++++++++++++++++- Cargo.toml | 1 + crates/tower-cmd/Cargo.toml | 1 + crates/tower-cmd/src/catalogs.rs | 728 ++++++++++++++++++++++- crates/tower-cmd/src/lib.rs | 3 + 5 files changed, 1646 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e39ade0..4025f0c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -52,6 +52,31 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -132,6 +157,184 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "arrow" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num-traits", +] + +[[package]] +name = "arrow-array" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" +dependencies = [ + "ahash 0.8.12", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.17.1", + "num-complex", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-buffer" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +dependencies = [ + "bytes", + "half", + "num-bigint", + "num-traits", +] + +[[package]] +name = "arrow-cast" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", + "atoi", + "base64", + "chrono", + "comfy-table", + "half", + "lexical-core", + "num-traits", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num-integer", + "num-traits", +] + +[[package]] +name = "arrow-ord" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "arrow-select" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" +dependencies = [ + "ahash 0.8.12", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num-traits", +] + +[[package]] +name = "arrow-string" +version = "58.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num-traits", + "regex", + "regex-syntax 0.8.5", +] + [[package]] name = "async-compression" version = "0.4.27" @@ -194,6 +397,15 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -293,6 +505,18 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -302,6 +526,30 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "bstr" version = "1.12.0" @@ -318,6 +566,28 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytecount" version = "0.6.9" @@ -336,12 +606,20 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -363,6 +641,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.41" @@ -489,6 +778,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm 0.28.1", + "unicode-segmentation", + "unicode-width 0.2.1", +] + [[package]] name = "config" version = "0.3.69" @@ -529,6 +829,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -563,6 +883,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -606,6 +935,19 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.9.1", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -633,6 +975,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto" version = "0.3.69" @@ -848,6 +1196,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -966,6 +1325,24 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" +[[package]] +name = "duckdb" +version = "1.10504.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9b997383221efd999a362448a0866c54b9a2cb7d4da8cd4903ead4df9f8eaa" +dependencies = [ + "arrow", + "cast", + "comfy-table", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libduckdb-sys", + "num-integer", + "rust_decimal", + "strum 0.27.2", +] + [[package]] name = "dyn-clone" version = "1.0.19" @@ -1027,6 +1404,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1063,6 +1452,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -1072,6 +1462,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1091,6 +1487,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -1225,8 +1627,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1237,10 +1641,24 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1304,17 +1722,50 @@ dependencies = [ "walkdir", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.4", +] [[package]] name = "heck" @@ -1657,7 +2108,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ "bitflags 2.9.1", - "crossterm", + "crossterm 0.29.0", "dyn-clone", "fuzzy-matcher", "unicode-segmentation", @@ -1743,6 +2194,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -1785,12 +2246,86 @@ dependencies = [ "spin", ] -[[package]] -name = "libc" -version = "0.2.174" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" - +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libduckdb-sys" +version = "1.10504.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e67523a40ee3da30411e00e243990b3760d91877202ab2f39c03ab34f8dbfc" +dependencies = [ + "cc", + "flate2", + "pkg-config", + "reqwest", + "serde", + "serde_json", + "tar", + "vcpkg", + "zip", +] + [[package]] name = "libm" version = "0.2.15" @@ -1808,6 +2343,15 @@ dependencies = [ "redox_syscall 0.5.15", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1850,9 +2394,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "maplit" @@ -2004,6 +2554,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -2021,6 +2581,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2267,6 +2836,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "polyval" version = "0.6.2" @@ -2274,7 +2849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2309,6 +2884,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.5", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2327,6 +2911,26 @@ dependencies = [ "rustyline", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pyo3" version = "0.28.0" @@ -2385,6 +2989,62 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.0", + "thiserror 2.0.12", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.12", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.0", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.40" @@ -2400,6 +3060,18 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "radix_trie" version = "0.2.1" @@ -2431,6 +3103,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2469,6 +3152,21 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -2568,6 +3266,15 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.22" @@ -2576,6 +3283,7 @@ checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -2589,6 +3297,7 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "serde", @@ -2639,6 +3348,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rmcp" version = "0.5.0" @@ -2724,12 +3462,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-demangle" version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2785,6 +3546,7 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -3067,7 +3829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3126,6 +3888,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.10" @@ -3211,7 +3985,7 @@ checksum = "a0ef947f358b9c238923f764c72a4a9d42f2d637c46e059dbd319d6e7cfb4f82" dependencies = [ "lazy_static", "maplit", - "strum", + "strum 0.24.1", ] [[package]] @@ -3261,7 +4035,16 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" dependencies = [ - "strum_macros", + "strum_macros 0.24.3", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -3277,6 +4060,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3358,6 +4153,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.45" @@ -3506,6 +4307,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -3516,6 +4326,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tmpdir" version = "1.0.0" @@ -3629,8 +4454,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3642,6 +4467,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3651,9 +4485,30 @@ dependencies = [ "indexmap 2.10.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.12", +] + +[[package]] +name = "toml_edit" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ad0b7ae9cfeef5605163839cb9221f453399f15cfb5c10be9885fcf56611f9" +dependencies = [ + "indexmap 2.10.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.12", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -3716,6 +4571,7 @@ dependencies = [ "crypto", "ctrlc", "cucumber", + "duckdb", "futures", "futures-util", "http", @@ -3739,7 +4595,7 @@ dependencies = [ "tokio-test", "tokio-util", "toml", - "toml_edit", + "toml_edit 0.22.27", "tower-api", "tower-package", "tower-runtime", @@ -4076,6 +4932,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -4125,6 +4987,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", ] @@ -4631,6 +5494,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "wit-bindgen-rt" version = "0.39.0" @@ -4646,6 +5515,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "xattr" version = "1.5.1" @@ -4759,3 +5637,35 @@ dependencies = [ "quote", "syn 2.0.104", ] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap 2.10.0", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index f89fb323..c17ee29f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ config = { path = "crates/config" } crypto = { path = "crates/crypto" } ctrlc = "3" dirs = "5" +duckdb = { version = "~1.10504.0", features = ["bundled"] } flate2 = "1" fs2 = "0.4" futures = "0.3" diff --git a/crates/tower-cmd/Cargo.toml b/crates/tower-cmd/Cargo.toml index dac7d31b..6d5bcf5e 100644 --- a/crates/tower-cmd/Cargo.toml +++ b/crates/tower-cmd/Cargo.toml @@ -11,6 +11,7 @@ clap = { workspace = true } cli-table = { workspace = true } colored = { workspace = true } config = { workspace = true } +duckdb = { workspace = true } crypto = { workspace = true } ctrlc = { workspace = true } futures-util = { workspace = true } diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index 4c1677fb..eba2a6e0 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -1,6 +1,7 @@ use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use colored::Colorize; use config::Config; +use std::io::{IsTerminal, Read}; use tower_api::models::{ vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse, }; @@ -69,7 +70,7 @@ pub fn catalogs_cmd() -> Command { .help("Environment the catalog belongs to") .action(ArgAction::Set), ) - .about("Show the details of a catalog, including its property names"), + .about("Show the details of a catalog, including its properties and tables"), ) .subcommand( Command::new("credentials") @@ -116,6 +117,44 @@ pub fn catalogs_cmd() -> Command { .short_about("Vend short-lived catalog credentials for external tools"), ), ) + .subcommand( + Command::new("query") + .arg( + Arg::new("catalog_name") + .value_parser(value_parser!(String)) + .index(1) + .required(true) + .help("Name of the catalog to query"), + ) + .arg( + Arg::new("sql") + .short('s') + .long("sql") + .value_parser(value_parser!(String)) + .help("SQL statement to execute; read from stdin when omitted") + .action(ArgAction::Set), + ) + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .default_value("default") + .value_parser(value_parser!(String)) + .help("Environment the catalog belongs to") + .action(ArgAction::Set), + ) + .arg( + Arg::new("write") + .short('w') + .long("write") + .help("Allow write statements by vending read-write credentials; queries are read-only by default") + .action(ArgAction::SetTrue), + ) + .about(beta::STORAGE.short_about("Run a SQL query against a catalog using DuckDB")) + .after_help( + "Reference tables as .., e.g.:\n tower catalogs query default --sql 'SELECT * FROM \"default\".my_namespace.my_table LIMIT 10'", + ), + ) } pub async fn do_list(out: &output::Out, config: Config, args: &ArgMatches) { @@ -197,15 +236,435 @@ pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { .expect("catalog_name is required"); let env = cmd::get_string_flag(args, "environment"); - match api::describe_catalog(&config, name, &env).await { - Ok(response) => { - if is_storage_catalog_type(Some(&response.catalog.r#type)) { - beta::notify_once(out, &beta::STORAGE); - } - let human = catalog_details_text(&response); - out.text(&human, &response); + let response = match api::describe_catalog(&config, name, &env).await { + Ok(response) => response, + Err(err) => out.tower_error_and_die(err, "Fetching catalog details failed"), + }; + + let is_storage = is_storage_catalog_type(Some(&response.catalog.r#type)); + if is_storage { + beta::notify_once(out, &beta::STORAGE); + } + + let tables = if is_storage { + Some(fetch_catalog_tables(out, &config, name, &env).await) + } else { + None + }; + + let mut human = catalog_details_text(&response); + human.push('\n'); + human.push_str(&header_line("Tables")); + match &tables { + None => { + human.push_str(&format!( + " Table listing is not supported for {} catalogs.\n", + response.catalog.r#type + )); + } + Some(Ok(result)) if result.rows.is_empty() => { + human.push_str(" No tables found.\n"); + } + Some(Ok(result)) => { + let headers = vec!["Schema".to_string(), "Table".to_string()]; + let data = result + .rows + .iter() + .map(|row| row.iter().map(json_value_to_cell).collect()) + .collect(); + human.push_str(&output::table_text(headers, data)); + } + Some(Err(err)) => { + human.push_str(&format!(" Unable to list tables: {}\n", err)); + } + } + + // `tables` is an array (possibly empty) on success and null otherwise; + // `tables_error` distinguishes a failed listing (message) from a catalog + // type that doesn't support listing (null). + let (json_tables, tables_error) = match tables { + None => (serde_json::Value::Null, None), + Some(Err(err)) => (serde_json::Value::Null, Some(err)), + Some(Ok(result)) => ( + serde_json::Value::Array( + result + .rows + .iter() + .map(|row| { + serde_json::json!({ + "schema": row.first().cloned().unwrap_or(serde_json::Value::Null), + "table": row.get(1).cloned().unwrap_or(serde_json::Value::Null), + }) + }) + .collect(), + ), + None, + ), + }; + let json_data = serde_json::json!({ + "catalog": response.catalog, + "tables": json_tables, + "tables_error": tables_error, + }); + out.text(&human, &json_data); +} + +/// Lists the tables in a catalog by attaching it in DuckDB. Unlike the query +/// path this never exits: `show` should still render catalog details when the +/// tables can't be fetched. +async fn fetch_catalog_tables( + out: &output::Out, + config: &Config, + name: &str, + env: &str, +) -> Result { + let mut spinner = out.spinner("Listing tables..."); + + let response = match api::vend_catalog_credentials( + config, + name, + env, + vend_catalog_credentials_body::Mode::Read, + ) + .await + { + Ok(response) => response, + Err(err) => { + spinner.failure(out); + return Err(err.to_string()); } + }; + + let token = response.credentials.oauth_token.clone(); + let setup = attach_statements( + name, + &response.credentials, + vend_catalog_credentials_body::Mode::Read, + ); + let db_name = name.to_string(); + + let result = tokio::task::spawn_blocking(move || { + run_duckdb_query( + &setup, + "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", + duckdb::params![db_name], + ) + }) + .await; + match result { + Ok(Ok(query_result)) => { + spinner.success(out); + Ok(query_result) + } + Ok(Err(err)) => { + spinner.failure(out); + Err(redact_token(&err.to_string(), &token)) + } + Err(err) => { + spinner.failure(out); + Err(redact_token(&err.to_string(), &token)) + } + } +} + +/// DuckDB errors can echo the failing statement, and the setup batch contains +/// the vended OAuth token — scrub it before the message reaches any output. +fn redact_token(message: &str, token: &str) -> String { + if token.is_empty() { + return message.to_string(); + } + message.replace(token, "[REDACTED]") +} + +pub async fn do_query(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); + + let name = args + .get_one::("catalog_name") + .expect("catalog_name is required"); + let env = cmd::get_string_flag(args, "environment"); + + let sql = match args.get_one::("sql") { + Some(sql) => sql.clone(), + None => read_sql_from_stdin(out), + }; + let sql = sql.trim().to_string(); + if sql.is_empty() { + out.die("No SQL statement provided. Pass one with --sql or pipe it via stdin."); + } + + let response = match api::describe_catalog(&config, name, &env).await { + Ok(response) => response, Err(err) => out.tower_error_and_die(err, "Fetching catalog details failed"), + }; + if !is_storage_catalog_type(Some(&response.catalog.r#type)) { + out.die(&format!( + "Querying is only supported for {} catalogs; '{}' has type '{}'.", + STORAGE_CATALOG_TYPE, name, response.catalog.r#type + )); + } + + let write = cmd::get_bool_flag(args, "write"); + let query_result = execute_catalog_query(out, &config, name, &env, sql, write).await; + output_query_result(out, &query_result); +} + +/// Vends credentials for the catalog, attaches it in an in-memory DuckDB, and +/// runs `sql` against it. Read-only unless `write` is set, in which case +/// read-write credentials are vended and the attach allows writes. Dies with a +/// user-facing error on failure. +async fn execute_catalog_query( + out: &output::Out, + config: &Config, + name: &str, + env: &str, + sql: String, + write: bool, +) -> QueryResult { + let mode = if write { + vend_catalog_credentials_body::Mode::ReadWrite + } else { + vend_catalog_credentials_body::Mode::Read + }; + + let mut spinner = out.spinner("Running query..."); + + let response = match api::vend_catalog_credentials(config, name, env, mode).await { + Ok(response) => response, + Err(err) => { + spinner.failure(out); + out.tower_error_and_die(err, "Running query failed"); + } + }; + + let token = response.credentials.oauth_token.clone(); + let setup = attach_statements(name, &response.credentials, mode); + let result = tokio::task::spawn_blocking(move || run_duckdb_query(&setup, &sql, [])).await; + + match result { + Ok(Ok(query_result)) => { + spinner.success(out); + query_result + } + Ok(Err(err)) => { + spinner.failure(out); + out.die(&format!( + "Query failed: {}", + redact_token(&err.to_string(), &token) + )); + } + Err(err) => { + spinner.failure(out); + out.die(&format!( + "Query execution panicked: {}", + redact_token(&err.to_string(), &token) + )); + } + } +} + +fn read_sql_from_stdin(out: &output::Out) -> String { + let mut stdin = std::io::stdin(); + if stdin.is_terminal() { + out.die("No SQL statement provided. Pass one with --sql or pipe it via stdin."); + } + let mut sql = String::new(); + if let Err(err) = stdin.read_to_string(&mut sql) { + out.die(&format!("Failed reading SQL from stdin: {}", err)); + } + sql +} + +struct QueryResult { + columns: Vec, + rows: Vec>, +} + +/// Statements that install the Iceberg support and attach the catalog under +/// its Tower name — mirrors `templates/duckdb.sql.tmpl`. The attach is +/// READ_ONLY unless read-write credentials were vended. No `USE`: DuckDB's +/// `USE` needs a `main` schema, which Iceberg catalogs don't have, so queries +/// must qualify tables as ..
. +fn attach_statements( + name: &str, + credentials: &CatalogCredentials, + mode: vend_catalog_credentials_body::Mode, +) -> Vec { + let read_only = match mode { + vend_catalog_credentials_body::Mode::Read => "READ_ONLY, ", + vend_catalog_credentials_body::Mode::ReadWrite => "", + }; + vec![ + "INSTALL httpfs".to_string(), + "LOAD httpfs".to_string(), + "INSTALL iceberg".to_string(), + "LOAD iceberg".to_string(), + "SET s3_region='eu-central-1'".to_string(), + format!( + "CREATE OR REPLACE SECRET tower_cat (TYPE iceberg, TOKEN {})", + SqlLiteral(&credentials.oauth_token), + ), + format!( + "ATTACH {warehouse} AS {name} (TYPE iceberg, {read_only}SECRET tower_cat, ENDPOINT {uri}, DEFAULT_REGION 'eu-central-1')", + warehouse = SqlLiteral(&credentials.warehouse), + name = SqlIdent(name), + uri = SqlLiteral(&credentials.catalog_uri), + ), + ] +} + +/// Runs `setup` statements one at a time, then `query` as a prepared statement +/// with `params` bound. Values that fit a bind position should go through +/// `params` rather than into the query text. +fn run_duckdb_query( + setup: &[String], + query: &str, + params: P, +) -> Result { + let conn = duckdb::Connection::open_in_memory()?; + for statement in setup { + conn.execute_batch(statement)?; + } + + let mut stmt = conn.prepare(query)?; + let mut columns: Vec = Vec::new(); + let mut rows = Vec::new(); + + { + let mut result_rows = stmt.query(params)?; + while let Some(row) = result_rows.next()? { + if columns.is_empty() { + columns = row.as_ref().column_names(); + } + let mut record = Vec::with_capacity(columns.len()); + for idx in 0..columns.len() { + let value: duckdb::types::Value = row.get(idx)?; + record.push(duckdb_value_to_json(value)); + } + rows.push(record); + } + } + + // A query with no result rows never populates columns above. + if columns.is_empty() { + columns = stmt.column_names(); + } + + Ok(QueryResult { columns, rows }) +} + +fn duckdb_value_to_json(value: duckdb::types::Value) -> serde_json::Value { + use duckdb::types::{TimeUnit, Value}; + use serde_json::json; + + match value { + Value::Null => serde_json::Value::Null, + Value::Boolean(v) => json!(v), + Value::TinyInt(v) => json!(v), + Value::SmallInt(v) => json!(v), + Value::Int(v) => json!(v), + Value::BigInt(v) => json!(v), + Value::HugeInt(v) => json!(v.to_string()), + Value::UTinyInt(v) => json!(v), + Value::USmallInt(v) => json!(v), + Value::UInt(v) => json!(v), + Value::UBigInt(v) => json!(v), + Value::Float(v) => json!(v), + Value::Double(v) => json!(v), + Value::Decimal(v) => json!(v.to_string()), + Value::Text(v) => json!(v), + Value::Timestamp(unit, v) => { + let micros = match unit { + TimeUnit::Second => v.checked_mul(1_000_000), + TimeUnit::Millisecond => v.checked_mul(1_000), + TimeUnit::Microsecond => Some(v), + TimeUnit::Nanosecond => Some(v / 1_000), + }; + match micros.and_then(chrono::DateTime::from_timestamp_micros) { + Some(ts) => json!(ts.naive_utc().to_string()), + None => json!(format!("{:?}", Value::Timestamp(unit, v))), + } + } + Value::Date32(days) => { + let date = chrono::DateTime::from_timestamp(i64::from(days) * 86_400, 0); + match date { + Some(d) => json!(d.date_naive().to_string()), + None => json!(format!("{:?}", Value::Date32(days))), + } + } + Value::Time64(unit, v) => { + let micros = match unit { + TimeUnit::Second => v.checked_mul(1_000_000), + TimeUnit::Millisecond => v.checked_mul(1_000), + TimeUnit::Microsecond => Some(v), + TimeUnit::Nanosecond => Some(v / 1_000), + }; + let time = micros.and_then(|m| { + chrono::NaiveTime::from_num_seconds_from_midnight_opt( + (m / 1_000_000) as u32, + ((m % 1_000_000) * 1_000) as u32, + ) + }); + match time { + Some(t) => json!(t.to_string()), + None => json!(format!("{:?}", Value::Time64(unit, v))), + } + } + Value::Enum(v) => json!(v), + Value::List(items) | Value::Array(items) => { + serde_json::Value::Array(items.into_iter().map(duckdb_value_to_json).collect()) + } + Value::Struct(fields) => serde_json::Value::Object( + fields + .iter() + .map(|(name, value)| (name.clone(), duckdb_value_to_json(value.clone()))) + .collect(), + ), + Value::Map(entries) => serde_json::Value::Object( + entries + .iter() + .map(|(key, value)| { + ( + json_value_to_cell(&duckdb_value_to_json(key.clone())), + duckdb_value_to_json(value.clone()), + ) + }) + .collect(), + ), + Value::Union(inner) => duckdb_value_to_json(*inner), + other => json!(format!("{:?}", other)), + } +} + +fn output_query_result(out: &output::Out, result: &QueryResult) { + let json_rows: Vec> = result + .rows + .iter() + .map(|row| { + result + .columns + .iter() + .cloned() + .zip(row.iter().cloned()) + .collect() + }) + .collect(); + + let data = result + .rows + .iter() + .map(|row| row.iter().map(json_value_to_cell).collect()) + .collect(); + + out.table(result.columns.clone(), data, Some(&json_rows)); + out.note(&format!("\n{} row(s)\n", result.rows.len())); +} + +fn json_value_to_cell(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), } } @@ -228,12 +687,24 @@ fn quote(value: &str) -> String { serde_json::to_string(value).expect("serializing a string should not fail") } -fn sql_string(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) +/// A value embedded in generated SQL as a single-quoted string literal. +/// Escaping lives in the `Display` impl, so a value can only appear in the +/// generated text in its escaped form. +struct SqlLiteral<'a>(&'a str); + +impl std::fmt::Display for SqlLiteral<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "'{}'", self.0.replace('\'', "''")) + } } -fn sql_ident(value: &str) -> String { - format!("\"{}\"", value.replace('"', "\"\"")) +/// A value embedded in generated SQL as a double-quoted identifier. +struct SqlIdent<'a>(&'a str); + +impl std::fmt::Display for SqlIdent<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "\"{}\"", self.0.replace('"', "\"\"")) + } } fn token_export_command(name: &str, environment: &str, mode: &str, tower_url: &str) -> String { @@ -369,7 +840,7 @@ fn snippets( "os.environ[\"TOWER_CATALOG_TOKEN\"]".to_string() }; let sql_token = if show_token { - sql_string(&credentials.oauth_token) + SqlLiteral(&credentials.oauth_token).to_string() } else { "'${TOWER_CATALOG_TOKEN}'".to_string() }; @@ -414,9 +885,15 @@ fn snippets( body: render( DUCKDB_TMPL, &[ - ("__TOWER_NAME__", sql_ident(name)), - ("__TOWER_URI__", sql_string(&credentials.catalog_uri)), - ("__TOWER_WAREHOUSE__", sql_string(&credentials.warehouse)), + ("__TOWER_NAME__", SqlIdent(name).to_string()), + ( + "__TOWER_URI__", + SqlLiteral(&credentials.catalog_uri).to_string(), + ), + ( + "__TOWER_WAREHOUSE__", + SqlLiteral(&credentials.warehouse).to_string(), + ), ("__TOWER_TOKEN__", sql_token.clone()), ], ), @@ -443,7 +920,8 @@ fn snippets( #[cfg(test)] mod tests { use super::{ - catalogs_cmd, is_storage_catalog_type, parse_mode, snippets, token_export_command, + attach_statements, catalogs_cmd, duckdb_value_to_json, is_storage_catalog_type, parse_mode, + run_duckdb_query, snippets, token_export_command, }; use tower_api::models::{vend_catalog_credentials_body, CatalogCredentials}; @@ -637,6 +1115,222 @@ mod tests { ); } + #[test] + fn redact_token_scrubs_secret_from_error_text() { + let msg = "Parser Error near 'CREATE SECRET tower_cat (TYPE iceberg, TOKEN 'sekret-123')'"; + assert_eq!( + super::redact_token(msg, "sekret-123"), + "Parser Error near 'CREATE SECRET tower_cat (TYPE iceberg, TOKEN '[REDACTED]')'" + ); + assert_eq!(super::redact_token(msg, ""), msg); + assert_eq!( + super::redact_token("no secret here", "sekret-123"), + "no secret here" + ); + } + + #[test] + fn show_all_tables_query_lists_tables() { + let setup = vec![ + "CREATE SCHEMA s; CREATE TABLE s.t1 (i INTEGER); CREATE TABLE s.t2 (i INTEGER);" + .to_string(), + ]; + let result = run_duckdb_query( + &setup, + "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", + duckdb::params!["memory"], + ) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["schema", "name"]); + assert_eq!( + result.rows, + vec![ + vec![serde_json::json!("s"), serde_json::json!("t1")], + vec![serde_json::json!("s"), serde_json::json!("t2")], + ] + ); + } + + #[test] + fn query_requires_catalog_name() { + let result = catalogs_cmd().try_get_matches_from(["catalogs", "query"]); + assert!(result.is_err()); + } + + #[test] + fn query_accepts_sql_flag_and_environment() { + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "query", + "my-catalog", + "--sql", + "SELECT 1", + "-e", + "production", + ]) + .expect("query with --sql should parse"); + + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + + assert_eq!( + query_args.get_one::("catalog_name").unwrap(), + "my-catalog" + ); + assert_eq!(query_args.get_one::("sql").unwrap(), "SELECT 1"); + assert_eq!( + query_args.get_one::("environment").unwrap(), + "production" + ); + } + + #[test] + fn query_sql_flag_is_optional() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "query", "my-catalog"]) + .expect("query without --sql should parse"); + + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + + assert!(query_args.get_one::("sql").is_none()); + assert_eq!( + query_args.get_one::("environment").unwrap(), + "default" + ); + } + + #[test] + fn attach_statements_escapes_values_and_uses_catalog() { + let credentials = CatalogCredentials::new( + "https://catalog.example.com".to_string(), + "2026-06-26T12:00:00Z".to_string(), + "read".to_string(), + "secret'token".to_string(), + "warehouse-id".to_string(), + ); + + let sql = attach_statements( + "my\"catalog", + &credentials, + vend_catalog_credentials_body::Mode::Read, + ) + .join("\n"); + + assert!(sql.contains("TOKEN 'secret''token'")); + assert!( + sql.contains("ATTACH 'warehouse-id' AS \"my\"\"catalog\" (TYPE iceberg, READ_ONLY,") + ); + assert!(sql.contains("ENDPOINT 'https://catalog.example.com'")); + assert!(!sql.contains("USE ")); + + let write_sql = attach_statements( + "my\"catalog", + &credentials, + vend_catalog_credentials_body::Mode::ReadWrite, + ) + .join("\n"); + assert!(!write_sql.contains("READ_ONLY")); + assert!(write_sql.contains( + "ATTACH 'warehouse-id' AS \"my\"\"catalog\" (TYPE iceberg, SECRET tower_cat," + )); + } + + #[test] + fn query_write_flag_defaults_to_false() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "query", "my-catalog", "--sql", "SELECT 1"]) + .expect("query should parse"); + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + assert_eq!(query_args.get_one::("write").copied(), Some(false)); + + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "query", + "my-catalog", + "--sql", + "DELETE FROM t", + "--write", + ]) + .expect("query --write should parse"); + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + assert_eq!(query_args.get_one::("write").copied(), Some(true)); + } + + #[test] + fn duckdb_values_convert_to_json() { + use duckdb::types::{TimeUnit, Value}; + + assert_eq!(duckdb_value_to_json(Value::Null), serde_json::Value::Null); + assert_eq!( + duckdb_value_to_json(Value::BigInt(42)), + serde_json::json!(42) + ); + assert_eq!( + duckdb_value_to_json(Value::Text("hi".to_string())), + serde_json::json!("hi") + ); + assert_eq!( + duckdb_value_to_json(Value::Timestamp(TimeUnit::Microsecond, 0)), + serde_json::json!("1970-01-01 00:00:00") + ); + assert_eq!( + duckdb_value_to_json(Value::Date32(1)), + serde_json::json!("1970-01-02") + ); + } + + #[test] + fn run_duckdb_query_returns_columns_and_rows() { + let setup = vec![ + "CREATE TABLE t (id INTEGER, name VARCHAR); INSERT INTO t VALUES (1, 'a'), (2, NULL);" + .to_string(), + ]; + let result = run_duckdb_query(&setup, "SELECT id, name FROM t ORDER BY id", []) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["id", "name"]); + assert_eq!(result.rows.len(), 2); + assert_eq!( + result.rows[0], + vec![serde_json::json!(1), serde_json::json!("a")] + ); + assert_eq!( + result.rows[1], + vec![serde_json::json!(2), serde_json::Value::Null] + ); + } + + #[test] + fn nested_duckdb_values_convert_to_json_structures() { + let result = run_duckdb_query( + &[], + "SELECT [1, 2] AS l, {'a': 1, 'b': 'x'} AS s, MAP {'k': 2} AS m", + [], + ) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["l", "s", "m"]); + assert_eq!( + result.rows[0], + vec![ + serde_json::json!([1, 2]), + serde_json::json!({"a": 1, "b": "x"}), + serde_json::json!({"k": 2}), + ] + ); + } + + #[test] + fn run_duckdb_query_reports_columns_for_empty_results() { + let result = + run_duckdb_query(&[], "SELECT 1 AS x WHERE 1 = 0", []).expect("query should succeed"); + + assert_eq!(result.columns, vec!["x"]); + assert!(result.rows.is_empty()); + } + #[test] fn token_export_command_fetches_token_without_printing_it() { let credentials = CatalogCredentials::new( diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index 2fb916d1..a4d8b48f 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -151,6 +151,9 @@ impl App { Some(("credentials", args)) => { catalogs::do_credentials(&out, sessionized_config, args).await } + Some(("query", args)) => { + catalogs::do_query(&out, sessionized_config, args).await + } _ => { catalogs::catalogs_cmd().print_help().unwrap(); std::process::exit(2); From c899060f43e7ecf186266dfcd6c5ab7a803e79e8 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 16 Jul 2026 16:16:20 +0200 Subject: [PATCH 07/18] chore: Bump version to v0.3.70-rc.1 --- Cargo.lock | 22 +++++++++++----------- Cargo.toml | 2 +- pyproject.toml | 2 +- uv.lock | 5 +---- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4025f0c5..b445be24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -791,7 +791,7 @@ dependencies = [ [[package]] name = "config" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "base64", "chrono", @@ -983,7 +983,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "aes-gcm", "base64", @@ -4210,7 +4210,7 @@ dependencies = [ [[package]] name = "testutils" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "pem", "rsa", @@ -4519,7 +4519,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "config", "pyo3", @@ -4547,7 +4547,7 @@ dependencies = [ [[package]] name = "tower-api" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "reqwest", "serde", @@ -4559,7 +4559,7 @@ dependencies = [ [[package]] name = "tower-cmd" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "axum", "bytes", @@ -4632,7 +4632,7 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-package" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "async-compression", "flate2", @@ -4657,7 +4657,7 @@ dependencies = [ [[package]] name = "tower-runtime" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "async-trait", "chrono", @@ -4681,7 +4681,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-telemetry" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "tracing", "tracing-appender", @@ -4690,7 +4690,7 @@ dependencies = [ [[package]] name = "tower-uv" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "async-compression", "async_zip", @@ -4710,7 +4710,7 @@ dependencies = [ [[package]] name = "tower-version" -version = "0.3.69" +version = "0.3.70-rc.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index c17ee29f..a005bb5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.3.69" +version = "0.3.70-rc.1" description = "Tower is the best way to host Python data apps in production" rust-version = "1.81" authors = ["Brad Heller ", "Ben Lovell "] diff --git a/pyproject.toml b/pyproject.toml index 829727a3..c2a830e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "tower" -version = "0.3.69" +version = "0.3.70rc1" description = "Tower CLI and runtime environment for Tower." authors = [{ name = "Tower Computing Inc.", email = "brad@tower.dev" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index 671d0068..06b37752 100644 --- a/uv.lock +++ b/uv.lock @@ -559,7 +559,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -570,7 +569,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -581,7 +579,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -2080,7 +2077,7 @@ wheels = [ [[package]] name = "tower" -version = "0.3.69" +version = "0.3.70rc1" source = { editable = "." } dependencies = [ { name = "attrs" }, From 79139d35049f7c1f77d24d1ed77a2c427e6e808e Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 16 Jul 2026 18:31:49 +0200 Subject: [PATCH 08/18] fix(ci): build musl release artifacts in musllinux containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bundled DuckDB build (libduckdb-sys) needs a musl C++ toolchain, which the stock Ubuntu runners don't provide. Run the musl dist jobs inside quay.io/pypa/musllinux_1_2 containers — the same images the maturin wheel builds already use — where gcc/g++ target musl natively. --- dist-workspace.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dist-workspace.toml b/dist-workspace.toml index 93b242f5..ddbade3c 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -42,3 +42,15 @@ runner = "macos-15" [dist.github-custom-runners.aarch64-apple-darwin] runner = "macos-15" + +# The bundled DuckDB build (libduckdb-sys) needs a musl C++ toolchain, which the +# stock Ubuntu runners don't have. Build the musl targets inside musllinux +# containers (same images the maturin wheel builds use), where gcc/g++ target +# musl natively. +[dist.github-custom-runners.x86_64-unknown-linux-musl] +runner = "ubuntu-latest" +container = { image = "quay.io/pypa/musllinux_1_2_x86_64", host = "x86_64-unknown-linux-musl" } + +[dist.github-custom-runners.aarch64-unknown-linux-musl] +runner = "ubuntu-22.04-arm" +container = { image = "quay.io/pypa/musllinux_1_2_aarch64", host = "aarch64-unknown-linux-musl" } From 791987dfb72b2b013cd932c07ba144887b13ddd4 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Fri, 17 Jul 2026 10:57:45 +0200 Subject: [PATCH 09/18] fix(ci): use glibc-based musl-cross container for aarch64 musl builds GitHub's JS actions (checkout, upload-artifact) only support Alpine containers on x64 runners. Switch the aarch64-unknown-linux-musl dist build to messense/rust-musl-cross:aarch64-musl, an Ubuntu-based arm64 image that carries the aarch64-linux-musl cross toolchain. --- dist-workspace.toml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dist-workspace.toml b/dist-workspace.toml index ddbade3c..b256677c 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -44,13 +44,14 @@ runner = "macos-15" runner = "macos-15" # The bundled DuckDB build (libduckdb-sys) needs a musl C++ toolchain, which the -# stock Ubuntu runners don't have. Build the musl targets inside musllinux -# containers (same images the maturin wheel builds use), where gcc/g++ target -# musl natively. +# stock Ubuntu runners don't have. Build the musl targets inside containers that +# provide one. x86_64 uses the same musllinux image as the maturin wheel builds; +# aarch64 can't use an Alpine image (GitHub's JS actions don't support Alpine on +# arm64 runners), so it uses a glibc-based image with a musl cross toolchain. [dist.github-custom-runners.x86_64-unknown-linux-musl] runner = "ubuntu-latest" container = { image = "quay.io/pypa/musllinux_1_2_x86_64", host = "x86_64-unknown-linux-musl" } [dist.github-custom-runners.aarch64-unknown-linux-musl] runner = "ubuntu-22.04-arm" -container = { image = "quay.io/pypa/musllinux_1_2_aarch64", host = "aarch64-unknown-linux-musl" } +container = { image = "messense/rust-musl-cross:aarch64-musl", host = "aarch64-unknown-linux-gnu" } From 0a3cd96f5cf0370552797949c67d42bccd7b3d62 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Fri, 17 Jul 2026 12:24:27 +0200 Subject: [PATCH 10/18] fix(ci): publish prerelease versions to npm under the 'next' dist-tag Recent npm versions refuse to publish a prerelease without an explicit --tag, which broke the wasm package publish for v0.3.70-rc.1. Tag prereleases as 'next' so they also never shadow 'latest'. --- .github/workflows/publish-npm.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 9c1f3ff5..8d14e7dc 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -35,4 +35,12 @@ jobs: run: npm install -g npm@latest - name: Publish to npm working-directory: pkg - run: npm publish --access public --provenance + # npm refuses to publish a prerelease version without an explicit + # dist-tag; publish those under `next` so they never become `latest`. + run: | + VERSION="$(node -p "require('./package.json').version")" + if [[ "$VERSION" == *-* ]]; then + npm publish --access public --provenance --tag next + else + npm publish --access public --provenance + fi From ca498a2bd727388bc758ce35ffd7ed0b931aae9a Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Thu, 23 Jul 2026 10:56:28 +0100 Subject: [PATCH 11/18] Add full overview to catalogs show (#329) * chore: Fix the catalog show output and provide a full view * chore: Add `--full` to `catalogs show` so we can one-shot the whole environment --- crates/tower-cmd/src/catalogs.rs | 505 +++++++++++++++++++++++++++++-- 1 file changed, 480 insertions(+), 25 deletions(-) diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index eba2a6e0..4dcf6fd7 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -1,10 +1,13 @@ use clap::{value_parser, Arg, ArgAction, ArgMatches, Command}; use colored::Colorize; use config::Config; +use futures_util::StreamExt; use std::io::{IsTerminal, Read}; +use std::time::{Duration, Instant}; use tower_api::models::{ vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse, }; +use tower_telemetry::debug; use crate::{api, beta, output, util::cmd}; @@ -70,6 +73,12 @@ pub fn catalogs_cmd() -> Command { .help("Environment the catalog belongs to") .action(ArgAction::Set), ) + .arg( + Arg::new("full") + .long("full") + .help("List each table's columns and their types") + .action(ArgAction::SetTrue), + ) .about("Show the details of a catalog, including its properties and tables"), ) .subcommand( @@ -235,6 +244,7 @@ pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { .get_one::("catalog_name") .expect("catalog_name is required"); let env = cmd::get_string_flag(args, "environment"); + let full = cmd::get_bool_flag(args, "full"); let response = match api::describe_catalog(&config, name, &env).await { Ok(response) => response, @@ -247,7 +257,7 @@ pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { } let tables = if is_storage { - Some(fetch_catalog_tables(out, &config, name, &env).await) + Some(fetch_catalog_tables(out, &config, name, &env, full).await) } else { None }; @@ -265,8 +275,30 @@ pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { Some(Ok(result)) if result.rows.is_empty() => { human.push_str(" No tables found.\n"); } + Some(Ok(result)) if full => { + for row in &result.rows { + let namespace = json_value_to_cell(row.first().unwrap_or(&serde_json::Value::Null)); + let table = json_value_to_cell(row.get(1).unwrap_or(&serde_json::Value::Null)); + human.push_str(&header_line(&format!("{}.{}", namespace, table))); + + let names = json_array_to_strings(row.get(2)); + let types = json_array_to_strings(row.get(3)); + if names.is_empty() { + human.push_str(" No columns found.\n"); + } else { + let headers = vec!["Column".to_string(), "Type".to_string()]; + let data = names + .iter() + .enumerate() + .map(|(i, n)| vec![n.clone(), types.get(i).cloned().unwrap_or_default()]) + .collect(); + human.push_str(&output::table_text(headers, data)); + } + human.push('\n'); + } + } Some(Ok(result)) => { - let headers = vec!["Schema".to_string(), "Table".to_string()]; + let headers = vec!["Namespace".to_string(), "Table".to_string()]; let data = result .rows .iter() @@ -291,10 +323,26 @@ pub async fn do_show(out: &output::Out, config: Config, args: &ArgMatches) { .rows .iter() .map(|row| { - serde_json::json!({ - "schema": row.first().cloned().unwrap_or(serde_json::Value::Null), + let mut entry = serde_json::json!({ + "namespace": row.first().cloned().unwrap_or(serde_json::Value::Null), "table": row.get(1).cloned().unwrap_or(serde_json::Value::Null), - }) + }); + if full { + let names = json_array_to_strings(row.get(2)); + let types = json_array_to_strings(row.get(3)); + let columns = names + .iter() + .enumerate() + .map(|(i, n)| { + serde_json::json!({ + "name": n, + "type": types.get(i).cloned().unwrap_or_default(), + }) + }) + .collect(); + entry["columns"] = serde_json::Value::Array(columns); + } + entry }) .collect(), ), @@ -317,6 +365,7 @@ async fn fetch_catalog_tables( config: &Config, name: &str, env: &str, + full: bool, ) -> Result { let mut spinner = out.spinner("Listing tables..."); @@ -336,33 +385,42 @@ async fn fetch_catalog_tables( }; let token = response.credentials.oauth_token.clone(); - let setup = attach_statements( - name, - &response.credentials, - vend_catalog_credentials_body::Mode::Read, - ); - let db_name = name.to_string(); - let result = tokio::task::spawn_blocking(move || { - run_duckdb_query( - &setup, - "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", - duckdb::params![db_name], - ) - }) - .await; + // `--full` needs every table's column schema. Going through DuckDB means a + // `DESCRIBE` per table, each of which fully opens the Iceberg table (reading + // manifests from object storage) — slow, and unbounded for tables with heavy + // metadata. The Iceberg REST catalog's `loadTable` returns the schema + // straight from table metadata with no manifest I/O, so `--full` talks to + // the catalog directly. The plain listing stays on DuckDB. + let result = if full { + fetch_catalog_columns_via_rest(&response.credentials).await + } else { + let setup = attach_statements( + name, + &response.credentials, + vend_catalog_credentials_body::Mode::Read, + ); + let db_name = name.to_string(); + tokio::task::spawn_blocking(move || { + run_duckdb_query( + &setup, + "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", + duckdb::params![db_name], + ) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|inner| inner.map_err(|err| err.to_string())) + }; + match result { - Ok(Ok(query_result)) => { + Ok(query_result) => { spinner.success(out); Ok(query_result) } - Ok(Err(err)) => { - spinner.failure(out); - Err(redact_token(&err.to_string(), &token)) - } Err(err) => { spinner.failure(out); - Err(redact_token(&err.to_string(), &token)) + Err(redact_token(&err, &token)) } } } @@ -522,10 +580,19 @@ fn run_duckdb_query( params: P, ) -> Result { let conn = duckdb::Connection::open_in_memory()?; + // Setup statements embed the vended OAuth token, so time them without logging + // their text. + let setup_start = Instant::now(); for statement in setup { conn.execute_batch(statement)?; } + debug!( + "duckdb: setup ({} statements) took {:?}", + setup.len(), + setup_start.elapsed() + ); + let query_start = Instant::now(); let mut stmt = conn.prepare(query)?; let mut columns: Vec = Vec::new(); let mut rows = Vec::new(); @@ -550,9 +617,388 @@ fn run_duckdb_query( columns = stmt.column_names(); } + debug!( + "duckdb: query took {:?} ({} rows): {}", + query_start.elapsed(), + rows.len(), + query + ); + Ok(QueryResult { columns, rows }) } +/// How many `loadTable` requests `--full` runs against the Iceberg REST catalog +/// at once. Each is a single metadata fetch; kept modest because Polaris rate +/// limits (HTTP 429) aggressive fan-out. Throttled requests are retried, so this +/// only tunes throughput, not correctness. +const CATALOG_LOADTABLE_CONCURRENCY: usize = 4; + +/// How many times to retry a throttled or transient catalog request before +/// giving up on it. +const CATALOG_HTTP_MAX_RETRIES: usize = 4; + +/// Fetches every table's column schema directly from the Iceberg REST catalog. +/// Discovers namespaces and tables through the catalog's list endpoints, then +/// `loadTable`s each table concurrently and reads its current schema — no DuckDB +/// attach and no manifest I/O, which is what makes `DESCRIBE` slow. Returns rows +/// shaped like the plain listing (`[namespace, table, [column names], +/// [column types]]`) so `do_show` renders both modes the same way. A table whose +/// `loadTable` fails is reported with no columns rather than failing the listing. +async fn fetch_catalog_columns_via_rest( + credentials: &CatalogCredentials, +) -> Result { + let client = reqwest::Client::new(); + let base = credentials.catalog_uri.trim_end_matches('/'); + // The vended warehouse is the Polaris REST prefix (see `CatalogCredentials`). + let prefix = credentials.warehouse.as_str(); + let token = credentials.oauth_token.as_str(); + + let list_start = Instant::now(); + let namespaces = list_all_namespaces(&client, base, prefix, token).await?; + let mut tables: Vec<(Vec, String)> = Vec::new(); + for namespace in &namespaces { + for table in list_tables(&client, base, prefix, token, namespace).await? { + tables.push((namespace.clone(), table)); + } + } + debug!( + "catalog rest: discovered {} tables across {} namespaces in {:?}", + tables.len(), + namespaces.len(), + list_start.elapsed() + ); + + let load_start = Instant::now(); + let client = &client; + let mut rows: Vec> = futures_util::stream::iter(tables) + .map(|(namespace, table)| async move { + let display_ns = namespace.join("."); + let started = Instant::now(); + let (names, types) = + match load_table_columns(client, base, prefix, token, &namespace, &table).await { + Ok(columns) => { + debug!( + "catalog rest: loadTable {}.{} took {:?} ({} columns)", + display_ns, + table, + started.elapsed(), + columns.0.len() + ); + columns + } + Err(err) => { + debug!( + "catalog rest: loadTable {}.{} failed after {:?}: {}", + display_ns, + table, + started.elapsed(), + err + ); + (Vec::new(), Vec::new()) + } + }; + vec![ + serde_json::Value::String(display_ns), + serde_json::Value::String(table), + serde_json::Value::Array(names), + serde_json::Value::Array(types), + ] + }) + .buffer_unordered(CATALOG_LOADTABLE_CONCURRENCY) + .collect() + .await; + debug!( + "catalog rest: loaded {} table schemas in {:?}", + rows.len(), + load_start.elapsed() + ); + + // `buffer_unordered` yields as each request finishes, so restore the + // namespace/table ordering the plain listing produces. + rows.sort_by(|a, b| { + let key = |row: &[serde_json::Value]| { + ( + row.first().and_then(|v| v.as_str()).unwrap_or("").to_owned(), + row.get(1).and_then(|v| v.as_str()).unwrap_or("").to_owned(), + ) + }; + key(a).cmp(&key(b)) + }); + + Ok(QueryResult { + columns: vec![ + "schema".to_string(), + "name".to_string(), + "column_names".to_string(), + "column_types".to_string(), + ], + rows, + }) +} + +/// Appends `segments` to the catalog base URL, percent-encoding each segment +/// (so a multi-level namespace joined by the Iceberg `\u{1f}` separator is +/// encoded as one path component). Preserves any path already on the base. +fn build_catalog_url(base: &str, segments: &[&str]) -> Result { + let mut url = reqwest::Url::parse(base).map_err(|err| err.to_string())?; + { + let mut path = url + .path_segments_mut() + .map_err(|_| format!("catalog uri is not a valid base: {}", base))?; + for segment in segments { + path.push(segment); + } + } + Ok(url) +} + +/// Issues an authenticated GET and parses the JSON body, retrying on throttling +/// (HTTP 429) and transient 5xx responses with backoff — Polaris rate limits +/// concurrent `loadTable`s, and an un-retried 429 would drop that table's +/// schema. The bearer token rides in the header, never the URL, so the URL is +/// safe to include in errors. +async fn catalog_get_json( + client: &reqwest::Client, + url: reqwest::Url, + token: &str, +) -> Result { + let url_display = url.as_str().to_owned(); + let mut attempt = 0; + loop { + let response = client + .get(url.clone()) + .bearer_auth(token) + .send() + .await + .map_err(|err| err.to_string())?; + let status = response.status(); + if status.is_success() { + return response + .json::() + .await + .map_err(|err| format!("GET {} -> {}", url_display, err)); + } + + let retryable = + status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error(); + if retryable && attempt < CATALOG_HTTP_MAX_RETRIES { + // Honour Retry-After when present; otherwise exponential backoff. + let backoff = retry_after(&response) + .unwrap_or_else(|| Duration::from_millis(200 * (1 << attempt))); + debug!( + "catalog rest: {} returned HTTP {} (attempt {}), retrying in {:?}", + url_display, + status, + attempt + 1, + backoff + ); + attempt += 1; + tokio::time::sleep(backoff).await; + continue; + } + + let body: String = response + .text() + .await + .unwrap_or_default() + .chars() + .take(200) + .collect(); + return Err(format!("GET {} -> HTTP {}: {}", url_display, status, body)); + } +} + +/// Parses a `Retry-After` header expressed in whole seconds, if present. +fn retry_after(response: &reqwest::Response) -> Option { + response + .headers() + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .map(Duration::from_secs) +} + +/// Walks the catalog's namespace tree breadth-first so nested namespaces are +/// included, not just the top level. Deduped in case a catalog also returns +/// descendants from the root listing. +async fn list_all_namespaces( + client: &reqwest::Client, + base: &str, + prefix: &str, + token: &str, +) -> Result>, String> { + let mut all: Vec> = Vec::new(); + let mut frontier: Vec> = vec![Vec::new()]; + while let Some(parent) = frontier.pop() { + for child in list_namespaces(client, base, prefix, token, &parent).await? { + if !all.contains(&child) { + all.push(child.clone()); + frontier.push(child); + } + } + } + Ok(all) +} + +/// Lists the immediate child namespaces of `parent` (the root when empty). +async fn list_namespaces( + client: &reqwest::Client, + base: &str, + prefix: &str, + token: &str, + parent: &[String], +) -> Result>, String> { + let mut url = build_catalog_url(base, &["v1", prefix, "namespaces"])?; + if !parent.is_empty() { + url.query_pairs_mut() + .append_pair("parent", &parent.join("\u{1f}")); + } + let json = catalog_get_json(client, url, token).await?; + Ok(json + .get("namespaces") + .and_then(|v| v.as_array()) + .map(|list| { + list.iter() + .filter_map(|ns| ns.as_array()) + .map(|levels| { + levels + .iter() + .filter_map(|level| level.as_str().map(str::to_owned)) + .collect() + }) + .collect() + }) + .unwrap_or_default()) +} + +/// Lists the table names in a single namespace. +async fn list_tables( + client: &reqwest::Client, + base: &str, + prefix: &str, + token: &str, + namespace: &[String], +) -> Result, String> { + let ns = namespace.join("\u{1f}"); + let url = build_catalog_url(base, &["v1", prefix, "namespaces", &ns, "tables"])?; + let json = catalog_get_json(client, url, token).await?; + Ok(json + .get("identifiers") + .and_then(|v| v.as_array()) + .map(|list| { + list.iter() + .filter_map(|ident| ident.get("name").and_then(|n| n.as_str())) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default()) +} + +/// `loadTable`s one table and extracts its current schema as parallel +/// `(column names, column types)` vectors. Falls back to the first schema, and +/// to legacy single-`schema` metadata, when `current-schema-id` isn't matched. +async fn load_table_columns( + client: &reqwest::Client, + base: &str, + prefix: &str, + token: &str, + namespace: &[String], + table: &str, +) -> Result<(Vec, Vec), String> { + let ns = namespace.join("\u{1f}"); + let url = build_catalog_url(base, &["v1", prefix, "namespaces", &ns, "tables", table])?; + let json = catalog_get_json(client, url, token).await?; + + let metadata = json + .get("metadata") + .ok_or_else(|| "loadTable response missing `metadata`".to_string())?; + let current = metadata.get("current-schema-id").and_then(|v| v.as_i64()); + let schema = match (metadata.get("schemas").and_then(|v| v.as_array()), current) { + (Some(schemas), Some(id)) => schemas + .iter() + .find(|s| s.get("schema-id").and_then(|v| v.as_i64()) == Some(id)) + .or_else(|| schemas.first()), + (Some(schemas), None) => schemas.first(), + (None, _) => metadata.get("schema"), + }; + + let fields = schema + .and_then(|s| s.get("fields")) + .and_then(|v| v.as_array()); + let mut names = Vec::new(); + let mut types = Vec::new(); + if let Some(fields) = fields { + for field in fields { + let name = field + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_owned(); + let ty = iceberg_type_to_display(field.get("type").unwrap_or(&serde_json::Value::Null)); + names.push(serde_json::Value::String(name)); + types.push(serde_json::Value::String(ty)); + } + } + Ok((names, types)) +} + +/// Renders an Iceberg field type (from `loadTable` metadata) as a display +/// string, mapping primitives to the DuckDB-style names the plain listing shows +/// and summarising nested types. +fn iceberg_type_to_display(ty: &serde_json::Value) -> String { + match ty { + serde_json::Value::String(name) => iceberg_primitive_to_display(name), + serde_json::Value::Object(obj) => match obj.get("type").and_then(|v| v.as_str()) { + Some("struct") => "STRUCT".to_string(), + Some("list") => { + let element = obj + .get("element") + .map(iceberg_type_to_display) + .unwrap_or_default(); + format!("LIST({})", element) + } + Some("map") => { + let key = obj.get("key").map(iceberg_type_to_display).unwrap_or_default(); + let value = obj + .get("value") + .map(iceberg_type_to_display) + .unwrap_or_default(); + format!("MAP({}, {})", key, value) + } + Some(other) => other.to_uppercase(), + None => "UNKNOWN".to_string(), + }, + _ => "UNKNOWN".to_string(), + } +} + +/// Maps an Iceberg primitive type name to its DuckDB-style display name so the +/// `--full` output matches the types the plain listing used to show. +fn iceberg_primitive_to_display(name: &str) -> String { + match name { + "boolean" => "BOOLEAN".to_string(), + "int" => "INTEGER".to_string(), + "long" => "BIGINT".to_string(), + "float" => "FLOAT".to_string(), + "double" => "DOUBLE".to_string(), + "date" => "DATE".to_string(), + "time" => "TIME".to_string(), + "timestamp" => "TIMESTAMP".to_string(), + "timestamp_ns" => "TIMESTAMP_NS".to_string(), + "timestamptz" | "timestamptz_ns" => "TIMESTAMP WITH TIME ZONE".to_string(), + "string" => "VARCHAR".to_string(), + "uuid" => "UUID".to_string(), + "binary" => "BLOB".to_string(), + // decimal(P, S) is already descriptive; fixed[L] has no tidy SQL name. + other if other.starts_with("decimal") => other.to_uppercase(), + other if other.starts_with("fixed") => "BLOB".to_string(), + other => other.to_uppercase(), + } +} + fn duckdb_value_to_json(value: duckdb::types::Value) -> serde_json::Value { use duckdb::types::{TimeUnit, Value}; use serde_json::json; @@ -668,6 +1114,15 @@ fn json_value_to_cell(value: &serde_json::Value) -> String { } } +/// Flattens a DuckDB list column (surfaced as a JSON array) into display strings. +/// Anything that isn't an array — a null or missing cell — becomes an empty list. +fn json_array_to_strings(value: Option<&serde_json::Value>) -> Vec { + match value { + Some(serde_json::Value::Array(items)) => items.iter().map(json_value_to_cell).collect(), + _ => Vec::new(), + } +} + fn is_storage_catalog_type(catalog_type: Option<&str>) -> bool { catalog_type == Some(STORAGE_CATALOG_TYPE) } From 1e22a9160bbba54d8b5bc874440cc486dbdcf574 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Mon, 27 Jul 2026 11:40:03 +0100 Subject: [PATCH 12/18] =?UTF-8?q?feat:=20tower-duckdb=20crate=20=E2=80=94?= =?UTF-8?q?=20sandboxed=20catalog=20queries=20and=20adversarial=20tests=20?= =?UTF-8?q?(#331)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(catalogs): add DuckDB query sandbox primitives and adversarial tests Agent-issued catalog SQL is untrusted, so before an MCP query tool can run it we need the query path locked down and proof that the lockdown holds. This lands that foundation without wiring it to a caller yet. It adds a `sandbox` module with the gates a future `tower_catalogs_query` tool applies: reject write/DDL and multi-statement input (the multi-statement scanner skips separators inside strings and comments, which matters because duckdb-rs `prepare` executes every statement but the last as a side effect), and a session hardening set that disables local-filesystem access, blocks community extensions, and locks the configuration. The hardening disables only `LocalFileSystem`, so httpfs and the object-store reads an attached Iceberg catalog depends on keep working. `run_duckdb_query` gains an optional row cap that flags the result truncated, so a model cannot pull an unbounded table into memory or its context. The adversarial suite is the point: each test encodes an attack an agent query might attempt (data tampering, statement smuggling, host filesystem reads and writes, configuration escape, arbitrary extension loading, SSRF via table functions) and asserts the sandbox refuses it. A testcontainers MinIO test proves the same hardening does not break a real object-store Iceberg read while local access and config changes stay blocked, and self-skips when no Docker daemon is present so a plain `cargo test` still passes. * refactor(catalogs): extract Tower's DuckDB usage into a tower-duckdb crate and wire the sandbox in The sandbox primitives from the previous commit had no caller: the gates, the session hardening, and the row cap all lived in an inline `#[allow(dead_code)]` module in catalogs.rs, proven only by tests. This moves Tower's DuckDB usage into a dedicated `tower-duckdb` crate and makes `tower catalogs query` its first real consumer, so the hardening runs in production rather than sitting on a shelf. The crate owns the whole DuckDB surface: opening an in-memory Session, running trusted setup, locking the session down for untrusted SQL (Session::harden), and executing a query into JSON rows with an optional row cap. The static text gates (reject write/DDL, reject multi-statement) live in its `guard` module. The value conversion, the query execution, and the full adversarial + integration test suite move with it, so the security invariants are tested next to the code they protect. tower-cmd no longer depends on duckdb directly; it goes through the crate. `catalogs query` now splits by mode. Read mode is the default and the sandboxed path: the SQL is gated before it reaches DuckDB (a smuggled second statement would otherwise run as a side effect of prepare, and a write gets a clear message instead of a raw engine error), the session is hardened after attach, and rows are capped with a truncation notice. Write mode stays the trusted power-user opt-in and runs as before. Once this lands, #328 will layer the MCP `tower_catalogs_query` tool on top of the same crate. * fix(tower-duckdb): gate read-only queries with DuckDB's parser, not a keyword denylist The read-only gate checked only the first SQL keyword against a denylist. That is not a safe policy: a `--` comment ends at a carriage return as well as a newline in DuckDB, so a `-- x\rDROP …` payload looked empty to the scanner but parses as a DROP, and a statement that opens with an allowed keyword (a `WITH` CTE, for one) can still mutate. Addresses Konstantinos's review on #331. The gate now runs the SQL through DuckDB's own parser via `json_serialize_sql`, which parses without executing and serializes only SELECT statements, erroring on anything else. `classify_read_only` returns Allowed only for exactly one SELECT; everything else (writes, DDL, PRAGMA/SET, multi-statement, unparseable) is refused, fail-closed. This is an allowlist of what the executor will actually run, so the comment-terminator and CTE bypasses are caught, and the SQL is bound as a parameter rather than spliced into the parser query. The keyword denylist and the hand-rolled statement scanner are gone, along with their unit tests; new tests cover the single-SELECT allowlist, the `\r` smuggling case, the mutating-CTE case, and empty/multiple classification. * fix(tower-duckdb): bundle the json extension and skip iceberg test where it can't install The Windows test job failed because the read-only gate runs `json_serialize_sql`, which needs the `json` extension. It is not bundled, so DuckDB tried to auto-download and install it, and the install fails on the Windows runner ("Could not move file: Access is denied"). That is a real defect, not just a test problem: the gate would be broken for Windows users too. Enabling the duckdb `json` feature compiles the extension statically into the bundled build, so it is available with no autoload or network. Verified it works with `autoinstall_known_extensions` and `autoload_known_extensions` both off. The `iceberg_scan` regression test needs the `iceberg` extension, which has no such feature and must still be fetched at runtime. It now self-skips when that install cannot happen, the same way the object-store test skips without Docker, so the Windows job stops failing on an environment it can't satisfy while the test keeps running where it can. * test(tower-duckdb): co-locate read-only gate regression tests in guard.rs The gate's unit tests lived in lib.rs, away from the code they cover. This moves them next to `classify_read_only` in guard.rs and broadens them into a proper regression suite, so the parser-based read-only policy is pinned down where a reader of guard.rs will find it. The suite locks in the current DuckDB classification across the shapes that matter: the many forms of a single read (plain SELECT, CTEs, set operations, subqueries, VALUES/TABLE/FROM-first, DESCRIBE/SUMMARIZE/SHOW), that comments and whitespace and semicolons inside literals don't change the verdict, that writes and DDL and config/transaction/meta statements are rejected whatever the leading keyword, that a leading SELECT can't launder a trailing mutation (the `\r` comment-terminator and data-modifying-CTE bypasses), multi-statement and empty/comment-only classification, fail-closed rejection of unparseable input, and the shared-connection reuse path. It also documents one boundary explicitly: a SELECT that reads a local file or URL is Allowed by the gate because the gate classifies statement shape only; the session hardening is what refuses the read. The redundant gate tests are dropped from lib.rs; its adversarial suite keeps the shared `check` helper. * feat(tower-duckdb): close SELECT-shaped holes, expand the lockdown, and bound queries Parsing as a SELECT is a statement shape, not a read-only property, and the gate was treating the two as the same thing. Verified against the DuckDB we ship (v1.5.4): `SELECT nextval('s')` classifies as a plain SELECT and really does advance the sequence, and `query()`/`query_table()` classify as SELECTs while handing a string to the execution pipeline. Those are now refused by name from the parsed tree, walking `function_name` nodes rather than matching text, so comments and quoting cannot hide them. The session lockdown gains the settings it was missing: no implicit extension install or load, no unsigned extensions, secrets kept redacted, and optional memory and temp-size ceilings, with `lock_configuration` still last because it freezes every later SET. `Hardening` is now a struct so a caller that needs no object storage can set `deny_external_access` and close network egress. The default cannot: an attached Iceberg catalog is made of S3 reads, so egress stays open on the catalog path and the docs now say so plainly rather than implying the lockdown is total. A row cap turned out to bound almost nothing, since one row can carry a whole column (`string_agg`, `list`, `to_json`). Results are now bounded by rows, total bytes, and wall-clock time together, via `Limits`; the byte ceiling is what actually holds, and the timeout exists because DuckDB has no statement timeout of its own, so it is enforced by interrupting the connection. `QueryResult.truncated` carries which ceiling was hit so the CLI can say which one. The docs now state that none of this is the security boundary: the read-only credential and the READ_ONLY attach are, and everything here is defence in depth in front of them. Tests pin each judgement against the shipped DuckDB, including canaries that fail loudly on upgrade if data-modifying CTEs start parsing as SELECTs or `prepare` stops executing leading statements. * feat(catalogs): add --max-rows to override the query result ceiling Read queries are bounded by default so a runaway result cannot flood a terminal or a model's context, but there was no way to ask for more. That makes the bound a wall rather than a default: an agent (or a person) with a legitimate need for a large extract had no option short of --write, which vends read-write credentials for what is still only a read. --max-rows sets the row ceiling and lifts the size ceiling with it, because a caller who asks for a million rows should not then be cut short by a byte budget they never set. --max-rows 0 removes the ceilings entirely. The help text says plainly that a large result can exhaust memory, since that is the trade being made. The read-only gate and the session hardening are untouched: this widens how much data a read may return, not what a query is allowed to do. The decision is factored into `query_limits` so it is unit-tested directly rather than only through argument parsing. * fix(tower-duckdb): make the function gate fail-closed and the ceilings real Review found three ways the sandbox did less than it claimed, and two ways the tests hid it. The function denylist was not fail-closed. `json_execute_serialized_sql` runs whatever SQL it is handed and ships with the very `json` extension this crate enables to build the gate, so the gate supplied its own bypass: the outer statement is an ordinary SELECT and the effectful call hides in a string. `enable_logging`, `checkpoint`, `setseed` and friends were allowed for the same reason, that naming dangerous functions one at a time can never be complete. The gate now allowlists the table-function position, which is where the danger lives, so an unknown function is refused rather than waved through. Scalars are too numerous to allowlist, so it asks the engine instead: DuckDB's `has_side_effects` flags `nextval`, `setseed` and the rest, and keeps up with new versions on its own. It is NULL for every table function, which is why those are allowlisted rather than queried. An explicit list covers what neither reaches: the dynamic-SQL executors and the effectful table functions. This tightens the read path, since `read_csv`/`read_parquet` and the rest of that family are no longer permitted; a catalog query reads base tables, which are not functions. The byte ceiling measured a row after admitting it, so `SELECT repeat('x', 5e7)` returned fifty megabytes and labelled it truncated. It now measures first and withholds the row, which means a single over-budget row yields an empty truncated result. That is blunt but honest, and it is what the ceiling was for. Because that still only bounds what the caller is handed, the read path now hardens with `Hardening::agent()`, which sets an engine memory limit for what a query spends before the first row exists. The SSRF test passed for a reason production does not share: its helper never loaded `httpfs`, so there was no HTTP filesystem to block. It now loads the same extensions `attach_statements` does, and counts connections to a listener it owns rather than trusting an error string, with a control run so it cannot pass vacuously. Note the hardening blocks these today only because disabling LocalFileSystem breaks path resolution first; egress is still not closed by design, and that gap is documented rather than papered over. Also: the MinIO image is pinned to a release tag instead of `latest` and only skips when Docker is genuinely unreachable, so a registry problem fails loudly instead of silently disabling a security test; the iceberg skip is narrowed the same way; and the workspace `rust-version` is corrected to 1.88, which the toolchain and the dependency tree have required for some time. * fix(tower-duckdb): close the replacement-scan SSRF path CI found on Linux CI caught what my local testing could not. The SSRF I reported as non-reproducible does reproduce on Linux: three hardened queries reached the probe listener there. The block I measured on macOS was incidental, disabling LocalFileSystem breaks HTTP path resolution before the HTTP filesystem is consulted, and Linux does not do that. Relying on it was the mistake; the reviewer was right that the hardening is not an egress control. Two holes are closed at the gate, which is platform-independent. `SELECT * FROM 'http://…'` is a replacement scan: it parses as an ordinary base table with the URL as its name, so the table-function allowlist never saw it. Base-table references that name a file or a URL are now refused outright, with their own verdict so the error can say what is actually wrong. `iceberg_scan` is out of the allowlist. It takes a location, so allowing it left a way to reach internal services even with the rest of the family refused. Every remaining entry is a pure generator that accepts no path and no URL, which is the property to preserve: adding anything there that takes a location reopens this. The test now mirrors production by running the gate first and executing only what it allows, because asserting that hardening alone blocks the network was asserting something this crate's own documentation says is untrue. A companion test records that hardening does not close egress, so the next person does not rediscover it in CI. Separately, the MinIO test failed on Windows because MinIO publishes Linux images only, and my previous change had turned that into a hard failure. It now skips when no Linux container runtime is available, decided from the platform rather than inferred from a pull error, and still fails loudly for anything else. --- Cargo.lock | 314 ++++++- Cargo.toml | 9 +- crates/tower-cmd/Cargo.toml | 2 +- crates/tower-cmd/src/catalogs.rs | 380 ++++----- crates/tower-duckdb/Cargo.toml | 17 + crates/tower-duckdb/src/guard.rs | 775 +++++++++++++++++ crates/tower-duckdb/src/lib.rs | 1328 ++++++++++++++++++++++++++++++ 7 files changed, 2592 insertions(+), 233 deletions(-) create mode 100644 crates/tower-duckdb/Cargo.toml create mode 100644 crates/tower-duckdb/src/guard.rs create mode 100644 crates/tower-duckdb/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b445be24..f728341f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -301,7 +301,7 @@ version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", ] [[package]] @@ -501,9 +501,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" @@ -526,6 +526,56 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + [[package]] name = "borsh" version = "1.7.0" @@ -664,7 +714,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -941,7 +991,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "crossterm_winapi", "parking_lot", "rustix 0.38.44", @@ -954,7 +1004,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "crossterm_winapi", "derive_more 2.1.1", "document-features", @@ -1310,6 +1360,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64", + "serde", + "serde_json", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1393,6 +1454,17 @@ dependencies = [ "str-buf", ] +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.59.0", +] + [[package]] name = "eventsource-stream" version = "0.2.3" @@ -1717,7 +1789,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "ignore", "walkdir", ] @@ -1785,6 +1857,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.3.1" @@ -1857,6 +1938,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -1898,6 +1993,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -2107,7 +2217,7 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "crossterm 0.29.0", "dyn-clone", "fuzzy-matcher", @@ -2130,7 +2240,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "cfg-if", "libc", ] @@ -2338,7 +2448,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "libc", "redox_syscall 0.5.15", ] @@ -2517,7 +2627,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -2653,7 +2763,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "objc2", ] @@ -2684,6 +2794,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2725,6 +2841,31 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax 0.8.5", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax 0.8.5", + "structmeta", + "syn 2.0.104", +] + [[package]] name = "paste" version = "1.0.15" @@ -3182,7 +3323,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", ] [[package]] @@ -3506,7 +3647,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3519,7 +3660,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.9.4", @@ -3540,6 +3681,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -3606,6 +3768,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3668,6 +3839,29 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -4029,6 +4223,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.104", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "strum" version = "0.24.1" @@ -4208,6 +4425,35 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "testcontainers" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bb7577dca13ad86a78e8271ef5d322f37229ec83b8d98da6d996c588a1ddb1" +dependencies = [ + "async-trait", + "bollard", + "bollard-stubs", + "bytes", + "docker_credential", + "either", + "etcetera", + "futures", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-tar", + "tokio-util", + "url", +] + [[package]] name = "testutils" version = "0.3.70-rc.1" @@ -4571,7 +4817,6 @@ dependencies = [ "crypto", "ctrlc", "cucumber", - "duckdb", "futures", "futures-util", "http", @@ -4597,6 +4842,7 @@ dependencies = [ "toml", "toml_edit 0.22.27", "tower-api", + "tower-duckdb", "tower-package", "tower-runtime", "tower-telemetry", @@ -4606,13 +4852,24 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "tower-duckdb" +version = "0.3.70-rc.1" +dependencies = [ + "chrono", + "duckdb", + "serde_json", + "testcontainers", + "tower-telemetry", +] + [[package]] name = "tower-http" version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -5147,7 +5404,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -5180,13 +5437,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-result" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -5195,7 +5458,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" dependencies = [ - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -5243,6 +5506,15 @@ dependencies = [ "windows-targets 0.53.2", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.42.2" @@ -5506,7 +5778,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a005bb5a..d02601b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,11 @@ resolver = "2" edition = "2021" version = "0.3.70-rc.1" description = "Tower is the best way to host Python data apps in production" -rust-version = "1.81" +# Matches rust-toolchain.toml. The two had drifted: the toolchain has been 1.88 +# for a while, and the dependency tree (testcontainers and its transitive deps, +# some on edition 2024) does not resolve on 1.81, so the older figure was a +# promise the workspace could not keep. +rust-version = "1.88" authors = ["Brad Heller ", "Ben Lovell "] license = "MIT" repository = "https://github.com/tower/tower-cli" @@ -28,7 +32,7 @@ config = { path = "crates/config" } crypto = { path = "crates/crypto" } ctrlc = "3" dirs = "5" -duckdb = { version = "~1.10504.0", features = ["bundled"] } +duckdb = { version = "~1.10504.0", features = ["bundled", "json"] } flate2 = "1" fs2 = "0.4" futures = "0.3" @@ -68,6 +72,7 @@ toml = "0.8" toml_edit = "0.22" tower-api = { path = "crates/tower-api" } tower-cmd = { path = "crates/tower-cmd" } +tower-duckdb = { path = "crates/tower-duckdb" } tower-package = { path = "crates/tower-package" } tower-runtime = { path = "crates/tower-runtime" } tower-telemetry = { path = "crates/tower-telemetry" } diff --git a/crates/tower-cmd/Cargo.toml b/crates/tower-cmd/Cargo.toml index 6d5bcf5e..93c5fe01 100644 --- a/crates/tower-cmd/Cargo.toml +++ b/crates/tower-cmd/Cargo.toml @@ -11,7 +11,6 @@ clap = { workspace = true } cli-table = { workspace = true } colored = { workspace = true } config = { workspace = true } -duckdb = { workspace = true } crypto = { workspace = true } ctrlc = { workspace = true } futures-util = { workspace = true } @@ -30,6 +29,7 @@ spinners = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } tower-api = { workspace = true } +tower-duckdb = { workspace = true } tower-package = { workspace = true } tower-runtime = { workspace = true } tower-telemetry = { workspace = true } diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index 4dcf6fd7..b0bd8799 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -7,6 +7,7 @@ use std::time::{Duration, Instant}; use tower_api::models::{ vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse, }; +use tower_duckdb::{guard, params, run_query, Hardening, Limits, QueryResult, Session}; use tower_telemetry::debug; use crate::{api, beta, output, util::cmd}; @@ -159,6 +160,13 @@ pub fn catalogs_cmd() -> Command { .help("Allow write statements by vending read-write credentials; queries are read-only by default") .action(ArgAction::SetTrue), ) + .arg( + Arg::new("max_rows") + .long("max-rows") + .value_parser(value_parser!(usize)) + .help("Maximum rows to return; 0 for no limit. Also lifts the result size limit, so a large result can exhaust memory") + .action(ArgAction::Set), + ) .about(beta::STORAGE.short_about("Run a SQL query against a catalog using DuckDB")) .after_help( "Reference tables as ..
, e.g.:\n tower catalogs query default --sql 'SELECT * FROM \"default\".my_namespace.my_table LIMIT 10'", @@ -402,10 +410,11 @@ async fn fetch_catalog_tables( ); let db_name = name.to_string(); tokio::task::spawn_blocking(move || { - run_duckdb_query( + run_query( &setup, "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", - duckdb::params![db_name], + params![db_name], + &Limits::none(), ) }) .await @@ -463,13 +472,71 @@ pub async fn do_query(out: &output::Out, config: Config, args: &ArgMatches) { } let write = cmd::get_bool_flag(args, "write"); - let query_result = execute_catalog_query(out, &config, name, &env, sql, write).await; + // Read mode runs untrusted SQL, so gate it through DuckDB's parser before it + // reaches the query path: it must be exactly one SELECT. A smuggled second + // statement would otherwise execute as a side effect of `prepare`, and a + // write should fail with a clear message rather than a raw engine error. + // Write mode is the trusted power-user path and skips the gate. + if !write { + let sql_to_check = sql.clone(); + let verdict = + tokio::task::spawn_blocking(move || guard::classify_read_only(&sql_to_check)).await; + match verdict { + Ok(Ok(guard::ReadOnlyCheck::Allowed)) => {} + Ok(Ok(guard::ReadOnlyCheck::Empty)) => { + out.die("No SQL statement provided. Pass one with --sql or pipe it via stdin.") + } + Ok(Ok(guard::ReadOnlyCheck::Multiple)) => { + out.die("Only a single SQL statement can be run at a time. Remove the extra statement(s).") + } + Ok(Ok(guard::ReadOnlyCheck::NotReadOnly)) => out.die( + "This command runs read-only queries. Only a single SELECT statement is allowed; re-run with --write to modify the catalog.", + ), + Ok(Ok(guard::ReadOnlyCheck::DeniedFunction(name))) => out.die(&format!( + "'{name}' is not allowed in a read-only query: it changes engine state, runs SQL built at runtime, or reads outside the catalog. Remove it, or re-run with --write." + )), + Ok(Ok(guard::ReadOnlyCheck::DeniedTableReference(reference))) => out.die(&format!( + "'{reference}' is a file or URL, not a table in this catalog. Read-only queries can only read the catalog's own tables; re-run with --write to read elsewhere." + )), + Ok(Err(err)) => out.die(&format!("Could not validate the query: {err}")), + Err(err) => out.die(&format!("Could not validate the query: {err}")), + } + } + let limits = query_limits(write, args.get_one::("max_rows").copied()); + let query_result = execute_catalog_query(out, &config, name, &env, sql, write, limits).await; output_query_result(out, &query_result); } +/// The result ceilings for a query. +/// +/// Read mode defaults to bounded, so a runaway query cannot flood a terminal or a +/// model's context. `--max-rows` is the escape hatch for a caller that knowingly +/// wants more: it sets the row ceiling and lifts the size ceiling, because a +/// caller who asked for a million rows should not then be cut off by a byte +/// budget they never saw. `--max-rows 0` removes the ceilings entirely. Write +/// mode is the trusted path and is unbounded unless a row count is asked for. +fn query_limits(write: bool, max_rows: Option) -> Limits { + match max_rows { + Some(0) => Limits::none(), + Some(rows) => Limits { + max_rows: Some(rows), + max_total_bytes: None, + timeout: None, + }, + None if write => Limits::none(), + None => Limits { + max_rows: Some(guard::AGENT_MAX_ROWS), + max_total_bytes: Some(guard::AGENT_MAX_RESULT_BYTES), + timeout: None, + }, + } +} + /// Vends credentials for the catalog, attaches it in an in-memory DuckDB, and -/// runs `sql` against it. Read-only unless `write` is set, in which case -/// read-write credentials are vended and the attach allows writes. Dies with a +/// runs `sql` against it. In read mode (the default) the session is hardened +/// after attach and the result is bounded by `limits`, so an untrusted query +/// cannot read the host or pull an unbounded table back. `write` vends read-write +/// credentials, lets the attach write, and runs the query trusted. Dies with a /// user-facing error on failure. async fn execute_catalog_query( out: &output::Out, @@ -478,6 +545,7 @@ async fn execute_catalog_query( env: &str, sql: String, write: bool, + limits: Limits, ) -> QueryResult { let mode = if write { vend_catalog_credentials_body::Mode::ReadWrite @@ -497,7 +565,24 @@ async fn execute_catalog_query( let token = response.credentials.oauth_token.clone(); let setup = attach_statements(name, &response.credentials, mode); - let result = tokio::task::spawn_blocking(move || run_duckdb_query(&setup, &sql, [])).await; + // Read mode is the sandboxed path: lock the session down after attach so the + // query cannot read the host or unwind the config. `limits` bounds the result + // (see `query_limits`). No wall-clock ceiling on this path, because a + // legitimate analytical scan over a large catalog can take minutes and a + // person is driving; the agent path uses `Limits::agent()`, which adds one. + let harden = !write; + let result = tokio::task::spawn_blocking(move || -> Result { + let session = Session::open()?; + session.run_setup(&setup)?; + if harden { + // `Hardening::agent()` adds an engine memory ceiling on top of the + // lockdown. The result `limits` bound what comes back; only the engine + // can bound what a query spends producing it. + session.harden(&Hardening::agent())?; + } + session.query(&sql, [], &limits) + }) + .await; match result { Ok(Ok(query_result)) => { @@ -533,11 +618,6 @@ fn read_sql_from_stdin(out: &output::Out) -> String { sql } -struct QueryResult { - columns: Vec, - rows: Vec>, -} - /// Statements that install the Iceberg support and attach the catalog under /// its Tower name — mirrors `templates/duckdb.sql.tmpl`. The attach is /// READ_ONLY unless read-write credentials were vended. No `USE`: DuckDB's @@ -571,62 +651,6 @@ fn attach_statements( ] } -/// Runs `setup` statements one at a time, then `query` as a prepared statement -/// with `params` bound. Values that fit a bind position should go through -/// `params` rather than into the query text. -fn run_duckdb_query( - setup: &[String], - query: &str, - params: P, -) -> Result { - let conn = duckdb::Connection::open_in_memory()?; - // Setup statements embed the vended OAuth token, so time them without logging - // their text. - let setup_start = Instant::now(); - for statement in setup { - conn.execute_batch(statement)?; - } - debug!( - "duckdb: setup ({} statements) took {:?}", - setup.len(), - setup_start.elapsed() - ); - - let query_start = Instant::now(); - let mut stmt = conn.prepare(query)?; - let mut columns: Vec = Vec::new(); - let mut rows = Vec::new(); - - { - let mut result_rows = stmt.query(params)?; - while let Some(row) = result_rows.next()? { - if columns.is_empty() { - columns = row.as_ref().column_names(); - } - let mut record = Vec::with_capacity(columns.len()); - for idx in 0..columns.len() { - let value: duckdb::types::Value = row.get(idx)?; - record.push(duckdb_value_to_json(value)); - } - rows.push(record); - } - } - - // A query with no result rows never populates columns above. - if columns.is_empty() { - columns = stmt.column_names(); - } - - debug!( - "duckdb: query took {:?} ({} rows): {}", - query_start.elapsed(), - rows.len(), - query - ); - - Ok(QueryResult { columns, rows }) -} - /// How many `loadTable` requests `--full` runs against the Iceberg REST catalog /// at once. Each is a single metadata fetch; kept modest because Polaris rate /// limits (HTTP 429) aggressive fan-out. Throttled requests are retried, so this @@ -733,6 +757,7 @@ async fn fetch_catalog_columns_via_rest( "column_types".to_string(), ], rows, + truncated: None, }) } @@ -999,89 +1024,6 @@ fn iceberg_primitive_to_display(name: &str) -> String { } } -fn duckdb_value_to_json(value: duckdb::types::Value) -> serde_json::Value { - use duckdb::types::{TimeUnit, Value}; - use serde_json::json; - - match value { - Value::Null => serde_json::Value::Null, - Value::Boolean(v) => json!(v), - Value::TinyInt(v) => json!(v), - Value::SmallInt(v) => json!(v), - Value::Int(v) => json!(v), - Value::BigInt(v) => json!(v), - Value::HugeInt(v) => json!(v.to_string()), - Value::UTinyInt(v) => json!(v), - Value::USmallInt(v) => json!(v), - Value::UInt(v) => json!(v), - Value::UBigInt(v) => json!(v), - Value::Float(v) => json!(v), - Value::Double(v) => json!(v), - Value::Decimal(v) => json!(v.to_string()), - Value::Text(v) => json!(v), - Value::Timestamp(unit, v) => { - let micros = match unit { - TimeUnit::Second => v.checked_mul(1_000_000), - TimeUnit::Millisecond => v.checked_mul(1_000), - TimeUnit::Microsecond => Some(v), - TimeUnit::Nanosecond => Some(v / 1_000), - }; - match micros.and_then(chrono::DateTime::from_timestamp_micros) { - Some(ts) => json!(ts.naive_utc().to_string()), - None => json!(format!("{:?}", Value::Timestamp(unit, v))), - } - } - Value::Date32(days) => { - let date = chrono::DateTime::from_timestamp(i64::from(days) * 86_400, 0); - match date { - Some(d) => json!(d.date_naive().to_string()), - None => json!(format!("{:?}", Value::Date32(days))), - } - } - Value::Time64(unit, v) => { - let micros = match unit { - TimeUnit::Second => v.checked_mul(1_000_000), - TimeUnit::Millisecond => v.checked_mul(1_000), - TimeUnit::Microsecond => Some(v), - TimeUnit::Nanosecond => Some(v / 1_000), - }; - let time = micros.and_then(|m| { - chrono::NaiveTime::from_num_seconds_from_midnight_opt( - (m / 1_000_000) as u32, - ((m % 1_000_000) * 1_000) as u32, - ) - }); - match time { - Some(t) => json!(t.to_string()), - None => json!(format!("{:?}", Value::Time64(unit, v))), - } - } - Value::Enum(v) => json!(v), - Value::List(items) | Value::Array(items) => { - serde_json::Value::Array(items.into_iter().map(duckdb_value_to_json).collect()) - } - Value::Struct(fields) => serde_json::Value::Object( - fields - .iter() - .map(|(name, value)| (name.clone(), duckdb_value_to_json(value.clone()))) - .collect(), - ), - Value::Map(entries) => serde_json::Value::Object( - entries - .iter() - .map(|(key, value)| { - ( - json_value_to_cell(&duckdb_value_to_json(key.clone())), - duckdb_value_to_json(value.clone()), - ) - }) - .collect(), - ), - Value::Union(inner) => duckdb_value_to_json(*inner), - other => json!(format!("{:?}", other)), - } -} - fn output_query_result(out: &output::Out, result: &QueryResult) { let json_rows: Vec> = result .rows @@ -1103,7 +1045,17 @@ fn output_query_result(out: &output::Out, result: &QueryResult) { .collect(); out.table(result.columns.clone(), data, Some(&json_rows)); - out.note(&format!("\n{} row(s)\n", result.rows.len())); + match result.truncated { + Some(tower_duckdb::Truncation::Rows) => out.note(&format!( + "\nShowing the first {} row(s); result truncated at the row limit. Add a LIMIT or filter to narrow it.\n", + result.rows.len() + )), + Some(tower_duckdb::Truncation::Bytes) => out.note(&format!( + "\nShowing {} row(s); result truncated at the size limit. Select fewer columns, or filter to narrow it.\n", + result.rows.len() + )), + None => out.note(&format!("\n{} row(s)\n", result.rows.len())), + } } fn json_value_to_cell(value: &serde_json::Value) -> String { @@ -1375,10 +1327,11 @@ fn snippets( #[cfg(test)] mod tests { use super::{ - attach_statements, catalogs_cmd, duckdb_value_to_json, is_storage_catalog_type, parse_mode, - run_duckdb_query, snippets, token_export_command, + attach_statements, catalogs_cmd, is_storage_catalog_type, parse_mode, query_limits, + snippets, token_export_command, }; use tower_api::models::{vend_catalog_credentials_body, CatalogCredentials}; + use tower_duckdb::{params, run_query, Limits}; #[test] fn list_defaults_to_default_environment() { @@ -1590,10 +1543,11 @@ mod tests { "CREATE SCHEMA s; CREATE TABLE s.t1 (i INTEGER); CREATE TABLE s.t2 (i INTEGER);" .to_string(), ]; - let result = run_duckdb_query( + let result = run_query( &setup, "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", - duckdb::params!["memory"], + params!["memory"], + &Limits::none(), ) .expect("query should succeed"); @@ -1714,76 +1668,84 @@ mod tests { } #[test] - fn duckdb_values_convert_to_json() { - use duckdb::types::{TimeUnit, Value}; + fn query_accepts_a_max_rows_override() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "query", "my-catalog", "--sql", "SELECT 1"]) + .expect("query should parse"); + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + assert_eq!(query_args.get_one::("max_rows").copied(), None); - assert_eq!(duckdb_value_to_json(Value::Null), serde_json::Value::Null); - assert_eq!( - duckdb_value_to_json(Value::BigInt(42)), - serde_json::json!(42) - ); - assert_eq!( - duckdb_value_to_json(Value::Text("hi".to_string())), - serde_json::json!("hi") - ); - assert_eq!( - duckdb_value_to_json(Value::Timestamp(TimeUnit::Microsecond, 0)), - serde_json::json!("1970-01-01 00:00:00") - ); - assert_eq!( - duckdb_value_to_json(Value::Date32(1)), - serde_json::json!("1970-01-02") + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "query", + "my-catalog", + "--sql", + "SELECT 1", + "--max-rows", + "50000", + ]) + .expect("query --max-rows should parse"); + let (_, query_args) = matches.subcommand().expect("expected query subcommand"); + assert_eq!(query_args.get_one::("max_rows").copied(), Some(50_000)); + + assert!( + catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "query", + "my-catalog", + "--sql", + "SELECT 1", + "--max-rows", + "not-a-number", + ]) + .is_err(), + "a non-numeric --max-rows should be rejected" ); } #[test] - fn run_duckdb_query_returns_columns_and_rows() { - let setup = vec![ - "CREATE TABLE t (id INTEGER, name VARCHAR); INSERT INTO t VALUES (1, 'a'), (2, NULL);" - .to_string(), - ]; - let result = run_duckdb_query(&setup, "SELECT id, name FROM t ORDER BY id", []) - .expect("query should succeed"); - - assert_eq!(result.columns, vec!["id", "name"]); - assert_eq!(result.rows.len(), 2); + fn read_queries_are_bounded_unless_the_caller_asks_otherwise() { + // The default: bounded by rows and by size, so a runaway read cannot + // flood a terminal or a model's context. + let default = query_limits(false, None); + assert_eq!(default.max_rows, Some(tower_duckdb::guard::AGENT_MAX_ROWS)); assert_eq!( - result.rows[0], - vec![serde_json::json!(1), serde_json::json!("a")] - ); - assert_eq!( - result.rows[1], - vec![serde_json::json!(2), serde_json::Value::Null] + default.max_total_bytes, + Some(tower_duckdb::guard::AGENT_MAX_RESULT_BYTES) ); } #[test] - fn nested_duckdb_values_convert_to_json_structures() { - let result = run_duckdb_query( - &[], - "SELECT [1, 2] AS l, {'a': 1, 'b': 'x'} AS s, MAP {'k': 2} AS m", - [], - ) - .expect("query should succeed"); - - assert_eq!(result.columns, vec!["l", "s", "m"]); + fn max_rows_override_raises_the_row_ceiling_and_lifts_the_size_ceiling() { + // A caller who asks for a million rows should not then be cut short by a + // byte budget they never set, so the size ceiling comes off with it. + let raised = query_limits(false, Some(1_000_000)); + assert_eq!(raised.max_rows, Some(1_000_000)); assert_eq!( - result.rows[0], - vec![ - serde_json::json!([1, 2]), - serde_json::json!({"a": 1, "b": "x"}), - serde_json::json!({"k": 2}), - ] + raised.max_total_bytes, None, + "an explicit row count should not be second-guessed by the size cap" ); + + // Lowering it is just as valid as raising it. + assert_eq!(query_limits(false, Some(10)).max_rows, Some(10)); } #[test] - fn run_duckdb_query_reports_columns_for_empty_results() { - let result = - run_duckdb_query(&[], "SELECT 1 AS x WHERE 1 = 0", []).expect("query should succeed"); + fn max_rows_zero_removes_every_result_ceiling() { + let unbounded = query_limits(false, Some(0)); + assert_eq!(unbounded.max_rows, None); + assert_eq!(unbounded.max_total_bytes, None); + } + + #[test] + fn write_mode_is_unbounded_but_still_honours_an_explicit_row_count() { + let write_default = query_limits(true, None); + assert_eq!(write_default.max_rows, None); + assert_eq!(write_default.max_total_bytes, None); - assert_eq!(result.columns, vec!["x"]); - assert!(result.rows.is_empty()); + assert_eq!(query_limits(true, Some(25)).max_rows, Some(25)); } #[test] diff --git a/crates/tower-duckdb/Cargo.toml b/crates/tower-duckdb/Cargo.toml new file mode 100644 index 00000000..7ba0dcea --- /dev/null +++ b/crates/tower-duckdb/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tower-duckdb" +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +license = { workspace = true } +repository = { workspace = true } + +[dependencies] +chrono = { workspace = true } +duckdb = { workspace = true } +serde_json = { workspace = true } +tower-telemetry = { workspace = true } + +[dev-dependencies] +testcontainers = { version = "0.24", features = ["blocking"] } diff --git a/crates/tower-duckdb/src/guard.rs b/crates/tower-duckdb/src/guard.rs new file mode 100644 index 00000000..8df75a34 --- /dev/null +++ b/crates/tower-duckdb/src/guard.rs @@ -0,0 +1,775 @@ +//! Read-only gate on untrusted SQL, applied before a statement runs. +//! +//! This gate is defence in depth, not the security boundary. The load-bearing +//! control is the engine-enforced privilege: `catalogs query` vends read-only +//! credentials and attaches the catalog `READ_ONLY`, so a write is refused by the +//! catalog regardless of what this module concludes. The gate exists to refuse +//! bad input early, with a clear message, and to narrow the gap between "the +//! credential is read-only" and "the statement is a read". +//! +//! The check runs the SQL through DuckDB's own parser via `json_serialize_sql`, +//! which parses (but does not execute) and serializes only `SELECT` statements, +//! erroring on everything else. Using the engine's parser rather than scanning +//! keywords is what makes this workable: a keyword denylist misses comment tricks +//! (a `--` comment ends at `\r` as well as `\n` in DuckDB, so a `-- x\rDROP …` +//! payload looks empty to a naive scanner but parses as a DROP) and statements +//! that open with an allowed keyword but still mutate. The parser sees the +//! statement the way the executor will. +//! +//! Parsing as a `SELECT` is a statement *shape*, not a read-only property, so +//! shape alone is not enough. Two classes of SELECT-shaped statement still act: +//! functions that mutate engine state (`nextval` advances a sequence) and +//! functions that execute dynamically-built SQL (`query`, `query_table`). Those +//! are refused by name from the parsed tree, see [`MUTATING_OR_DYNAMIC_FUNCTIONS`]. +//! A `SELECT` that reads a file or a URL through a table function is still +//! allowed here; the session hardening is what refuses that read at execution. +//! +//! Every judgement in this module is pinned by tests against the DuckDB build we +//! ship, because both the grammar and the serialized JSON shape change between +//! versions. Re-run them on every DuckDB upgrade. + +/// Row cap for agent-issued queries. Rows past this are dropped and the result +/// is flagged truncated. +/// +/// A row cap alone bounds very little: `SELECT string_agg(email, ',') FROM users` +/// returns a whole column in one row. Pair it with [`AGENT_MAX_RESULT_BYTES`], +/// which is what actually bounds how much data a query carries back. +pub const AGENT_MAX_ROWS: usize = 1_000; + +/// Byte ceiling on a whole agent result set, measured as values are read. This is +/// the cap that survives aggregation tricks (`string_agg`, `list`, `to_json`) +/// that pack many rows into few. +pub const AGENT_MAX_RESULT_BYTES: usize = 1 << 20; + +/// Wall-clock ceiling on a single agent query. DuckDB has no statement timeout of +/// its own, so this is enforced from the host by interrupting the connection. +pub const AGENT_QUERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// Functions refused wherever they appear, whatever the engine says about them. +/// +/// Two kinds live here. The first executes SQL built at runtime, which would turn +/// this gate into an execution surface: `query`, `query_table`, and +/// `json_execute_serialized_sql` (the last one ships with the `json` extension +/// this crate enables for the parser, so the gate would otherwise supply its own +/// bypass). The second is effectful table functions, which the engine's +/// `has_side_effects` metadata does not cover because it is NULL for every table +/// function, so nothing but a list can catch them. +pub const ALWAYS_DENIED_FUNCTIONS: &[&str] = &[ + // Executes SQL assembled at runtime. + "query", + "query_table", + "json_execute_serialized_sql", + // Effectful table functions the engine cannot flag for us. + "enable_logging", + "disable_logging", + "truncate_duckdb_logs", + "checkpoint", + "force_checkpoint", + // Discloses vended credentials. + "duckdb_secrets", +]; + +/// Table functions a catalog query may use. Anything else in `FROM` position is +/// refused. +/// +/// This is the fail-closed half of the gate and the reason it does not rely on +/// naming every dangerous function. A catalog query reads base tables in the +/// attached catalog, which are not functions at all, so the legitimate +/// table-function surface is tiny. +/// +/// Every entry here is a pure generator that takes no path and no URL. That is +/// the property worth preserving: the session hardening does not close network +/// egress, and on Linux it does not incidentally block it either, so any table +/// function that accepts a location is a way to reach internal services. That is +/// why the `read_*`/`glob` family is absent, and why `iceberg_scan` is too even +/// though it reads the same data an attached catalog does. **Adding anything here +/// that accepts a path or URL reopens SSRF.** A caller that needs one can widen +/// the set through [`classify_read_only_with`] and take that on knowingly. +pub const ALLOWED_TABLE_FUNCTIONS: &[&str] = &["range", "generate_series", "unnest"]; + +/// What a caller will and will not allow in untrusted SQL. +#[derive(Debug, Clone)] +pub struct FunctionPolicy<'a> { + /// Refused wherever they appear. + pub always_denied: &'a [&'a str], + /// The only table functions permitted in `FROM` position. + pub allowed_table_functions: &'a [&'a str], + /// Ask the engine which scalar functions have side effects and refuse those. + /// Engine-sourced, so it keeps up with DuckDB rather than drifting. + pub deny_side_effecting_scalars: bool, +} + +impl Default for FunctionPolicy<'static> { + fn default() -> Self { + Self { + always_denied: ALWAYS_DENIED_FUNCTIONS, + allowed_table_functions: ALLOWED_TABLE_FUNCTIONS, + deny_side_effecting_scalars: true, + } + } +} + +/// The verdict for a piece of untrusted SQL. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum ReadOnlyCheck { + /// Exactly one `SELECT` statement, calling nothing from the denied set. + Allowed, + /// No statement at all (blank or comment-only input). + Empty, + /// More than one statement. Rejected because duckdb-rs `prepare` executes + /// every statement but the last as a side effect: preparing + /// `DELETE FROM t; SELECT 1` deletes the rows and hands back the SELECT, so a + /// second statement must never reach it. Verified against the DuckDB build we + /// ship, in `prepare_executes_all_but_the_last_statement`. + Multiple, + /// Parses as something other than a single `SELECT` (a write, DDL, `PRAGMA`, + /// `SET`, `COPY`, `ATTACH`, …) or does not parse at all. Rejected fail-closed. + NotReadOnly, + /// Parses as a `SELECT` but calls a function that mutates state or executes + /// dynamically-built SQL. Carries the offending name for the error message. + DeniedFunction(String), + /// Names a file or URL where a table belongs, which DuckDB resolves through a + /// replacement scan. `SELECT * FROM 'http://…'` reads as an ordinary base + /// table in the parse tree, so nothing about the function allowlist catches + /// it. Carries the offending reference. + DeniedTableReference(String), +} + +/// Classify untrusted `sql` for the read-only path using DuckDB's parser. Opens a +/// throwaway in-memory connection with no catalog attached, so the parse cannot +/// touch customer data even in principle. Returns a `duckdb::Error` only if the +/// parser query itself cannot run; a response it cannot make sense of is treated +/// as [`ReadOnlyCheck::NotReadOnly`] (fail closed). +pub fn classify_read_only(sql: &str) -> Result { + let conn = duckdb::Connection::open_in_memory()?; + classify_read_only_on(&conn, sql) +} + +/// [`classify_read_only`] against a caller-supplied connection, so a long-lived +/// caller (an MCP server) can reuse one connection instead of opening a fresh one +/// per query. The connection is only used to run `json_serialize_sql`, which does +/// not execute `sql`. +pub fn classify_read_only_on( + conn: &duckdb::Connection, + sql: &str, +) -> Result { + classify_read_only_with(conn, sql, &FunctionPolicy::default()) +} + +/// [`classify_read_only_on`] under an explicit [`FunctionPolicy`], for a caller +/// that needs to widen or narrow what functions are permitted. Names are matched +/// case-insensitively against the parsed tree, never the raw SQL text. +pub fn classify_read_only_with( + conn: &duckdb::Connection, + sql: &str, + policy: &FunctionPolicy<'_>, +) -> Result { + // `json_serialize_sql` parses and serializes SELECT statements to JSON and + // errors on anything else. The SQL is bound as a parameter, never spliced + // into this query. + let serialized: String = + conn.query_row("SELECT json_serialize_sql(CAST(? AS VARCHAR))", [sql], |row| { + row.get(0) + })?; + + let parsed: serde_json::Value = match serde_json::from_str(&serialized) { + Ok(value) => value, + // DuckDB always returns valid JSON here; an unparseable response means + // something we don't understand, so refuse it rather than guess. + Err(_) => return Ok(ReadOnlyCheck::NotReadOnly), + }; + + // `error: true` covers both non-SELECT statements ("Only SELECT statements + // can be serialized to json!") and malformed SQL. + if parsed + .get("error") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true) + { + return Ok(ReadOnlyCheck::NotReadOnly); + } + + let statements = parsed + .get("statements") + .and_then(serde_json::Value::as_array) + .map_or(0, Vec::len); + + match statements { + 0 => return Ok(ReadOnlyCheck::Empty), + 1 => {} + _ => return Ok(ReadOnlyCheck::Multiple), + } + + let calls = collect_function_calls(&parsed); + + // 0. A table reference that names a file or a URL. DuckDB turns these into a + // replacement scan, so `SELECT * FROM 'http://internal/x.csv'` reaches the + // network while parsing as a plain base table. The function allowlist never + // sees it, which is why this is checked separately. + if let Some(reference) = calls + .table_references + .iter() + .find(|name| looks_like_a_location(name)) + { + return Ok(ReadOnlyCheck::DeniedTableReference(reference.clone())); + } + + // 1. Names refused wherever they appear. + if let Some(name) = calls + .all + .iter() + .find(|name| policy.always_denied.iter().any(|d| d.eq_ignore_ascii_case(name))) + { + return Ok(ReadOnlyCheck::DeniedFunction(name.clone())); + } + + // 2. Table functions are allowlisted, so an unknown one is refused rather + // than waved through. This is what keeps the gate fail-closed as DuckDB + // and its extensions add functions we have never heard of. + if let Some(name) = calls.table_functions.iter().find(|name| { + !policy + .allowed_table_functions + .iter() + .any(|a| a.eq_ignore_ascii_case(name)) + }) { + return Ok(ReadOnlyCheck::DeniedFunction(name.clone())); + } + + // 3. Scalars are too numerous to allowlist, so ask the engine which ones have + // side effects. `has_side_effects` is NULL for table functions, which is + // why those are handled above instead. + if policy.deny_side_effecting_scalars && !calls.all.is_empty() { + let effectful = side_effecting_functions(conn)?; + if let Some(name) = calls.all.iter().find(|name| effectful.contains(*name)) { + return Ok(ReadOnlyCheck::DeniedFunction(name.clone())); + } + } + + Ok(ReadOnlyCheck::Allowed) +} + +/// Function names used by a statement, split by the position they appear in. +#[derive(Default)] +struct FunctionCalls { + /// Every function name in the statement, lowercased. + all: std::collections::BTreeSet, + /// Just the ones in `FROM` position. + table_functions: std::collections::BTreeSet, + /// Base-table names, which is where a replacement scan hides a file or URL. + /// Kept with original case, since these are paths rather than identifiers. + table_references: std::collections::BTreeSet, +} + +/// Whether a table reference is really a file or URL, and so a replacement scan +/// rather than a table. +/// +/// A DuckDB identifier does not contain a path separator or a scheme, so those +/// are decisive. The extension check catches a bare relative name like +/// `data.csv`. Erring toward refusal is right here: the cost of rejecting an +/// oddly-named table is a clear error, and the cost of missing one is egress. +fn looks_like_a_location(reference: &str) -> bool { + let lowered = reference.to_lowercase(); + if lowered.contains("://") || lowered.contains('/') || lowered.contains('\\') { + return true; + } + [ + ".csv", ".tsv", ".parquet", ".json", ".ndjson", ".jsonl", ".avro", ".arrow", ".xlsx", + ".gz", ".zst", + ] + .iter() + .any(|extension| lowered.ends_with(extension)) +} + +/// Walks the serialized parse tree collecting function names, noting which sit in +/// `FROM` position. +/// +/// This reads typed nodes rather than pattern-matching JSON text, because the +/// serialized shape is not stable across DuckDB versions and a substring match on +/// raw SQL would be defeated by comments and quoting. A table function appears as +/// a `from_table` node of type `TABLE_FUNCTION` carrying a nested `function`; a +/// scalar is any other node with a `function_name`. If a future DuckDB renames +/// those keys this walk quietly stops matching, so every denial below is covered +/// by a test that fails loudly the moment it stops being refused. +fn collect_function_calls(node: &serde_json::Value) -> FunctionCalls { + let mut calls = FunctionCalls::default(); + walk_function_calls(node, &mut calls); + calls +} + +fn walk_function_calls(node: &serde_json::Value, calls: &mut FunctionCalls) { + match node { + serde_json::Value::Object(fields) => { + if fields.get("type").and_then(serde_json::Value::as_str) == Some("TABLE_FUNCTION") { + if let Some(name) = fields + .get("function") + .and_then(|f| f.get("function_name")) + .and_then(serde_json::Value::as_str) + { + calls.table_functions.insert(name.to_lowercase()); + } + } + if fields.get("type").and_then(serde_json::Value::as_str) == Some("BASE_TABLE") { + if let Some(name) = fields + .get("table_name") + .and_then(serde_json::Value::as_str) + { + calls.table_references.insert(name.to_string()); + } + } + if let Some(name) = fields.get("function_name").and_then(serde_json::Value::as_str) { + calls.all.insert(name.to_lowercase()); + } + for value in fields.values() { + walk_function_calls(value, calls); + } + } + serde_json::Value::Array(items) => { + for item in items { + walk_function_calls(item, calls); + } + } + _ => {} + } +} + +/// The scalar functions DuckDB reports as having side effects, lowercased. +/// +/// Sourcing this from the engine rather than a hand-kept list means a function +/// added by a future DuckDB is refused without us noticing it exists. It only +/// covers functions registered on `conn`, so a caller classifying against a +/// throwaway connection will not see extension-provided scalars; the +/// always-denied list carries the ones that matters for. +fn side_effecting_functions( + conn: &duckdb::Connection, +) -> Result, duckdb::Error> { + let mut stmt = conn.prepare( + "SELECT DISTINCT lower(function_name) FROM duckdb_functions() WHERE has_side_effects", + )?; + let mut names = std::collections::BTreeSet::new(); + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + names.insert(row.get::<_, String>(0)?); + } + Ok(names) +} + +#[cfg(test)] +mod tests { + use super::{classify_read_only, classify_read_only_on, ReadOnlyCheck}; + + fn check(sql: &str) -> ReadOnlyCheck { + classify_read_only(sql).expect("the parser query should run") + } + + #[test] + fn allows_a_single_select_in_its_many_shapes() { + // Everything DuckDB parses as one read: plain SELECTs, CTEs, set + // operations, subqueries, and the SELECT-sugar forms an agent might emit. + for sql in [ + "SELECT 1", + "select 1", + " SELECT 1 ", + "SELECT * FROM runs WHERE id > 0", + "SELECT count(*) FROM runs", + "WITH x AS (SELECT 1) SELECT * FROM x", + "WITH RECURSIVE t(n) AS (SELECT 1 UNION SELECT n + 1 FROM t WHERE n < 3) SELECT * FROM t", + "SELECT 1 UNION SELECT 2", + "(SELECT 1)", + "VALUES (1), (2)", + "TABLE runs", + "FROM runs", + "DESCRIBE SELECT 1", + "SUMMARIZE SELECT 1", + "SHOW TABLES", + "SHOW ALL TABLES", + ] { + assert_eq!(check(sql), ReadOnlyCheck::Allowed, "should allow: {sql}"); + } + } + + #[test] + fn leading_comments_and_whitespace_do_not_change_the_verdict() { + for sql in [ + "-- a comment\nSELECT 1", + "/* a comment */ SELECT 1", + "\n\t SELECT 1", + "SELECT 1 -- trailing line comment", + "SELECT 1 /* trailing block comment */", + ] { + assert_eq!(check(sql), ReadOnlyCheck::Allowed, "should allow: {sql}"); + } + } + + #[test] + fn semicolons_inside_literals_and_comments_are_not_separators() { + // A single statement whose text merely contains `;` in a string, a + // dollar-quoted string, or a comment stays a single statement. + for sql in [ + "SELECT 'a;b'", + "SELECT $$a;b$$", + "SELECT 1 -- ; not a separator", + "SELECT /* ; */ 1", + "SELECT 1;", + "SELECT 1 ; ", + "SELECT 1 \t ; \n ", + "SELECT 1;;", + ] { + assert_eq!(check(sql), ReadOnlyCheck::Allowed, "should allow: {sql}"); + } + } + + #[test] + fn rejects_writes_and_ddl() { + for sql in [ + "INSERT INTO runs VALUES (1)", + "insert into runs values (1)", + "UPDATE runs SET id = 0", + "DELETE FROM runs", + "MERGE INTO runs USING x ON true WHEN MATCHED THEN DELETE", + "CREATE TABLE evil AS SELECT 1", + "CREATE OR REPLACE TABLE runs AS SELECT 1", + "DROP TABLE runs", + "ALTER TABLE runs ADD COLUMN x INTEGER", + "TRUNCATE runs", + "COPY runs TO '/tmp/exfil.csv'", + "ATTACH '/tmp/evil.db' AS e", + "DETACH runs", + ] { + assert_eq!(check(sql), ReadOnlyCheck::NotReadOnly, "should reject: {sql}"); + } + } + + #[test] + fn rejects_config_transaction_and_meta_statements() { + // Statements that are neither writes nor plain SELECTs still must not run + // on the read-only path: they can change session state or the plan. + for sql in [ + "SET memory_limit = '1GB'", + "RESET memory_limit", + "PRAGMA database_list", + "PRAGMA disable_verification", + "BEGIN", + "COMMIT", + "ROLLBACK", + "PREPARE p AS SELECT 1", + "EXECUTE p", + "CALL pragma_version()", + "EXPLAIN SELECT 1", + ] { + assert_eq!(check(sql), ReadOnlyCheck::NotReadOnly, "should reject: {sql}"); + } + } + + #[test] + fn a_leading_select_does_not_launder_a_trailing_mutation() { + // The bypass class the gate exists for: a statement that opens with an + // allowed keyword but carries a write. A first-keyword denylist would let + // these through; the parser does not. + for sql in [ + // A `--` comment ends at a carriage return in DuckDB, so this parses + // as a DROP even though it opens with a full-line comment. + "-- harmless\rDROP TABLE runs", + // A data-modifying CTE. + "WITH x AS (DELETE FROM runs RETURNING *) SELECT * FROM x", + ] { + assert_eq!(check(sql), ReadOnlyCheck::NotReadOnly, "should reject: {sql}"); + } + // The same shapes without the mutation stay allowed. + assert_eq!(check("-- harmless\r\nSELECT 1"), ReadOnlyCheck::Allowed); + assert_eq!( + check("WITH x AS (SELECT 1) SELECT * FROM x"), + ReadOnlyCheck::Allowed + ); + } + + #[test] + fn rejects_multiple_statements() { + // Two statements must never reach the query path: duckdb-rs `prepare` + // runs every statement but the last as a side effect. An all-SELECT pair + // is Multiple; a mix trips the SELECT-only parser first. + assert_eq!(check("SELECT 1; SELECT 2"), ReadOnlyCheck::Multiple); + assert_eq!(check("SELECT 'a;b'; SELECT 2"), ReadOnlyCheck::Multiple); + for sql in [ + "SELECT 1; DROP TABLE runs", + "SELECT 1; DELETE FROM runs", + "SELECT 1;\n-- c\nUPDATE runs SET id = 0", + ] { + assert_ne!(check(sql), ReadOnlyCheck::Allowed, "smuggled write allowed: {sql}"); + } + } + + #[test] + fn empty_and_comment_only_input_is_empty_not_allowed() { + for sql in ["", " ", "\n\t ", "-- just a comment", "/* only a block */"] { + assert_eq!(check(sql), ReadOnlyCheck::Empty, "should be empty: {sql:?}"); + } + } + + #[test] + fn unparseable_input_is_rejected_fail_closed() { + for sql in ["not valid sql at all", "SELECT FROM WHERE", "SELECT ((("] { + assert_eq!(check(sql), ReadOnlyCheck::NotReadOnly, "should reject: {sql}"); + } + } + + #[test] + fn rejects_functions_that_mutate_state_or_build_sql_dynamically() { + // These parse as perfectly good SELECTs, so the parser alone would let + // them through. `nextval` really does advance a sequence (see + // `nextval_is_a_select_that_mutates`), and `query`/`query_table` hand a + // string to the execution pipeline. + for (sql, expected) in [ + ("SELECT nextval('s')", "nextval"), + ("SELECT NEXTVAL('s')", "nextval"), + ("SELECT currval('s')", "currval"), + ("SELECT * FROM query('SELECT 1')", "query"), + ("SELECT * FROM query_table('t')", "query_table"), + // Nested well below the top level, to prove the walk is not shallow. + ( + "WITH x AS (SELECT nextval('s') AS n) SELECT sum(n) FROM x", + "nextval", + ), + ("SELECT (SELECT max(v) FROM (SELECT nextval('s') v))", "nextval"), + ] { + assert_eq!( + check(sql), + ReadOnlyCheck::DeniedFunction(expected.to_string()), + "should deny {expected} in: {sql}" + ); + } + } + + #[test] + fn a_denied_name_used_as_an_identifier_is_not_a_call() { + // The denial matches parsed function calls, not text, so a column or alias + // that happens to share the name is still a fine read. + for sql in [ + "SELECT query FROM runs", + "SELECT 1 AS nextval", + "SELECT 'nextval(x)' AS literal_text", + "SELECT * FROM runs -- nextval('s')", + ] { + assert_eq!(check(sql), ReadOnlyCheck::Allowed, "should allow: {sql}"); + } + } + + /// Pins the reason `nextval` is on the denylist: it is a SELECT that writes. + /// If a DuckDB upgrade ever made this non-mutating the denial could be + /// revisited, and if the denial is dropped while this still mutates, the test + /// above starts failing. + #[test] + fn nextval_is_a_select_that_mutates() { + let conn = duckdb::Connection::open_in_memory().expect("open duckdb"); + conn.execute_batch("CREATE SEQUENCE s START 1") + .expect("create sequence"); + let first: i64 = conn + .query_row("SELECT nextval('s')", [], |row| row.get(0)) + .expect("nextval"); + let second: i64 = conn + .query_row("SELECT nextval('s')", [], |row| row.get(0)) + .expect("nextval"); + assert_ne!( + first, second, + "nextval no longer mutates; revisit MUTATING_OR_DYNAMIC_FUNCTIONS" + ); + } + + /// Pins the reason multi-statement input is refused, and guards the claim in + /// `ReadOnlyCheck::Multiple`. duckdb-rs `prepare` runs every statement but the + /// last, so the leading statement takes effect even though only the final one + /// is returned. If a future duckdb-rs changes this to a hard error the test + /// fails and the doc comment needs updating, but the gate stays correct. + #[test] + fn prepare_executes_all_but_the_last_statement() { + let conn = duckdb::Connection::open_in_memory().expect("open duckdb"); + conn.execute_batch("CREATE TABLE t(id INTEGER); INSERT INTO t VALUES (1), (2)") + .expect("seed table"); + + let _ = conn.prepare("DELETE FROM t; SELECT 1"); + + let remaining: i64 = conn + .query_row("SELECT count(*) FROM t", [], |row| row.get(0)) + .expect("count"); + assert_eq!( + remaining, 0, + "prepare no longer executes leading statements; revisit the Multiple rationale" + ); + } + + /// A DuckDB version canary. Data-modifying CTEs do not parse today ("A CTE + /// needs a SELECT"), so they land in `NotReadOnly`. Upstream has work to allow + /// DML as a CTE body; if that ships, these gain an outer SELECT and could + /// start classifying as `Allowed` while still deleting rows. This test fails + /// the moment that changes, which is the signal to add an explicit CTE check. + #[test] + fn data_modifying_ctes_do_not_parse_as_selects() { + for sql in [ + "WITH x AS (DELETE FROM runs RETURNING *) SELECT * FROM x", + "WITH x AS (INSERT INTO runs VALUES (9) RETURNING *) SELECT * FROM x", + "WITH x AS (UPDATE runs SET id = 5 RETURNING *) SELECT * FROM x", + ] { + assert_eq!( + check(sql), + ReadOnlyCheck::NotReadOnly, + "DML-in-CTE now parses as a SELECT; the gate needs an explicit check: {sql}" + ); + } + } + + #[test] + fn table_functions_that_reach_a_file_or_a_url_are_refused_by_the_allowlist() { + // These used to be Allowed here, on the reasoning that the session + // hardening would refuse the read at execution time. That left the gate + // depending on a filesystem setting to cover a network vector, so the + // table-function position is now allowlisted and these are refused + // outright, before anything runs. + for (sql, func) in [ + ("SELECT * FROM read_csv('/etc/passwd')", "read_csv"), + ( + "SELECT * FROM read_parquet('https://example.com/x.parquet')", + "read_parquet", + ), + ("SELECT * FROM read_text('/etc/hostname')", "read_text"), + ("SELECT * FROM read_blob('/etc/hostname')", "read_blob"), + ("SELECT * FROM glob('/etc/*')", "glob"), + ] { + assert_eq!( + check(sql), + ReadOnlyCheck::DeniedFunction(func.to_string()), + "should refuse {func} in: {sql}" + ); + } + } + + #[test] + fn the_table_function_allowlist_still_permits_catalog_reads() { + // The point of the allowlist is to refuse the unknown without getting in + // the way of an ordinary catalog query. Base tables are not functions at + // all, so the common case is untouched. + for sql in [ + "SELECT * FROM runs", + "SELECT * FROM \"default\".bronze.runs WHERE id > 0", + "SELECT count(*) FROM range(10)", + "SELECT * FROM generate_series(1, 5)", + "WITH x AS (SELECT * FROM runs) SELECT count(*) FROM x", + ] { + assert_eq!(check(sql), ReadOnlyCheck::Allowed, "should allow: {sql}"); + } + } + + #[test] + fn a_replacement_scan_naming_a_file_or_url_is_refused() { + // `FROM ''` parses as an ordinary base table, so the function + // allowlist never sees it. Missing this was a live SSRF path: it reached + // the network on Linux, where disabling LocalFileSystem does not + // incidentally block HTTP the way it does on macOS. + for reference in [ + "http://169.254.169.254/latest/meta-data/", + "https://attacker.example/x.parquet", + "s3://bucket/object.parquet", + "/etc/passwd", + "./local.csv", + "data.parquet", + "C:\\Windows\\System32\\drivers\\etc\\hosts", + ] { + let sql = format!("SELECT * FROM '{reference}'"); + assert_eq!( + check(&sql), + ReadOnlyCheck::DeniedTableReference(reference.to_string()), + "replacement scan not refused: {sql}" + ); + } + } + + #[test] + fn ordinary_table_names_are_not_mistaken_for_locations() { + // The location check must not get in the way of real tables, including + // qualified ones, where the catalog and schema are separate fields. + for sql in [ + "SELECT * FROM runs", + "SELECT * FROM \"default\".bronze.runs", + "SELECT * FROM my_schema.events_2026", + "SELECT * FROM \"weird name\"", + "WITH staging AS (SELECT 1) SELECT * FROM staging", + ] { + assert_eq!(check(sql), ReadOnlyCheck::Allowed, "should allow: {sql}"); + } + } + + #[test] + fn an_unknown_table_function_is_refused_rather_than_waved_through() { + // The fail-closed property: a function nobody has heard of, which a future + // DuckDB or extension might add, is refused because it is not on the + // allowlist. A denylist would have admitted it. + assert_eq!( + check("SELECT * FROM some_brand_new_table_function('x')"), + ReadOnlyCheck::DeniedFunction("some_brand_new_table_function".to_string()) + ); + } + + #[test] + fn the_json_executor_bundled_for_the_parser_cannot_be_used_as_a_bypass() { + // This crate enables the `json` extension so the gate can parse SQL, and + // that same extension ships `json_execute_serialized_sql`, which runs + // whatever it is handed. The outer statement is a perfectly ordinary + // SELECT, so only an explicit denial catches it. + for sql in [ + "SELECT * FROM json_execute_serialized_sql(json_serialize_sql('SELECT 1'))", + "SELECT json_execute_serialized_sql(json_serialize_sql('SELECT 1'))", + ] { + assert_eq!( + check(sql), + ReadOnlyCheck::DeniedFunction("json_execute_serialized_sql".to_string()), + "the json executor must not be reachable: {sql}" + ); + } + } + + #[test] + fn effectful_functions_are_refused_whether_or_not_the_engine_flags_them() { + // `has_side_effects` is true for these scalars, so the engine tells us. + for (sql, func) in [ + ("SELECT nextval('s')", "nextval"), + ("SELECT currval('s')", "currval"), + ("SELECT setseed(0.5)", "setseed"), + ] { + assert_eq!( + check(sql), + ReadOnlyCheck::DeniedFunction(func.to_string()), + "engine-flagged effectful scalar should be refused: {sql}" + ); + } + // `has_side_effects` is NULL for every table function, so the engine + // cannot help here and the explicit list has to. + for (sql, func) in [ + ("SELECT * FROM enable_logging()", "enable_logging"), + ("SELECT * FROM duckdb_secrets()", "duckdb_secrets"), + ] { + assert_eq!( + check(sql), + ReadOnlyCheck::DeniedFunction(func.to_string()), + "effectful table function should be refused: {sql}" + ); + } + } + + #[test] + fn classify_on_a_shared_connection_matches_the_owning_variant() { + // The reuse path an MCP server would take: one connection, many queries. + let conn = duckdb::Connection::open_in_memory().expect("open duckdb"); + assert_eq!( + classify_read_only_on(&conn, "SELECT 1").unwrap(), + ReadOnlyCheck::Allowed + ); + assert_eq!( + classify_read_only_on(&conn, "DROP TABLE runs").unwrap(), + ReadOnlyCheck::NotReadOnly + ); + assert_eq!( + classify_read_only_on(&conn, "SELECT 1; SELECT 2").unwrap(), + ReadOnlyCheck::Multiple + ); + } +} + + diff --git a/crates/tower-duckdb/src/lib.rs b/crates/tower-duckdb/src/lib.rs new file mode 100644 index 00000000..57ab1b11 --- /dev/null +++ b/crates/tower-duckdb/src/lib.rs @@ -0,0 +1,1328 @@ +//! Tower's usage of DuckDB in one place: opening a session, running trusted +//! setup, locking the session down for untrusted SQL, and executing a query +//! into JSON rows with an optional row cap. +//! +//! The point of the crate is the hardened query path. Agent-issued SQL runs on a +//! customer's machine with their catalog credentials, so it gets layered +//! treatment: reject anything that is not a single read-only SELECT using +//! DuckDB's own parser (the [`guard`] module), lock the session down so a query +//! cannot read the local filesystem, pull in extensions, or unwind the settings +//! ([`Hardening`]), and bound what a result may carry back in rows, bytes, and +//! wall-clock time ([`Limits`]). The adversarial tests exercise each of these +//! directly. +//! +//! None of that is the security boundary. The boundary is the engine-enforced +//! privilege: the caller vends read-only credentials and attaches the catalog +//! `READ_ONLY`, so a write is refused by the catalog no matter what this crate +//! concludes. Everything here is defence in depth in front of that, worth having +//! because it turns a confusing engine error into a clear refusal and closes +//! SELECT-shaped holes the credential alone would not. +//! +//! Two gaps are deliberate and worth knowing. Network egress is open: an attached +//! Iceberg catalog is made of S3 reads, so `enable_external_access` cannot be +//! turned off on that path, and a query can still reach a URL through a table +//! function. And the ceilings bound what this process holds, not what DuckDB +//! spends internally on a materializing plan. Closing either one needs controls +//! outside this crate: a network boundary, and [`Hardening::memory_limit`]. + +use std::time::Instant; + +use tower_telemetry::debug; + +pub use duckdb::{params, Error, Params}; + +pub mod guard; + +/// Why a result stopped short of everything the query would have returned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Truncation { + /// Hit the row ceiling. + Rows, + /// Hit the byte ceiling. This is the one that catches a query which packs a + /// whole table into a handful of rows. + Bytes, +} + +/// A tabular query result: column names, rows as positional JSON values, and why +/// it stopped early if it did. +#[derive(Debug, Clone)] +pub struct QueryResult { + pub columns: Vec, + pub rows: Vec>, + /// Set when a [`Limits`] ceiling cut the result short. + pub truncated: Option, +} + +impl QueryResult { + /// Whether a ceiling cut this result short. + pub fn is_truncated(&self) -> bool { + self.truncated.is_some() + } +} + +/// Ceilings applied while a result is read back. +/// +/// Rows alone are a weak bound, because a query can compact a whole column into +/// one row (`string_agg`, `list`, `to_json`). `max_total_bytes` is what actually +/// limits how much data a single query carries back, so set both for untrusted +/// callers. `timeout` bounds how long the query may run: DuckDB has no statement +/// timeout of its own, so it is enforced by interrupting the connection. +#[derive(Debug, Clone, Default)] +pub struct Limits { + pub max_rows: Option, + pub max_total_bytes: Option, + pub timeout: Option, +} + +impl Limits { + /// No ceilings. For trusted, caller-authored SQL. + pub fn none() -> Self { + Self::default() + } + + /// The ceilings for untrusted (agent-issued) SQL. + pub fn agent() -> Self { + Self { + max_rows: Some(guard::AGENT_MAX_ROWS), + max_total_bytes: Some(guard::AGENT_MAX_RESULT_BYTES), + timeout: Some(guard::AGENT_QUERY_TIMEOUT), + } + } + + /// Only a row ceiling, for a caller that wants a readable result without a + /// security budget. + pub fn rows(max_rows: usize) -> Self { + Self { + max_rows: Some(max_rows), + ..Self::default() + } + } +} + +/// How to lock a session down before untrusted SQL runs. +/// +/// The defaults are what a catalog query needs: local-filesystem access off (so +/// `read_csv('/etc/passwd')` and friends fail), no implicit extension +/// install/load, no community or unsigned extensions, secrets kept redacted, and +/// the configuration frozen so the query cannot unwind any of it. +/// +/// Note what is deliberately *not* set: `enable_external_access = false`. It is +/// DuckDB's master switch and would be the stronger control, but it also blocks +/// the S3 reads an attached Iceberg catalog is made of, so a catalog query cannot +/// use it. Network egress is therefore not closed by this lockdown; a query can +/// still reach a URL through a table function. The read-only credential is what +/// bounds what that query can *read*, and closing egress needs a network boundary +/// outside this process. Callers that do not need object storage should set +/// `deny_external_access` and get the stronger guarantee. +#[derive(Debug, Clone, Default)] +pub struct Hardening { + /// Refuse all external access, network and local files alike. Breaks attached + /// object-store catalogs, so it is off by default. + pub deny_external_access: bool, + /// Engine memory ceiling, e.g. `"2GB"`. `None` keeps DuckDB's default. + pub memory_limit: Option, + /// Ceiling on spill-to-disk, e.g. `"4GB"`. `None` keeps DuckDB's default. + pub max_temp_directory_size: Option, +} + +impl Hardening { + /// The lockdown for untrusted (agent-issued) SQL. + /// + /// Adds an engine memory ceiling on top of the default lockdown. [`Limits`] + /// bounds what a result hands back, but a query can spend far more than that + /// inside DuckDB before the first row appears (a large `ORDER BY`, or a + /// `string_agg` over a whole column). Only the engine can bound that, so an + /// untrusted caller should set it. + pub fn agent() -> Self { + Self { + deny_external_access: false, + memory_limit: Some("1GB".to_string()), + max_temp_directory_size: Some("2GB".to_string()), + } + } + + /// The lockdown statements, in the order they must run. + /// + /// `lock_configuration` is last because it freezes every later `SET`. The + /// whole sequence runs after setup, because attaching a catalog is what + /// installs the extensions and reaches the network that this then takes away. + pub fn statements(&self) -> Vec { + let mut statements = Vec::new(); + if self.deny_external_access { + statements.push("SET enable_external_access = false".to_string()); + } + statements.extend( + [ + "SET disabled_filesystems = 'LocalFileSystem'", + // Stop DuckDB reaching out for an extension mid-query. The ones a + // catalog needs are already loaded by setup. + "SET autoinstall_known_extensions = false", + "SET autoload_known_extensions = false", + "SET allow_community_extensions = false", + "SET allow_unsigned_extensions = false", + // Keep vended tokens redacted in duckdb_secrets(). + "SET allow_unredacted_secrets = false", + ] + .into_iter() + .map(str::to_string), + ); + if let Some(limit) = &self.memory_limit { + statements.push(format!("SET memory_limit = '{}'", limit.replace('\'', "''"))); + } + if let Some(limit) = &self.max_temp_directory_size { + statements.push(format!( + "SET max_temp_directory_size = '{}'", + limit.replace('\'', "''") + )); + } + statements.push("SET lock_configuration = true".to_string()); + statements + } +} + +/// The default lockdown statements, for callers that do not need to tune it. +pub fn hardening_statements() -> Vec { + Hardening::default().statements() +} + +/// An in-memory DuckDB connection Tower runs queries through. +/// +/// The lifecycle is setup, then optionally harden, then query: [`run_setup`] +/// installs extensions and attaches catalogs (the access [`harden`] removes), +/// [`harden`] locks the session down, and [`query`] runs a single statement. +/// +/// [`run_setup`]: Session::run_setup +/// [`harden`]: Session::harden +/// [`query`]: Session::query +pub struct Session { + conn: duckdb::Connection, +} + +impl Session { + /// Open a fresh in-memory session. + pub fn open() -> Result { + Ok(Self { + conn: duckdb::Connection::open_in_memory()?, + }) + } + + /// Run trusted setup statements one at a time. These may embed secrets + /// (vended tokens, credentials), so their text is timed but never logged. + pub fn run_setup(&self, statements: &[String]) -> Result<(), Error> { + let start = Instant::now(); + for statement in statements { + self.conn.execute_batch(statement)?; + } + debug!( + "tower-duckdb: setup ({} statements) took {:?}", + statements.len(), + start.elapsed() + ); + Ok(()) + } + + /// Lock the session down for untrusted SQL. Apply after [`run_setup`], since + /// attaching a catalog needs the access this removes. + /// + /// [`run_setup`]: Session::run_setup + pub fn harden(&self, hardening: &Hardening) -> Result<(), Error> { + for statement in hardening.statements() { + self.conn.execute_batch(&statement)?; + } + Ok(()) + } + + /// Execute a single query as a prepared statement with `params` bound. Values + /// that fit a bind position should go through `params` rather than the query + /// text. + /// + /// `limits` bound the result as it is read: rows and total bytes are counted + /// as values come back and the read stops at either ceiling, so an untrusted + /// caller cannot pull an unbounded table into memory or a model's context. A + /// `timeout` interrupts the connection when the query outruns it. + /// + /// The ceilings bound what this process *holds*, which is not the same as + /// what the engine does: a query whose plan materializes (a large `ORDER BY` + /// or aggregate) spends that memory inside DuckDB before the first row is + /// handed over. Use [`Hardening::memory_limit`] for that. + pub fn query( + &self, + sql: &str, + params: P, + limits: &Limits, + ) -> Result { + let query_start = Instant::now(); + // Armed before prepare so a statement that hangs while binding (a remote + // catalog read, say) is still interrupted. Disarms on drop. + let _deadline = limits.timeout.map(|timeout| Deadline::arm(&self.conn, timeout)); + + let mut stmt = self.conn.prepare(sql)?; + let mut columns: Vec = Vec::new(); + let mut rows = Vec::new(); + let mut truncated = None; + let mut total_bytes = 0usize; + + { + let mut result_rows = stmt.query(params)?; + while let Some(row) = result_rows.next()? { + if columns.is_empty() { + columns = row.as_ref().column_names(); + } + if limits.max_rows.is_some_and(|max| rows.len() >= max) { + truncated = Some(Truncation::Rows); + break; + } + let mut record = Vec::with_capacity(columns.len()); + for idx in 0..columns.len() { + let value: duckdb::types::Value = row.get(idx)?; + record.push(value_to_json(value)); + } + // Measured before the row is kept, so a row that would blow the + // budget is discarded rather than returned and merely labelled + // truncated. A single `string_agg` can carry an entire table in + // one row, so admitting it and flagging it would leave the + // ceiling doing nothing at all. A first row that is already over + // budget yields an empty, truncated result, which is the honest + // answer. Note this bounds what the caller is handed, not what + // the engine allocated to produce it; that needs + // `Hardening::memory_limit`. + let record_bytes = record.iter().map(json_size).sum::(); + if limits + .max_total_bytes + .is_some_and(|max| total_bytes + record_bytes > max) + { + truncated = Some(Truncation::Bytes); + break; + } + total_bytes += record_bytes; + rows.push(record); + } + } + + // A query with no result rows never populates columns above. + if columns.is_empty() { + columns = stmt.column_names(); + } + + debug!( + "tower-duckdb: query took {:?} ({} rows, {} bytes): {}", + query_start.elapsed(), + rows.len(), + total_bytes, + sql + ); + + Ok(QueryResult { + columns, + rows, + truncated, + }) + } +} + +/// Interrupts a connection if it is still working when the deadline passes. +/// +/// DuckDB has no statement timeout, so a wall-clock bound has to come from the +/// host. Interrupts are honoured at chunk boundaries, so this bounds a runaway +/// query rather than guaranteeing an exact deadline; a hard kill would need a +/// process boundary. +struct Deadline { + expired: std::sync::Arc, + watcher: Option>, +} + +impl Deadline { + fn arm(conn: &duckdb::Connection, timeout: std::time::Duration) -> Self { + let handle = conn.interrupt_handle(); + let expired = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let done = expired.clone(); + let watcher = std::thread::spawn(move || { + let deadline = Instant::now() + timeout; + // Woken in slices so a finished query disarms promptly instead of + // holding the thread for the whole timeout. + while Instant::now() < deadline { + if done.load(std::sync::atomic::Ordering::Relaxed) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + if !done.load(std::sync::atomic::Ordering::Relaxed) { + handle.interrupt(); + } + }); + Self { + expired, + watcher: Some(watcher), + } + } +} + +impl Drop for Deadline { + fn drop(&mut self) { + self.expired + .store(true, std::sync::atomic::Ordering::Relaxed); + if let Some(watcher) = self.watcher.take() { + let _ = watcher.join(); + } + } +} + +/// Rough serialized size of a value, used only to bound how much a result may +/// carry back. Strings dominate real results, so they are measured exactly and +/// everything else is approximated. +fn json_size(value: &serde_json::Value) -> usize { + match value { + serde_json::Value::Null => 4, + serde_json::Value::Bool(_) => 5, + serde_json::Value::Number(n) => n.to_string().len(), + serde_json::Value::String(s) => s.len(), + serde_json::Value::Array(items) => items.iter().map(json_size).sum::() + 2, + serde_json::Value::Object(fields) => fields + .iter() + .map(|(key, value)| key.len() + json_size(value)) + .sum::(), + } +} + +/// Open a session, run `setup`, and execute `query`. Convenience for one-shot +/// callers that do not need to hold the session. Callers running untrusted SQL +/// should build a [`Session`] and call [`Session::harden`] between setup and +/// query instead. +pub fn run_query( + setup: &[String], + query: &str, + params: P, + limits: &Limits, +) -> Result { + let session = Session::open()?; + session.run_setup(setup)?; + session.query(query, params, limits) +} + +/// Converts a DuckDB value into a `serde_json::Value`. Integers that overflow an +/// f64 (HugeInt, Decimal) and temporal types are rendered as strings so no +/// precision is lost through JSON's number type. +pub fn value_to_json(value: duckdb::types::Value) -> serde_json::Value { + use duckdb::types::{TimeUnit, Value}; + use serde_json::json; + + match value { + Value::Null => serde_json::Value::Null, + Value::Boolean(v) => json!(v), + Value::TinyInt(v) => json!(v), + Value::SmallInt(v) => json!(v), + Value::Int(v) => json!(v), + Value::BigInt(v) => json!(v), + Value::HugeInt(v) => json!(v.to_string()), + Value::UTinyInt(v) => json!(v), + Value::USmallInt(v) => json!(v), + Value::UInt(v) => json!(v), + Value::UBigInt(v) => json!(v), + Value::Float(v) => json!(v), + Value::Double(v) => json!(v), + Value::Decimal(v) => json!(v.to_string()), + Value::Text(v) => json!(v), + Value::Timestamp(unit, v) => { + let micros = match unit { + TimeUnit::Second => v.checked_mul(1_000_000), + TimeUnit::Millisecond => v.checked_mul(1_000), + TimeUnit::Microsecond => Some(v), + TimeUnit::Nanosecond => Some(v / 1_000), + }; + match micros.and_then(chrono::DateTime::from_timestamp_micros) { + Some(ts) => json!(ts.naive_utc().to_string()), + None => json!(format!("{:?}", Value::Timestamp(unit, v))), + } + } + Value::Date32(days) => { + let date = chrono::DateTime::from_timestamp(i64::from(days) * 86_400, 0); + match date { + Some(d) => json!(d.date_naive().to_string()), + None => json!(format!("{:?}", Value::Date32(days))), + } + } + Value::Time64(unit, v) => { + let micros = match unit { + TimeUnit::Second => v.checked_mul(1_000_000), + TimeUnit::Millisecond => v.checked_mul(1_000), + TimeUnit::Microsecond => Some(v), + TimeUnit::Nanosecond => Some(v / 1_000), + }; + let time = micros.and_then(|m| { + chrono::NaiveTime::from_num_seconds_from_midnight_opt( + (m / 1_000_000) as u32, + ((m % 1_000_000) * 1_000) as u32, + ) + }); + match time { + Some(t) => json!(t.to_string()), + None => json!(format!("{:?}", Value::Time64(unit, v))), + } + } + Value::Enum(v) => json!(v), + Value::List(items) | Value::Array(items) => { + serde_json::Value::Array(items.into_iter().map(value_to_json).collect()) + } + Value::Struct(fields) => serde_json::Value::Object( + fields + .iter() + .map(|(name, value)| (name.clone(), value_to_json(value.clone()))) + .collect(), + ), + Value::Map(entries) => serde_json::Value::Object( + entries + .iter() + .map(|(key, value)| { + ( + stringify_key(&value_to_json(key.clone())), + value_to_json(value.clone()), + ) + }) + .collect(), + ), + Value::Union(inner) => value_to_json(*inner), + other => json!(format!("{:?}", other)), + } +} + +/// Renders a JSON value as a plain string for use as a map key: strings pass +/// through unquoted, everything else uses its JSON form. +fn stringify_key(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::guard::{classify_read_only, ReadOnlyCheck}; + use super::{ + hardening_statements, run_query, value_to_json, Hardening, Limits, QueryResult, Session, + Truncation, + }; + + #[test] + fn duckdb_values_convert_to_json() { + use duckdb::types::{TimeUnit, Value}; + + assert_eq!(value_to_json(Value::Null), serde_json::Value::Null); + assert_eq!(value_to_json(Value::Boolean(true)), serde_json::json!(true)); + assert_eq!(value_to_json(Value::Int(7)), serde_json::json!(7)); + assert_eq!( + value_to_json(Value::HugeInt(170141183460469231731687303715884105727)), + serde_json::json!("170141183460469231731687303715884105727") + ); + assert_eq!( + value_to_json(Value::Text("hello".to_string())), + serde_json::json!("hello") + ); + assert_eq!( + value_to_json(Value::Timestamp(TimeUnit::Microsecond, 0)), + serde_json::json!("1970-01-01 00:00:00") + ); + assert_eq!( + value_to_json(Value::Date32(0)), + serde_json::json!("1970-01-01") + ); + } + + #[test] + fn nested_values_convert_to_json_structures() { + // Round-trip through DuckDB so the list/struct/map values are built the + // way the engine builds them, rather than by hand. + let result = run_query( + &[], + "SELECT [1, 2] AS l, {'a': 1, 'b': 'x'} AS s, MAP {'k': 2} AS m", + [], + &Limits::none(), + ) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["l", "s", "m"]); + assert_eq!( + result.rows[0], + vec![ + serde_json::json!([1, 2]), + serde_json::json!({"a": 1, "b": "x"}), + serde_json::json!({"k": 2}), + ] + ); + } + + #[test] + fn run_query_returns_columns_and_rows() { + let setup = vec![ + "CREATE TABLE t (id INTEGER, name TEXT)".to_string(), + "INSERT INTO t VALUES (1, 'a'), (2, 'b')".to_string(), + ]; + let result = run_query(&setup, "SELECT id, name FROM t ORDER BY id", [], &Limits::none()) + .expect("query should succeed"); + + assert_eq!(result.columns, vec!["id", "name"]); + assert_eq!( + result.rows, + vec![ + vec![serde_json::json!(1), serde_json::json!("a")], + vec![serde_json::json!(2), serde_json::json!("b")], + ] + ); + assert!(!result.is_truncated()); + } + + #[test] + fn run_query_reports_columns_for_empty_results() { + let result = + run_query(&[], "SELECT 1 AS x WHERE 1 = 0", [], &Limits::none()).expect("query should succeed"); + + assert_eq!(result.columns, vec!["x"]); + assert!(result.rows.is_empty()); + } + + #[test] + fn run_query_caps_rows_and_flags_truncation() { + let capped = run_query(&[], "SELECT * FROM range(5) AS t(i)", [], &Limits::rows(3)) + .expect("query should succeed"); + assert_eq!(capped.rows.len(), 3); + assert_eq!(capped.truncated, Some(Truncation::Rows)); + + let exact = run_query(&[], "SELECT * FROM range(3) AS t(i)", [], &Limits::rows(3)) + .expect("query should succeed"); + assert_eq!(exact.rows.len(), 3); + assert!(!exact.is_truncated()); + } + + /// The reason a row cap is not enough on its own: one row can carry a whole + /// column. A row-only ceiling lets this through; the byte ceiling catches it. + #[test] + fn a_byte_ceiling_catches_what_a_row_ceiling_misses() { + let packed = "SELECT string_agg(i::VARCHAR, ',') AS all_rows FROM range(20000) AS t(i)"; + + let row_capped = run_query(&[], packed, [], &Limits::rows(1000)) + .expect("query should succeed"); + assert_eq!(row_capped.rows.len(), 1); + assert!( + !row_capped.is_truncated(), + "a row ceiling cannot see a single oversized row" + ); + let packed_bytes = row_capped.rows[0][0].as_str().map_or(0, str::len); + assert!( + packed_bytes > 64 * 1024, + "expected a large packed value, got {packed_bytes} bytes" + ); + + let byte_capped = run_query( + &[], + packed, + [], + &Limits { + max_rows: Some(1000), + max_total_bytes: Some(4096), + timeout: None, + }, + ) + .expect("query should succeed"); + assert_eq!( + byte_capped.truncated, + Some(Truncation::Bytes), + "the byte ceiling should have cut this short" + ); + // The ceiling has to *withhold* the oversized row, not hand it over with a + // label on it. Returning it and calling it truncated would leave the bound + // doing nothing. + let returned: usize = byte_capped + .rows + .iter() + .flat_map(|row| row.iter()) + .map(|value| value.as_str().map_or(0, str::len)) + .sum(); + assert!( + returned <= 4096, + "budget was 4096 bytes but {returned} bytes came back" + ); + } + + /// A single row larger than the whole budget is refused outright, leaving an + /// empty truncated result. That is deliberately blunt: the alternative is + /// admitting an arbitrarily large row, which is what the ceiling exists to + /// prevent. + #[test] + fn one_row_bigger_than_the_whole_budget_is_withheld() { + let result = run_query( + &[], + "SELECT repeat('x', 5000000) AS pad", + [], + &Limits { + max_rows: None, + max_total_bytes: Some(1024 * 1024), + timeout: None, + }, + ) + .expect("query should succeed"); + + assert_eq!(result.truncated, Some(Truncation::Bytes)); + assert!( + result.rows.is_empty(), + "a row over the whole budget must not be returned, got {} row(s)", + result.rows.len() + ); + // Columns are still reported so the caller can see the shape of what it + // asked for. + assert_eq!(result.columns, vec!["pad"]); + } + + /// Many small rows trip the byte ceiling too, so the bound holds however the + /// result is shaped. + #[test] + fn the_byte_ceiling_also_bounds_many_small_rows() { + let result = run_query( + &[], + "SELECT repeat('x', 512) AS pad FROM range(10000)", + [], + &Limits { + max_rows: None, + max_total_bytes: Some(16 * 1024), + timeout: None, + }, + ) + .expect("query should succeed"); + + assert_eq!(result.truncated, Some(Truncation::Bytes)); + assert!( + result.rows.len() < 10_000, + "should have stopped early, got {} rows", + result.rows.len() + ); + } + + /// DuckDB has no statement timeout, so a runaway query is bounded by + /// interrupting the connection from the host. `range(1e12)` would run + /// effectively forever; it must come back as an error, not hang the test. + #[test] + fn a_runaway_query_is_interrupted_by_the_timeout() { + let started = std::time::Instant::now(); + let result = run_query( + &[], + "SELECT count(*) FROM range(1000000000000) AS t(i) WHERE i % 7 = 0", + [], + &Limits { + max_rows: None, + max_total_bytes: None, + timeout: Some(std::time::Duration::from_secs(2)), + }, + ); + + assert!(result.is_err(), "runaway query should have been interrupted"); + assert!( + started.elapsed() < std::time::Duration::from_secs(60), + "interrupt took too long: {:?}", + started.elapsed() + ); + } + + /// The timeout must not fire on a query that finishes inside it, and the + /// watcher must not outlive the call. + #[test] + fn a_fast_query_is_unaffected_by_an_armed_timeout() { + let result = run_query( + &[], + "SELECT 1 AS x", + [], + &Limits { + max_rows: None, + max_total_bytes: None, + timeout: Some(std::time::Duration::from_secs(30)), + }, + ) + .expect("a fast query should not be interrupted"); + assert_eq!(result.rows, vec![vec![serde_json::json!(1)]]); + } + + /// `lock_configuration` freezes every later `SET`, so it has to be issued + /// last or the rest of the lockdown silently fails to apply. + #[test] + fn lock_configuration_is_always_the_last_hardening_statement() { + for hardening in [ + Hardening::default(), + Hardening { + deny_external_access: true, + memory_limit: Some("2GB".to_string()), + max_temp_directory_size: Some("4GB".to_string()), + }, + ] { + let statements = hardening.statements(); + let last = statements.last().expect("hardening should not be empty"); + assert!( + last.contains("lock_configuration"), + "lock_configuration must be last, got: {last}" + ); + assert_eq!( + statements + .iter() + .filter(|s| s.contains("lock_configuration")) + .count(), + 1 + ); + } + } + + /// The stricter lockdown a caller with no object-store catalog can take. It + /// closes network egress, which the default cannot, because an attached + /// Iceberg catalog is made of S3 reads. + #[test] + fn deny_external_access_closes_the_network_the_default_leaves_open() { + let strict = Hardening { + deny_external_access: true, + ..Hardening::default() + }; + assert!(strict + .statements() + .iter() + .any(|s| s.contains("enable_external_access"))); + assert!( + !Hardening::default() + .statements() + .iter() + .any(|s| s.contains("enable_external_access")), + "the default must leave object-store reads working" + ); + + let session = Session::open().expect("open session"); + session.harden(&strict).expect("harden"); + let err = session + .query( + "SELECT * FROM read_csv('https://example.com/x.csv')", + [], + &Limits::none(), + ) + .expect_err("external access should be refused"); + let message = err.to_string().to_lowercase(); + assert!( + message.contains("disabled") || message.contains("permission"), + "unexpected error: {err}" + ); + } + + // The read-only gate is unit-tested in `guard.rs`. This helper backs the + // adversarial suite below, which exercises the gate alongside the hardening. + fn check(sql: &str) -> ReadOnlyCheck { + classify_read_only(sql).expect("parser should run") + } + + #[test] + fn harden_allows_selects_but_blocks_local_files_and_config_changes() { + let setup = hardening_statements(); + + let ok = run_query(&setup, "SELECT 1 AS x", [], &Limits::none()) + .expect("a plain select should still run under the hardened session"); + assert_eq!(ok.columns, vec!["x"]); + assert_eq!(ok.rows, vec![vec![serde_json::json!(1)]]); + + let fs_err = run_query(&setup, "SELECT * FROM read_csv('Cargo.toml')", [], &Limits::none()) + .expect_err("local filesystem access should be blocked"); + assert!( + fs_err.to_string().to_lowercase().contains("disabled"), + "unexpected error: {fs_err}" + ); + + let cfg_err = run_query(&setup, "SET memory_limit = '1GB'", [], &Limits::none()) + .expect_err("configuration should be locked"); + let cfg_msg = cfg_err.to_string().to_lowercase(); + assert!( + cfg_msg.contains("lock") || cfg_msg.contains("configuration"), + "unexpected error: {cfg_err}" + ); + } + + /// Regression check against real Iceberg data. The other query tests use + /// plain in-memory tables; this one writes a small Iceberg table with + /// DuckDB's `COPY … (FORMAT iceberg)` (real metadata + manifests + parquet) + /// and reads it back, so the iceberg reader and our column/row extraction are + /// exercised end to end, NULLs and the row cap included. The `iceberg` + /// extension is not bundled, so it is fetched on first run and cached; the + /// test self-skips where that install cannot happen (some CI has no writable + /// extension directory), the same way the object-store test skips without + /// Docker. + #[test] + fn iceberg_scan_reads_written_table_through_run_query() { + let conn = duckdb::Connection::open_in_memory().expect("open duckdb"); + // The iceberg extension is fetched at runtime, so a machine that cannot + // install it (no writable extension directory, no network) is a legitimate + // skip. Anything else is a real failure: matching on the install error + // keeps a broken build from turning this into a silent pass. + if let Err(err) = conn.execute_batch("INSTALL iceberg; LOAD iceberg;") { + let message = err.to_string().to_lowercase(); + let unavailable = message.contains("install") + || message.contains("download") + || message.contains("network") + || message.contains("access is denied") + || message.contains("no such file"); + assert!( + unavailable, + "iceberg failed for a reason other than being unavailable: {err}" + ); + eprintln!("skipping iceberg_scan test (iceberg extension unavailable): {err}"); + return; + } + + let dir = std::env::temp_dir().join(format!("tower_iceberg_test_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let table = dir.join("events").to_string_lossy().replace('\'', "''"); + + conn.execute_batch(&format!( + "COPY (SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, NULL)) AS t(id, name)) \ + TO '{table}' (FORMAT iceberg)" + )) + .expect("write iceberg table"); + + let setup = vec!["INSTALL iceberg".to_string(), "LOAD iceberg".to_string()]; + + let result = run_query( + &setup, + &format!("SELECT id, name FROM iceberg_scan('{table}') ORDER BY id"), + [], + &Limits::none(), + ) + .expect("iceberg_scan should read the table"); + assert_eq!(result.columns, vec!["id", "name"]); + assert_eq!( + result.rows, + vec![ + vec![serde_json::json!(1), serde_json::json!("a")], + vec![serde_json::json!(2), serde_json::json!("b")], + vec![serde_json::json!(3), serde_json::Value::Null], + ] + ); + assert!(!result.is_truncated()); + + let capped = run_query( + &setup, + &format!("SELECT id FROM iceberg_scan('{table}') ORDER BY id"), + [], + &Limits::rows(2), + ) + .expect("iceberg_scan should read the table"); + assert_eq!(capped.rows.len(), 2); + assert_eq!(capped.truncated, Some(Truncation::Rows)); + + let _ = std::fs::remove_dir_all(&dir); + } + + // --- Adversarial regression suite ------------------------------------ + // + // Each test encodes an attack an agent-issued query might attempt and + // asserts the sandbox refuses it. These are the security invariants: if a + // future change weakens the gates or the hardening, one of these fails. + + /// A statement run under the session hardening. Extensions are not loaded, so + /// the attacks below must be blocked by the session lockdown alone. + fn run_hardened(sql: &str) -> Result { + run_query(&hardening_statements(), sql, [], &Limits::none()) + } + + #[test] + fn sandbox_gate_rejects_data_tampering() { + for sql in [ + "DROP TABLE runs", + "DELETE FROM runs", + "UPDATE runs SET id = 0", + "INSERT INTO runs VALUES (1)", + "CREATE TABLE evil AS SELECT 1", + "ALTER TABLE runs ADD COLUMN x INTEGER", + "TRUNCATE runs", + "MERGE INTO runs USING x ON true WHEN MATCHED THEN DELETE", + "COPY runs TO '/tmp/exfil.csv'", + "ATTACH '/tmp/evil.db' AS e", + "DETACH runs", + " drop TABLE runs", + "/* sneaky */ DELETE FROM runs", + ] { + assert_eq!(check(sql), ReadOnlyCheck::NotReadOnly, "not rejected: {sql}"); + } + for sql in [ + "SELECT * FROM runs", + "WITH t AS (SELECT 1) SELECT * FROM t", + "SELECT count(*) FROM runs", + ] { + assert_eq!(check(sql), ReadOnlyCheck::Allowed, "legit read rejected: {sql}"); + } + } + + #[test] + fn sandbox_gate_rejects_statement_smuggling() { + // A trailing statement of any kind is refused: an all-SELECT pair counts + // as Multiple, a mixed one trips the SELECT-only parser first. + for sql in [ + "SELECT 1; DROP TABLE runs", + "SELECT 1; DELETE FROM runs", + "SELECT 'a;b'; DROP TABLE runs", + "SELECT 1;\n-- c\nUPDATE runs SET id = 0", + "SELECT 1; SELECT 2", + ] { + assert_ne!(check(sql), ReadOnlyCheck::Allowed, "smuggled statement allowed: {sql}"); + } + assert_eq!( + check("SELECT * FROM runs -- a trailing ; comment"), + ReadOnlyCheck::Allowed + ); + } + + #[test] + fn sandbox_blocks_host_filesystem_reads() { + for sql in [ + "SELECT * FROM read_csv('/etc/passwd')", + "SELECT * FROM read_text('/etc/hostname')", + "SELECT * FROM read_json('/etc/passwd')", + "SELECT * FROM read_parquet('/tmp/x.parquet')", + "SELECT * FROM read_csv('/etc/*')", + "SELECT * FROM '/etc/passwd'", + ] { + assert!(run_hardened(sql).is_err(), "host file read NOT blocked: {sql}"); + } + let err = run_hardened("SELECT * FROM read_csv('/etc/passwd')").unwrap_err(); + assert!(err.to_string().to_lowercase().contains("disabled"), "{err}"); + } + + #[test] + fn sandbox_blocks_host_filesystem_writes() { + for sql in [ + "COPY (SELECT 1) TO '/tmp/pwned.csv'", + "COPY (SELECT 1) TO '/tmp/pwned.parquet' (FORMAT parquet)", + ] { + assert!(run_hardened(sql).is_err(), "host file write NOT blocked: {sql}"); + } + } + + #[test] + fn sandbox_blocks_configuration_escape() { + for sql in [ + "SET disabled_filesystems = ''", + "RESET disabled_filesystems", + "SET enable_external_access = true", + "SET allow_community_extensions = true", + "SET lock_configuration = false", + "PRAGMA disabled_filesystems=''", + ] { + assert!(run_hardened(sql).is_err(), "configuration escape NOT blocked: {sql}"); + } + } + + #[test] + fn sandbox_blocks_arbitrary_extension_loading() { + for sql in [ + "LOAD '/tmp/evil.duckdb_extension'", + "INSTALL some_untrusted_extension_xyz", + ] { + assert!(run_hardened(sql).is_err(), "extension load NOT blocked: {sql}"); + } + } + + /// Proves no request leaves the process, by counting connections to a listener + /// we control rather than trusting an error message. + /// + /// The earlier version of this test ran through a helper that never loaded + /// `httpfs`, so it passed for a reason production does not share: with no HTTP + /// filesystem registered there was nothing to block. This one loads the same + /// extensions `attach_statements` does before hardening, so it fails if the + /// sandbox ever stops covering the real configuration. + #[test] + fn no_query_can_reach_the_network_under_production_hardening() { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let connections = Arc::new(AtomicUsize::new(0)); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind probe listener"); + let port = listener.local_addr().expect("listener addr").port(); + let counter = connections.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + counter.fetch_add(1, Ordering::SeqCst); + if let Ok(mut stream) = stream { + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let body = "col\nreached\n"; + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nAccept-Ranges: bytes\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .as_bytes(), + ); + } + } + }); + + // A control run proves the listener and the URL are reachable at all, so a + // later "no connections" result means the sandbox stopped it rather than + // the probe being broken. + let control = duckdb::Connection::open_in_memory().expect("open duckdb"); + let extensions_available = control.execute_batch("INSTALL httpfs; LOAD httpfs;").is_ok(); + if !extensions_available { + eprintln!("skipping SSRF test: httpfs extension unavailable"); + return; + } + let url = format!("http://127.0.0.1:{port}/probe.csv"); + let reached_without_hardening = control + .query_row( + &format!("SELECT count(*) FROM read_csv('{url}')"), + [], + |row| row.get::<_, i64>(0), + ) + .is_ok(); + assert!( + reached_without_hardening && connections.load(Ordering::SeqCst) > 0, + "control failed: the probe listener was never reached even unhardened, so this test would pass vacuously" + ); + + let baseline = connections.load(Ordering::SeqCst); + for sql in [ + "SELECT count(*) FROM read_csv('URL')", + "SELECT count(*) FROM read_text('URL')", + "SELECT count(*) FROM read_blob('URL')", + "SELECT count(*) FROM read_json_auto('URL')", + "SELECT count(*) FROM 'URL'", + "SELECT count(*) FROM iceberg_scan('URL')", + "SELECT count(*) FROM read_csv('http://169.254.169.254/latest/meta-data/')", + ] { + let sql = sql.replace("URL", &url); + + // Production runs the gate first and never executes what it refuses, + // so the test has to as well. Executing regardless would measure the + // hardening alone, which is not a layer that closes egress: see + // `hardening_alone_does_not_close_network_egress`. + let verdict = classify_read_only(&sql).expect("gate should run"); + assert_ne!( + verdict, + ReadOnlyCheck::Allowed, + "the gate must refuse a query that can reach a URL: {sql}" + ); + + let conn = duckdb::Connection::open_in_memory().expect("open duckdb"); + conn.execute_batch("INSTALL httpfs; LOAD httpfs;") + .expect("load httpfs"); + let _ = conn.execute_batch("INSTALL iceberg; LOAD iceberg;"); + for statement in hardening_statements() { + conn.execute_batch(&statement).expect("harden session"); + } + if verdict == ReadOnlyCheck::Allowed { + let _ = conn.query_row(&sql, [], |row| row.get::<_, i64>(0)); + } + } + + assert_eq!( + connections.load(Ordering::SeqCst), + baseline, + "a query that survived the gate reached the network" + ); + } + + /// The layer that is *not* load-bearing here, recorded so nobody assumes it is. + /// + /// The session hardening does not close network egress: it cannot, because an + /// attached Iceberg catalog is made of S3 reads, so `enable_external_access` + /// has to stay on. On macOS it happens to block HTTP reads anyway, because + /// disabling LocalFileSystem breaks path resolution before the HTTP filesystem + /// is consulted. On Linux it does not, and the request goes out. Relying on + /// that accident is exactly the mistake this test exists to prevent: the + /// read-only gate's table-function allowlist is what actually stops these. + #[test] + fn hardening_alone_does_not_close_network_egress() { + let conn = duckdb::Connection::open_in_memory().expect("open duckdb"); + if conn.execute_batch("INSTALL httpfs; LOAD httpfs;").is_err() { + eprintln!("skipping: httpfs extension unavailable"); + return; + } + for statement in hardening_statements() { + conn.execute_batch(&statement).expect("harden session"); + } + + // Whether this reaches the network is platform-dependent, so assert only + // the part that holds everywhere: hardening does not *refuse* the + // statement the way the gate does. The error, if any, comes from the read + // failing, not from the sandbox declining to try. + let hardened_error = conn + .query_row( + "SELECT count(*) FROM read_csv('http://127.0.0.1:9/nothing')", + [], + |row| row.get::<_, i64>(0), + ) + .err() + .map(|err| err.to_string().to_lowercase()); + if let Some(message) = &hardened_error { + assert!( + !message.contains("not allowed") && !message.contains("denied function"), + "hardening is not the layer that refuses this; the gate is: {message}" + ); + } + + // The gate is. That is the invariant worth depending on. + assert_ne!( + classify_read_only("SELECT count(*) FROM read_csv('http://127.0.0.1:9/nothing')") + .expect("gate should run"), + ReadOnlyCheck::Allowed + ); + } + + /// Polls MinIO's health endpoint over a raw socket until it answers 200. + fn wait_for_minio(port: u16) -> bool { + use std::io::{Read, Write}; + for _ in 0..60 { + if let Ok(mut stream) = std::net::TcpStream::connect(("127.0.0.1", port)) { + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(2))); + let request = + "GET /minio/health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + let mut response = String::new(); + if stream.write_all(request.as_bytes()).is_ok() + && stream.read_to_string(&mut response).is_ok() + && response.contains(" 200 ") + { + return true; + } + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + false + } + + /// MinIO pinned to an immutable release tag rather than `latest`. + /// + /// A floating tag lets a new upstream image change this test's behaviour, or + /// break it into a skip, without anyone choosing that. A security regression + /// test should only change when someone means it. Bump deliberately and re-run. + const MINIO_IMAGE_TAG: &str = "RELEASE.2025-09-07T16-13-09Z"; + + /// Whether a container runtime that can run this image is reachable. + /// + /// Used to tell an environment that cannot run the test, which is a legitimate + /// skip, from a container that failed to start, which is a failure worth + /// surfacing. Treating both as a skip is how a security test quietly becomes a + /// no-op. + /// + /// Windows is excluded outright: MinIO publishes Linux images only, and + /// Windows CI runs Windows containers, so the pull can never succeed there. + /// That is a property of the image, not a transient problem, so it is decided + /// here rather than inferred from a pull error. + fn can_run_linux_containers() -> bool { + if cfg!(windows) { + return false; + } + std::process::Command::new("docker") + .args(["info", "--format", "{{.OSType}}"]) + .output() + .map(|out| { + out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "linux" + }) + .unwrap_or(false) + } + + /// The coverage the local-only tests can't give: it proves the hardening does + /// NOT break a real object-store Iceberg read (the reader uses S3FileSystem, + /// which the hardening leaves enabled) while local-filesystem access and + /// configuration changes stay blocked in the same session. + /// + /// Skips only when no container runtime is reachable. If Docker is present and + /// the container will not start, that is a failure, not a skip, so a broken + /// image or a registry problem cannot turn this into a silent pass. + #[test] + fn sandbox_holds_over_object_store_iceberg() { + use testcontainers::core::{IntoContainerPort, WaitFor}; + use testcontainers::runners::SyncRunner; + use testcontainers::{GenericImage, ImageExt}; + + if !can_run_linux_containers() { + eprintln!( + "skipping sandbox_holds_over_object_store_iceberg: no Linux container runtime" + ); + return; + } + + let bucket = "warehouse"; + let image = GenericImage::new("minio/minio", MINIO_IMAGE_TAG) + .with_wait_for(WaitFor::seconds(1)) + .with_exposed_port(9000.tcp()) + .with_entrypoint("sh") + .with_env_var("MINIO_ROOT_USER", "minioadmin") + .with_env_var("MINIO_ROOT_PASSWORD", "minioadmin") + .with_cmd([ + "-c".to_string(), + format!("mkdir -p /data/{bucket} && exec minio server /data"), + ]); + + // Docker answered `info` above, so a failure here is a real problem (a bad + // image, a registry outage, a broken container config) and must fail the + // test rather than quietly skip it. + let container = image + .start() + .expect("MinIO container failed to start despite a reachable Docker daemon"); + let port = container + .get_host_port_ipv4(9000.tcp()) + .expect("mapped MinIO port"); + assert!(wait_for_minio(port), "MinIO did not become healthy"); + + let secret = format!( + "CREATE SECRET s3sec (TYPE s3, KEY_ID 'minioadmin', SECRET 'minioadmin', \ + ENDPOINT '127.0.0.1:{port}', URL_STYLE 'path', USE_SSL false, REGION 'us-east-1')" + ); + let table = format!("s3://{bucket}/tbl"); + let extensions = || { + vec![ + "INSTALL httpfs".to_string(), + "LOAD httpfs".to_string(), + "INSTALL iceberg".to_string(), + "LOAD iceberg".to_string(), + ] + }; + + let seed = duckdb::Connection::open_in_memory().expect("open duckdb"); + for stmt in extensions() { + seed.execute_batch(&stmt).expect("load extension"); + } + seed.execute_batch(&secret).expect("create s3 secret"); + seed.execute_batch(&format!( + "COPY (SELECT * FROM (VALUES (1,'a'),(2,'b'),(3,NULL)) t(id,name)) \ + TO '{table}' (FORMAT iceberg)" + )) + .expect("seed iceberg table on object storage"); + + let mut setup = extensions(); + setup.push(secret); + setup.extend(hardening_statements()); + + let read = run_query( + &setup, + &format!("SELECT id, name FROM iceberg_scan('{table}') ORDER BY id"), + [], + &Limits::none(), + ) + .expect("hardening must not break object-store Iceberg reads"); + assert_eq!(read.columns, vec!["id", "name"]); + assert_eq!( + read.rows, + vec![ + vec![serde_json::json!(1), serde_json::json!("a")], + vec![serde_json::json!(2), serde_json::json!("b")], + vec![serde_json::json!(3), serde_json::Value::Null], + ] + ); + + let local = run_query(&setup, "SELECT * FROM read_csv('/etc/hostname')", [], &Limits::none()) + .expect_err("local filesystem reads must stay blocked"); + assert!( + local.to_string().to_lowercase().contains("disabled"), + "unexpected error: {local}" + ); + + let cfg = run_query(&setup, "SET memory_limit = '1GB'", [], &Limits::none()) + .expect_err("configuration must stay locked"); + let cfg = cfg.to_string().to_lowercase(); + assert!( + cfg.contains("lock") || cfg.contains("configuration"), + "unexpected error: {cfg}" + ); + } +} + From 2fe2b3a1cdd53e12adae708203c3b02053e3e4ae Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Mon, 27 Jul 2026 12:48:18 +0100 Subject: [PATCH 13/18] Catalog facts for agent memory (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: Fix the catalog show output and provide a full view * chore: Add `--full` to `catalogs show` so we can one-shot the whole environment * chore: Add `catalog facts` to tower-cli * refactor(catalogs): rename catalog `facts` command to `knowledge` `facts` collides with fact tables in dimensional modeling — confusing right where this lives, on a data catalog. Rename the CLI surface and tower-cmd internals to `knowledge`. The generated tower-api client still calls them `facts` since the backend is unchanged. --- crates/tower-api/README.md | 13 +- crates/tower-api/src/apis/configuration.rs | 2 +- crates/tower-api/src/apis/default_api.rs | 388 +++++++++++- .../tower-api/src/apis/feature_flags_api.rs | 2 +- crates/tower-api/src/models/account.rs | 2 +- .../src/models/acknowledge_alert_response.rs | 2 +- .../models/acknowledge_all_alerts_response.rs | 2 +- crates/tower-api/src/models/alert.rs | 2 +- crates/tower-api/src/models/api_key.rs | 2 +- crates/tower-api/src/models/api_key_owner.rs | 2 +- crates/tower-api/src/models/app.rs | 6 +- crates/tower-api/src/models/app_statistics.rs | 2 +- crates/tower-api/src/models/app_summary.rs | 2 +- crates/tower-api/src/models/app_tag.rs | 29 + crates/tower-api/src/models/app_version.rs | 2 +- .../src/models/authentication_context.rs | 2 +- .../src/models/batch_schedule_params.rs | 2 +- .../src/models/batch_schedule_response.rs | 2 +- .../src/models/cancel_run_response.rs | 2 +- crates/tower-api/src/models/catalog.rs | 2 +- .../src/models/catalog_credentials.rs | 2 +- crates/tower-api/src/models/catalog_fact.rs | 151 +++++ .../tower-api/src/models/catalog_property.rs | 2 +- .../claim_device_login_ticket_params.rs | 2 +- .../claim_device_login_ticket_response.rs | 2 +- .../src/models/create_account_params.rs | 2 +- .../create_account_params_flags_struct.rs | 2 +- .../src/models/create_account_response.rs | 2 +- .../src/models/create_api_key_params.rs | 2 +- .../src/models/create_api_key_response.rs | 2 +- .../tower-api/src/models/create_app_params.rs | 28 +- .../src/models/create_app_response.rs | 2 +- .../src/models/create_catalog_params.rs | 2 +- .../src/models/create_catalog_response.rs | 2 +- .../create_device_login_ticket_response.rs | 2 +- .../src/models/create_environment_params.rs | 2 +- .../src/models/create_environment_response.rs | 2 +- .../src/models/create_guest_params.rs | 2 +- .../src/models/create_guest_response.rs | 2 +- .../models/create_sandbox_secrets_params.rs | 2 +- .../models/create_sandbox_secrets_response.rs | 2 +- .../src/models/create_schedule_params.rs | 2 +- .../src/models/create_schedule_response.rs | 2 +- .../src/models/create_secret_params.rs | 2 +- .../src/models/create_secret_response.rs | 2 +- .../create_service_account_api_key_params.rs | 2 +- ...create_service_account_api_key_response.rs | 2 +- .../models/create_service_account_params.rs | 2 +- .../models/create_service_account_response.rs | 2 +- .../src/models/create_session_params.rs | 2 +- .../src/models/create_session_response.rs | 2 +- .../src/models/create_team_params.rs | 2 +- .../src/models/create_team_response.rs | 2 +- .../src/models/create_webhook_params.rs | 2 +- .../src/models/create_webhook_response.rs | 2 +- .../src/models/delete_api_key_params.rs | 2 +- .../src/models/delete_api_key_response.rs | 2 +- .../src/models/delete_app_response.rs | 2 +- .../src/models/delete_catalog_response.rs | 2 +- .../src/models/delete_environment_response.rs | 2 +- .../src/models/delete_guest_output_body.rs | 2 +- .../src/models/delete_schedule_params.rs | 2 +- .../src/models/delete_schedule_response.rs | 2 +- .../src/models/delete_secret_response.rs | 2 +- .../delete_service_account_api_key_params.rs | 2 +- .../src/models/delete_session_params.rs | 2 +- .../src/models/delete_session_response.rs | 2 +- .../models/delete_team_invitation_params.rs | 2 +- .../models/delete_team_invitation_response.rs | 2 +- .../src/models/delete_team_params.rs | 2 +- .../src/models/delete_team_response.rs | 2 +- .../src/models/delete_webhook_response.rs | 2 +- .../src/models/deploy_app_request.rs | 2 +- .../src/models/deploy_app_response.rs | 2 +- .../src/models/describe_account_body.rs | 2 +- .../src/models/describe_app_response.rs | 2 +- .../models/describe_app_version_response.rs | 2 +- .../describe_authentication_context_body.rs | 2 +- .../models/describe_catalog_fact_response.rs | 29 + .../src/models/describe_catalog_response.rs | 2 +- .../describe_device_login_session_response.rs | 2 +- .../models/describe_email_preferences_body.rs | 2 +- .../models/describe_environment_response.rs | 2 +- .../src/models/describe_plan_response.rs | 2 +- .../src/models/describe_run_graph_response.rs | 2 +- .../src/models/describe_run_links.rs | 2 +- .../src/models/describe_run_logs_response.rs | 2 +- .../src/models/describe_run_response.rs | 2 +- .../models/describe_secrets_key_response.rs | 2 +- .../describe_service_account_response.rs | 2 +- .../src/models/describe_session_response.rs | 2 +- .../src/models/describe_team_response.rs | 2 +- .../src/models/describe_webhook_response.rs | 2 +- .../src/models/describe_whoami_response.rs | 2 +- .../src/models/email_subscriptions.rs | 2 +- .../src/models/encrypted_catalog_property.rs | 2 +- crates/tower-api/src/models/environment.rs | 2 +- crates/tower-api/src/models/error_detail.rs | 2 +- crates/tower-api/src/models/error_model.rs | 2 +- crates/tower-api/src/models/event_alert.rs | 2 +- crates/tower-api/src/models/event_error.rs | 2 +- crates/tower-api/src/models/event_log.rs | 2 +- .../tower-api/src/models/event_shouldertap.rs | 2 +- crates/tower-api/src/models/event_warning.rs | 2 +- .../src/models/export_catalogs_params.rs | 2 +- .../src/models/export_catalogs_response.rs | 2 +- .../src/models/export_secrets_params.rs | 2 +- .../src/models/export_secrets_response.rs | 2 +- .../tower-api/src/models/exported_catalog.rs | 2 +- .../src/models/exported_catalog_property.rs | 2 +- .../tower-api/src/models/exported_secret.rs | 2 +- crates/tower-api/src/models/feature.rs | 2 +- .../src/models/featurebase_identity.rs | 2 +- .../generate_app_statistics_response.rs | 2 +- ...organization_usage_time_series_response.rs | 2 +- .../generate_run_statistics_response.rs | 2 +- .../generate_runner_credentials_response.rs | 2 +- .../models/get_feature_flag_response_body.rs | 2 +- crates/tower-api/src/models/guest.rs | 2 +- .../src/models/invite_team_member_params.rs | 2 +- .../src/models/invite_team_member_response.rs | 2 +- .../src/models/leave_team_response.rs | 2 +- .../src/models/list_alerts_response.rs | 2 +- .../src/models/list_api_keys_response.rs | 2 +- .../models/list_app_environments_response.rs | 2 +- .../src/models/list_app_versions_response.rs | 2 +- .../src/models/list_apps_response.rs | 2 +- .../src/models/list_catalog_facts_response.rs | 32 + .../src/models/list_catalogs_response.rs | 2 +- .../src/models/list_environments_response.rs | 2 +- .../src/models/list_guests_response.rs | 2 +- .../list_my_team_invitations_response.rs | 2 +- .../src/models/list_runners_response.rs | 2 +- .../src/models/list_runs_response.rs | 2 +- .../src/models/list_schedules_response.rs | 2 +- .../list_secret_environments_response.rs | 2 +- .../src/models/list_secrets_response.rs | 2 +- .../list_service_account_api_keys_response.rs | 2 +- .../models/list_service_accounts_response.rs | 2 +- .../models/list_team_invitations_response.rs | 2 +- .../src/models/list_team_members_response.rs | 2 +- .../src/models/list_teams_response.rs | 2 +- .../src/models/list_webhooks_response.rs | 2 +- crates/tower-api/src/models/mod.rs | 14 + crates/tower-api/src/models/organization.rs | 2 +- .../src/models/organization_usage.rs | 2 +- crates/tower-api/src/models/pagination.rs | 2 +- crates/tower-api/src/models/parameter.rs | 2 +- crates/tower-api/src/models/plan.rs | 2 +- .../src/models/refresh_session_params.rs | 2 +- .../src/models/refresh_session_response.rs | 2 +- .../regenerate_guest_login_url_params.rs | 2 +- .../regenerate_guest_login_url_response.rs | 2 +- .../src/models/remove_team_member_params.rs | 2 +- .../src/models/remove_team_member_response.rs | 2 +- .../models/resend_team_invitation_params.rs | 2 +- .../models/resend_team_invitation_response.rs | 2 +- crates/tower-api/src/models/run.rs | 2 +- .../src/models/run_app_initiator_data.rs | 2 +- crates/tower-api/src/models/run_app_params.rs | 2 +- .../tower-api/src/models/run_app_response.rs | 2 +- crates/tower-api/src/models/run_attempt.rs | 2 +- crates/tower-api/src/models/run_creator.rs | 2 +- .../tower-api/src/models/run_failure_alert.rs | 2 +- crates/tower-api/src/models/run_graph_node.rs | 2 +- .../tower-api/src/models/run_graph_run_id.rs | 2 +- crates/tower-api/src/models/run_initiator.rs | 2 +- .../src/models/run_initiator_details.rs | 2 +- crates/tower-api/src/models/run_log_line.rs | 2 +- crates/tower-api/src/models/run_parameter.rs | 2 +- crates/tower-api/src/models/run_results.rs | 2 +- .../tower-api/src/models/run_retry_policy.rs | 2 +- .../src/models/run_run_initiator_details.rs | 2 +- crates/tower-api/src/models/run_statistics.rs | 2 +- .../src/models/run_timeseries_point.rs | 2 +- crates/tower-api/src/models/runner.rs | 2 +- .../src/models/runner_credentials.rs | 2 +- crates/tower-api/src/models/schedule.rs | 2 +- crates/tower-api/src/models/schedule_owner.rs | 2 +- .../models/schedule_run_initiator_details.rs | 2 +- .../src/models/search_runs_response.rs | 2 +- crates/tower-api/src/models/secret.rs | 2 +- .../src/models/server_sent_events_inner.rs | 2 +- .../src/models/server_sent_events_inner_1.rs | 2 +- .../src/models/server_sent_events_inner_2.rs | 2 +- .../tower-api/src/models/service_account.rs | 2 +- .../src/models/service_account_creator.rs | 2 +- crates/tower-api/src/models/session.rs | 2 +- crates/tower-api/src/models/shoulder_tap.rs | 2 +- crates/tower-api/src/models/sse_warning.rs | 2 +- .../src/models/statistics_settings.rs | 2 +- crates/tower-api/src/models/tag_filter.rs | 75 +++ crates/tower-api/src/models/team.rs | 2 +- .../tower-api/src/models/team_invitation.rs | 2 +- .../tower-api/src/models/team_membership.rs | 2 +- .../src/models/test_webhook_response.rs | 2 +- crates/tower-api/src/models/token.rs | 2 +- .../src/models/update_account_params.rs | 7 +- .../src/models/update_account_response.rs | 2 +- .../models/update_app_environment_params.rs | 2 +- .../models/update_app_environment_response.rs | 2 +- .../tower-api/src/models/update_app_params.rs | 17 +- .../src/models/update_app_response.rs | 2 +- .../src/models/update_catalog_fact_body.rs | 129 ++++ .../models/update_catalog_fact_response.rs | 29 + .../src/models/update_catalog_params.rs | 2 +- .../src/models/update_catalog_response.rs | 2 +- .../models/update_email_preferences_body.rs | 2 +- .../src/models/update_environment_params.rs | 2 +- .../src/models/update_environment_response.rs | 2 +- .../update_my_team_invitation_params.rs | 2 +- .../update_my_team_invitation_response.rs | 2 +- .../src/models/update_organization_params.rs | 2 +- .../models/update_organization_response.rs | 2 +- .../src/models/update_plan_params.rs | 2 +- .../src/models/update_plan_response.rs | 2 +- .../src/models/update_schedule_params.rs | 2 +- .../src/models/update_schedule_response.rs | 2 +- .../src/models/update_secret_params.rs | 2 +- .../src/models/update_secret_response.rs | 2 +- .../models/update_service_account_params.rs | 2 +- .../models/update_service_account_response.rs | 2 +- .../src/models/update_team_member_params.rs | 2 +- .../src/models/update_team_member_response.rs | 2 +- .../src/models/update_team_params.rs | 7 +- .../src/models/update_team_response.rs | 2 +- .../src/models/update_user_params.rs | 2 +- .../src/models/update_user_response.rs | 2 +- .../src/models/update_webhook_params.rs | 2 +- .../src/models/update_webhook_response.rs | 2 +- crates/tower-api/src/models/usage_limit.rs | 2 +- .../models/usage_metric_time_series_point.rs | 2 +- crates/tower-api/src/models/user.rs | 2 +- .../models/vend_catalog_credentials_body.rs | 2 +- .../vend_catalog_credentials_response.rs | 2 +- crates/tower-api/src/models/webhook.rs | 2 +- crates/tower-cmd/src/api.rs | 140 +++++ crates/tower-cmd/src/catalogs.rs | 595 +++++++++++++++++- crates/tower-cmd/src/lib.rs | 18 + crates/tower-cmd/src/util/apps.rs | 4 + 240 files changed, 1921 insertions(+), 232 deletions(-) create mode 100644 crates/tower-api/src/models/app_tag.rs create mode 100644 crates/tower-api/src/models/catalog_fact.rs create mode 100644 crates/tower-api/src/models/describe_catalog_fact_response.rs create mode 100644 crates/tower-api/src/models/list_catalog_facts_response.rs create mode 100644 crates/tower-api/src/models/tag_filter.rs create mode 100644 crates/tower-api/src/models/update_catalog_fact_body.rs create mode 100644 crates/tower-api/src/models/update_catalog_fact_response.rs diff --git a/crates/tower-api/README.md b/crates/tower-api/README.md index 77a6697c..6c22dca1 100644 --- a/crates/tower-api/README.md +++ b/crates/tower-api/README.md @@ -8,7 +8,7 @@ For more information, please visit [https://tower.dev](https://tower.dev) This API client was generated by the [OpenAPI Generator](https://openapi-generator.tech) project. By using the [openapi-spec](https://openapis.org) from a remote server, you can easily generate an API client. -- API version: v0.11.12 +- API version: v0.11.17 - Package version: 1.0.0 - Generator version: 7.19.0 - Build package: `org.openapitools.codegen.languages.RustClientCodegen` @@ -53,6 +53,7 @@ Class | Method | HTTP request | Description *DefaultApi* | [**delete_api_key**](docs/DefaultApi.md#delete_api_key) | **DELETE** /api-keys | Delete API key *DefaultApi* | [**delete_app**](docs/DefaultApi.md#delete_app) | **DELETE** /apps/{name} | Delete app *DefaultApi* | [**delete_catalog**](docs/DefaultApi.md#delete_catalog) | **DELETE** /catalogs/{name} | Delete catalog +*DefaultApi* | [**delete_catalog_fact**](docs/DefaultApi.md#delete_catalog_fact) | **DELETE** /catalogs/{catalog}/facts/{name} | Delete a catalog fact *DefaultApi* | [**delete_environment**](docs/DefaultApi.md#delete_environment) | **DELETE** /environments/{name} | Delete environment *DefaultApi* | [**delete_guest**](docs/DefaultApi.md#delete_guest) | **DELETE** /guests/{guest_id} | Delete guest *DefaultApi* | [**delete_schedule**](docs/DefaultApi.md#delete_schedule) | **DELETE** /schedules | Delete schedule @@ -69,6 +70,7 @@ Class | Method | HTTP request | Description *DefaultApi* | [**describe_app_version**](docs/DefaultApi.md#describe_app_version) | **GET** /apps/{name}/versions/{num} | Describe app version *DefaultApi* | [**describe_authentication_context**](docs/DefaultApi.md#describe_authentication_context) | **GET** /user/auth-context | Describe authentication context *DefaultApi* | [**describe_catalog**](docs/DefaultApi.md#describe_catalog) | **GET** /catalogs/{name} | Describe catalog +*DefaultApi* | [**describe_catalog_fact**](docs/DefaultApi.md#describe_catalog_fact) | **GET** /catalogs/{catalog}/facts/{name} | Describe a catalog fact *DefaultApi* | [**describe_default_catalog**](docs/DefaultApi.md#describe_default_catalog) | **GET** /storage/catalogs/default | Describe default catalog *DefaultApi* | [**describe_device_login_session**](docs/DefaultApi.md#describe_device_login_session) | **GET** /login/device/{device_code} | Describe device login session *DefaultApi* | [**describe_email_preferences**](docs/DefaultApi.md#describe_email_preferences) | **GET** /user/email-preferences | Describe email preferences @@ -97,6 +99,7 @@ Class | Method | HTTP request | Description *DefaultApi* | [**list_app_environments**](docs/DefaultApi.md#list_app_environments) | **GET** /apps/{name}/environments | List app environments *DefaultApi* | [**list_app_versions**](docs/DefaultApi.md#list_app_versions) | **GET** /apps/{name}/versions | List app versions *DefaultApi* | [**list_apps**](docs/DefaultApi.md#list_apps) | **GET** /apps | List apps +*DefaultApi* | [**list_catalog_facts**](docs/DefaultApi.md#list_catalog_facts) | **GET** /catalogs/{catalog}/facts | List catalog facts *DefaultApi* | [**list_catalogs**](docs/DefaultApi.md#list_catalogs) | **GET** /catalogs | List catalogs *DefaultApi* | [**list_environments**](docs/DefaultApi.md#list_environments) | **GET** /environments | List environments *DefaultApi* | [**list_guests**](docs/DefaultApi.md#list_guests) | **GET** /guests | List guests @@ -125,6 +128,7 @@ Class | Method | HTTP request | Description *DefaultApi* | [**update_app**](docs/DefaultApi.md#update_app) | **PUT** /apps/{name} | Update app *DefaultApi* | [**update_app_environment**](docs/DefaultApi.md#update_app_environment) | **PUT** /apps/{name}/environments/{environment} | Update app environment *DefaultApi* | [**update_catalog**](docs/DefaultApi.md#update_catalog) | **PUT** /catalogs/{name} | Update catalog +*DefaultApi* | [**update_catalog_fact**](docs/DefaultApi.md#update_catalog_fact) | **PUT** /catalogs/{catalog}/facts/{name} | Update a catalog fact *DefaultApi* | [**update_email_preferences**](docs/DefaultApi.md#update_email_preferences) | **PUT** /user/email-preferences | Update email preferences *DefaultApi* | [**update_environment**](docs/DefaultApi.md#update_environment) | **PUT** /environments/{name} | Update environment *DefaultApi* | [**update_my_team_invitation**](docs/DefaultApi.md#update_my_team_invitation) | **PUT** /team-invites | Update my team invitation @@ -152,6 +156,7 @@ Class | Method | HTTP request | Description - [App](docs/App.md) - [AppStatistics](docs/AppStatistics.md) - [AppSummary](docs/AppSummary.md) + - [AppTag](docs/AppTag.md) - [AppVersion](docs/AppVersion.md) - [AuthenticationContext](docs/AuthenticationContext.md) - [BatchScheduleParams](docs/BatchScheduleParams.md) @@ -159,6 +164,7 @@ Class | Method | HTTP request | Description - [CancelRunResponse](docs/CancelRunResponse.md) - [Catalog](docs/Catalog.md) - [CatalogCredentials](docs/CatalogCredentials.md) + - [CatalogFact](docs/CatalogFact.md) - [CatalogProperty](docs/CatalogProperty.md) - [ClaimDeviceLoginTicketParams](docs/ClaimDeviceLoginTicketParams.md) - [ClaimDeviceLoginTicketResponse](docs/ClaimDeviceLoginTicketResponse.md) @@ -215,6 +221,7 @@ Class | Method | HTTP request | Description - [DescribeAppResponse](docs/DescribeAppResponse.md) - [DescribeAppVersionResponse](docs/DescribeAppVersionResponse.md) - [DescribeAuthenticationContextBody](docs/DescribeAuthenticationContextBody.md) + - [DescribeCatalogFactResponse](docs/DescribeCatalogFactResponse.md) - [DescribeCatalogResponse](docs/DescribeCatalogResponse.md) - [DescribeDeviceLoginSessionResponse](docs/DescribeDeviceLoginSessionResponse.md) - [DescribeEmailPreferencesBody](docs/DescribeEmailPreferencesBody.md) @@ -263,6 +270,7 @@ Class | Method | HTTP request | Description - [ListAppEnvironmentsResponse](docs/ListAppEnvironmentsResponse.md) - [ListAppVersionsResponse](docs/ListAppVersionsResponse.md) - [ListAppsResponse](docs/ListAppsResponse.md) + - [ListCatalogFactsResponse](docs/ListCatalogFactsResponse.md) - [ListCatalogsResponse](docs/ListCatalogsResponse.md) - [ListEnvironmentsResponse](docs/ListEnvironmentsResponse.md) - [ListGuestsResponse](docs/ListGuestsResponse.md) @@ -325,6 +333,7 @@ Class | Method | HTTP request | Description - [ShoulderTap](docs/ShoulderTap.md) - [SseWarning](docs/SseWarning.md) - [StatisticsSettings](docs/StatisticsSettings.md) + - [TagFilter](docs/TagFilter.md) - [Team](docs/Team.md) - [TeamInvitation](docs/TeamInvitation.md) - [TeamMembership](docs/TeamMembership.md) @@ -336,6 +345,8 @@ Class | Method | HTTP request | Description - [UpdateAppEnvironmentResponse](docs/UpdateAppEnvironmentResponse.md) - [UpdateAppParams](docs/UpdateAppParams.md) - [UpdateAppResponse](docs/UpdateAppResponse.md) + - [UpdateCatalogFactBody](docs/UpdateCatalogFactBody.md) + - [UpdateCatalogFactResponse](docs/UpdateCatalogFactResponse.md) - [UpdateCatalogParams](docs/UpdateCatalogParams.md) - [UpdateCatalogResponse](docs/UpdateCatalogResponse.md) - [UpdateEmailPreferencesBody](docs/UpdateEmailPreferencesBody.md) diff --git a/crates/tower-api/src/apis/configuration.rs b/crates/tower-api/src/apis/configuration.rs index 0458570c..eed91008 100644 --- a/crates/tower-api/src/apis/configuration.rs +++ b/crates/tower-api/src/apis/configuration.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/apis/default_api.rs b/crates/tower-api/src/apis/default_api.rs index a80c32a1..80aa8413 100644 --- a/crates/tower-api/src/apis/default_api.rs +++ b/crates/tower-api/src/apis/default_api.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ @@ -170,6 +170,17 @@ pub struct DeleteCatalogParams { pub environment: Option, } +/// struct for passing parameters to the method [`delete_catalog_fact`] +#[derive(Clone, Debug)] +pub struct DeleteCatalogFactParams { + /// The name of the catalog. + pub catalog: String, + /// The name of the fact. + pub name: String, + /// The environment of the catalog. + pub environment: Option, +} + /// struct for passing parameters to the method [`delete_environment`] #[derive(Clone, Debug)] pub struct DeleteEnvironmentParams { @@ -302,6 +313,17 @@ pub struct DescribeCatalogParams { pub environment: Option, } +/// struct for passing parameters to the method [`describe_catalog_fact`] +#[derive(Clone, Debug)] +pub struct DescribeCatalogFactParams { + /// The name of the catalog. + pub catalog: String, + /// The name of the fact. + pub name: String, + /// The environment of the catalog. + pub environment: Option, +} + /// struct for passing parameters to the method [`describe_device_login_session`] #[derive(Clone, Debug)] pub struct DescribeDeviceLoginSessionParams { @@ -352,6 +374,10 @@ pub struct DescribeRunLogsParams { pub seq: i64, /// Fetch logs from this timestamp onwards (inclusive). pub start_at: Option, + /// Return only the first N log lines. Cannot be combined with tail. + pub head: Option, + /// Return only the last N log lines. Cannot be combined with head. + pub tail: Option, } /// struct for passing parameters to the method [`describe_secrets_key`] @@ -480,6 +506,8 @@ pub struct ListAppsParams { pub page: Option, /// The number of records to fetch on each page. pub page_size: Option, + /// The tag query to filter apps by. Provide a serialized TagFilter as the value for the parameter. + pub tag_filter: Option, /// The search query to filter apps by. pub query: Option, /// Number of recent runs to fetch (-1 for all runs, defaults to 20) @@ -492,6 +520,19 @@ pub struct ListAppsParams { pub environment: Option, } +/// struct for passing parameters to the method [`list_catalog_facts`] +#[derive(Clone, Debug)] +pub struct ListCatalogFactsParams { + /// The name of the catalog. + pub catalog: String, + /// The environment of the catalog. + pub environment: Option, + /// Filter facts by scope. When omitted, facts of every scope are returned. + pub scope: Option, + /// Filter facts by object path (exact match). When omitted, facts about any object are returned. + pub object: Option, +} + /// struct for passing parameters to the method [`list_catalogs`] #[derive(Clone, Debug)] pub struct ListCatalogsParams { @@ -727,6 +768,18 @@ pub struct UpdateCatalogParams { pub update_catalog_params: models::UpdateCatalogParams, } +/// struct for passing parameters to the method [`update_catalog_fact`] +#[derive(Clone, Debug)] +pub struct UpdateCatalogFactParams { + /// The name of the catalog. + pub catalog: String, + /// The name of the fact. + pub name: String, + pub update_catalog_fact_body: models::UpdateCatalogFactBody, + /// The environment of the catalog. + pub environment: Option, +} + /// struct for passing parameters to the method [`update_email_preferences`] #[derive(Clone, Debug)] pub struct UpdateEmailPreferencesParams { @@ -1032,6 +1085,14 @@ pub enum DeleteCatalogSuccess { UnknownValue(serde_json::Value), } +/// struct for typed successes of method [`delete_catalog_fact`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DeleteCatalogFactSuccess { + Status204(), + UnknownValue(serde_json::Value), +} + /// struct for typed successes of method [`delete_environment`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -1160,6 +1221,14 @@ pub enum DescribeCatalogSuccess { UnknownValue(serde_json::Value), } +/// struct for typed successes of method [`describe_catalog_fact`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DescribeCatalogFactSuccess { + Status200(models::DescribeCatalogFactResponse), + UnknownValue(serde_json::Value), +} + /// struct for typed successes of method [`describe_default_catalog`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -1385,6 +1454,14 @@ pub enum ListAppsSuccess { UnknownValue(serde_json::Value), } +/// struct for typed successes of method [`list_catalog_facts`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ListCatalogFactsSuccess { + Status200(models::ListCatalogFactsResponse), + UnknownValue(serde_json::Value), +} + /// struct for typed successes of method [`list_catalogs`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -1609,6 +1686,14 @@ pub enum UpdateCatalogSuccess { UnknownValue(serde_json::Value), } +/// struct for typed successes of method [`update_catalog_fact`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UpdateCatalogFactSuccess { + Status200(models::UpdateCatalogFactResponse), + UnknownValue(serde_json::Value), +} + /// struct for typed successes of method [`update_email_preferences`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -1924,6 +2009,14 @@ pub enum DeleteCatalogError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`delete_catalog_fact`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DeleteCatalogFactError { + DefaultResponse(models::ErrorModel), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`delete_environment`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -2054,6 +2147,14 @@ pub enum DescribeCatalogError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`describe_catalog_fact`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DescribeCatalogFactError { + DefaultResponse(models::ErrorModel), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`describe_default_catalog`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -2279,6 +2380,14 @@ pub enum ListAppsError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`list_catalog_facts`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ListCatalogFactsError { + DefaultResponse(models::ErrorModel), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`list_catalogs`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -2503,6 +2612,14 @@ pub enum UpdateCatalogError { UnknownValue(serde_json::Value), } +/// struct for typed errors of method [`update_catalog_fact`] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UpdateCatalogFactError { + DefaultResponse(models::ErrorModel), + UnknownValue(serde_json::Value), +} + /// struct for typed errors of method [`update_email_preferences`] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] @@ -4111,6 +4228,71 @@ pub async fn delete_catalog( } } +/// Deletes a semantic metadata fact addressed by its name within a catalog. +pub async fn delete_catalog_fact( + configuration: &configuration::Configuration, + params: DeleteCatalogFactParams, +) -> Result, Error> { + let uri_str = format!( + "{}/catalogs/{catalog}/facts/{name}", + configuration.base_path, + catalog = crate::apis::urlencode(params.catalog), + name = crate::apis::urlencode(params.name) + ); + let mut req_builder = configuration + .client + .request(reqwest::Method::DELETE, &uri_str); + + if let Some(ref param_value) = params.environment { + req_builder = req_builder.query(&[("environment", ¶m_value.to_string())]); + } + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + if let Some(ref apikey) = configuration.api_key { + let key = apikey.key.clone(); + let value = match apikey.prefix { + Some(ref prefix) => format!("{} {}", prefix, key), + None => key, + }; + req_builder = req_builder.header("X-API-Key", value); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + let tower_trace_id = resp + .headers() + .get("x-tower-trace-id") + .and_then(|v| v.to_str().ok()) + .map_or(String::from(DEFAULT_TOWER_TRACE_ID), String::from); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Ok(ResponseContent { + tower_trace_id, + status, + content, + entity, + }) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + tower_trace_id, + status, + content, + entity, + })) + } +} + /// Delete an environment by name pub async fn delete_environment( configuration: &configuration::Configuration, @@ -5110,6 +5292,69 @@ pub async fn describe_catalog( } } +/// Returns a single semantic metadata fact addressed by its name within a catalog. +pub async fn describe_catalog_fact( + configuration: &configuration::Configuration, + params: DescribeCatalogFactParams, +) -> Result, Error> { + let uri_str = format!( + "{}/catalogs/{catalog}/facts/{name}", + configuration.base_path, + catalog = crate::apis::urlencode(params.catalog), + name = crate::apis::urlencode(params.name) + ); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref param_value) = params.environment { + req_builder = req_builder.query(&[("environment", ¶m_value.to_string())]); + } + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + if let Some(ref apikey) = configuration.api_key { + let key = apikey.key.clone(); + let value = match apikey.prefix { + Some(ref prefix) => format!("{} {}", prefix, key), + None => key, + }; + req_builder = req_builder.header("X-API-Key", value); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + let tower_trace_id = resp + .headers() + .get("x-tower-trace-id") + .and_then(|v| v.to_str().ok()) + .map_or(String::from(DEFAULT_TOWER_TRACE_ID), String::from); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Ok(ResponseContent { + tower_trace_id, + status, + content, + entity, + }) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + tower_trace_id, + status, + content, + entity, + })) + } +} + /// Returns the team's default catalog, provisioning it lazily if it does not yet exist. When two concurrent first calls race to provision the catalog, the loser receives 202 Accepted; retry after a few seconds and the catalog will be ready. pub async fn describe_default_catalog( configuration: &configuration::Configuration, @@ -5581,6 +5826,12 @@ pub async fn describe_run_logs( if let Some(ref param_value) = params.start_at { req_builder = req_builder.query(&[("start_at", ¶m_value.to_string())]); } + if let Some(ref param_value) = params.head { + req_builder = req_builder.query(&[("head", ¶m_value.to_string())]); + } + if let Some(ref param_value) = params.tail { + req_builder = req_builder.query(&[("tail", ¶m_value.to_string())]); + } if let Some(ref user_agent) = configuration.user_agent { req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); } @@ -6734,6 +6985,9 @@ pub async fn list_apps( if let Some(ref param_value) = params.page_size { req_builder = req_builder.query(&[("page_size", ¶m_value.to_string())]); } + if let Some(ref param_value) = params.tag_filter { + req_builder = req_builder.query(&[("tag_filter", ¶m_value.to_string())]); + } if let Some(ref param_value) = params.query { req_builder = req_builder.query(&[("query", ¶m_value.to_string())]); } @@ -6796,6 +7050,74 @@ pub async fn list_apps( } } +/// Lists the semantic metadata facts attached to a catalog, optionally filtered by scope and/or object path. +pub async fn list_catalog_facts( + configuration: &configuration::Configuration, + params: ListCatalogFactsParams, +) -> Result, Error> { + let uri_str = format!( + "{}/catalogs/{catalog}/facts", + configuration.base_path, + catalog = crate::apis::urlencode(params.catalog) + ); + let mut req_builder = configuration.client.request(reqwest::Method::GET, &uri_str); + + if let Some(ref param_value) = params.environment { + req_builder = req_builder.query(&[("environment", ¶m_value.to_string())]); + } + if let Some(ref param_value) = params.scope { + req_builder = req_builder.query(&[("scope", ¶m_value.to_string())]); + } + if let Some(ref param_value) = params.object { + req_builder = req_builder.query(&[("object", ¶m_value.to_string())]); + } + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + if let Some(ref apikey) = configuration.api_key { + let key = apikey.key.clone(); + let value = match apikey.prefix { + Some(ref prefix) => format!("{} {}", prefix, key), + None => key, + }; + req_builder = req_builder.header("X-API-Key", value); + }; + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + let tower_trace_id = resp + .headers() + .get("x-tower-trace-id") + .and_then(|v| v.to_str().ok()) + .map_or(String::from(DEFAULT_TOWER_TRACE_ID), String::from); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Ok(ResponseContent { + tower_trace_id, + status, + content, + entity, + }) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + tower_trace_id, + status, + content, + entity, + })) + } +} + /// Lists all the catalogs associated with your current account. pub async fn list_catalogs( configuration: &configuration::Configuration, @@ -8550,6 +8872,70 @@ pub async fn update_catalog( } } +/// Idempotently sets a semantic metadata fact by name: creates it when the name is new, updates it when it already exists. An inferred write cannot overwrite an existing confirmed fact. +pub async fn update_catalog_fact( + configuration: &configuration::Configuration, + params: UpdateCatalogFactParams, +) -> Result, Error> { + let uri_str = format!( + "{}/catalogs/{catalog}/facts/{name}", + configuration.base_path, + catalog = crate::apis::urlencode(params.catalog), + name = crate::apis::urlencode(params.name) + ); + let mut req_builder = configuration.client.request(reqwest::Method::PUT, &uri_str); + + if let Some(ref param_value) = params.environment { + req_builder = req_builder.query(&[("environment", ¶m_value.to_string())]); + } + if let Some(ref user_agent) = configuration.user_agent { + req_builder = req_builder.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref token) = configuration.bearer_access_token { + req_builder = req_builder.bearer_auth(token.to_owned()); + }; + if let Some(ref apikey) = configuration.api_key { + let key = apikey.key.clone(); + let value = match apikey.prefix { + Some(ref prefix) => format!("{} {}", prefix, key), + None => key, + }; + req_builder = req_builder.header("X-API-Key", value); + }; + req_builder = req_builder.json(¶ms.update_catalog_fact_body); + + let req = req_builder.build()?; + let resp = configuration.client.execute(req).await?; + + let status = resp.status(); + + let tower_trace_id = resp + .headers() + .get("x-tower-trace-id") + .and_then(|v| v.to_str().ok()) + .map_or(String::from(DEFAULT_TOWER_TRACE_ID), String::from); + + if !status.is_client_error() && !status.is_server_error() { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Ok(ResponseContent { + tower_trace_id, + status, + content, + entity, + }) + } else { + let content = resp.text().await?; + let entity: Option = serde_json::from_str(&content).ok(); + Err(Error::ResponseError(ResponseContent { + tower_trace_id, + status, + content, + entity, + })) + } +} + /// Updates the set of email preferences the current user has. If a partial set of preferences is submitted, it will be updated accordingly. pub async fn update_email_preferences( configuration: &configuration::Configuration, diff --git a/crates/tower-api/src/apis/feature_flags_api.rs b/crates/tower-api/src/apis/feature_flags_api.rs index 562e1050..bb90cc43 100644 --- a/crates/tower-api/src/apis/feature_flags_api.rs +++ b/crates/tower-api/src/apis/feature_flags_api.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/account.rs b/crates/tower-api/src/models/account.rs index 5d517280..13f84cda 100644 --- a/crates/tower-api/src/models/account.rs +++ b/crates/tower-api/src/models/account.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/acknowledge_alert_response.rs b/crates/tower-api/src/models/acknowledge_alert_response.rs index 3e5ae388..d55f0d56 100644 --- a/crates/tower-api/src/models/acknowledge_alert_response.rs +++ b/crates/tower-api/src/models/acknowledge_alert_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/acknowledge_all_alerts_response.rs b/crates/tower-api/src/models/acknowledge_all_alerts_response.rs index cf0960a6..3562b641 100644 --- a/crates/tower-api/src/models/acknowledge_all_alerts_response.rs +++ b/crates/tower-api/src/models/acknowledge_all_alerts_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/alert.rs b/crates/tower-api/src/models/alert.rs index 150914cb..917366f8 100644 --- a/crates/tower-api/src/models/alert.rs +++ b/crates/tower-api/src/models/alert.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/api_key.rs b/crates/tower-api/src/models/api_key.rs index e4ff77db..c92f2dff 100644 --- a/crates/tower-api/src/models/api_key.rs +++ b/crates/tower-api/src/models/api_key.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/api_key_owner.rs b/crates/tower-api/src/models/api_key_owner.rs index f079c412..840d5b93 100644 --- a/crates/tower-api/src/models/api_key_owner.rs +++ b/crates/tower-api/src/models/api_key_owner.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/app.rs b/crates/tower-api/src/models/app.rs index 62771ba8..6afd89c2 100644 --- a/crates/tower-api/src/models/app.rs +++ b/crates/tower-api/src/models/app.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ @@ -69,6 +69,9 @@ pub struct App { /// The subdomain that this app is accessible via. Must be externally accessible first. #[serde(rename = "subdomain", skip_serializing_if = "Option::is_none")] pub subdomain: Option, + /// The tags applied to the app. + #[serde(rename = "tags", skip_serializing_if = "Option::is_none")] + pub tags: Option>, /// The current version of this app, null if none. #[serde(rename = "version", deserialize_with = "Option::deserialize")] pub version: Option, @@ -105,6 +108,7 @@ impl App { slug: None, status: None, subdomain: None, + tags: None, version, } } diff --git a/crates/tower-api/src/models/app_statistics.rs b/crates/tower-api/src/models/app_statistics.rs index ac1c918f..e691a736 100644 --- a/crates/tower-api/src/models/app_statistics.rs +++ b/crates/tower-api/src/models/app_statistics.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/app_summary.rs b/crates/tower-api/src/models/app_summary.rs index 0e8a7d62..fd6eaacf 100644 --- a/crates/tower-api/src/models/app_summary.rs +++ b/crates/tower-api/src/models/app_summary.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/app_tag.rs b/crates/tower-api/src/models/app_tag.rs new file mode 100644 index 00000000..59fadff6 --- /dev/null +++ b/crates/tower-api/src/models/app_tag.rs @@ -0,0 +1,29 @@ +/* + * Tower API + * + * REST API to interact with Tower Services. + * + * The version of the OpenAPI document: v0.11.17 + * Contact: hello@tower.dev + * Generated by: https://openapi-generator.tech + */ +use crate::models; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, DefaultOnNull}; + +#[serde_as] +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct AppTag { + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "name")] + pub name: String, + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "value")] + pub value: String, +} + +impl AppTag { + pub fn new(name: String, value: String) -> AppTag { + AppTag { name, value } + } +} diff --git a/crates/tower-api/src/models/app_version.rs b/crates/tower-api/src/models/app_version.rs index ca14cc97..b1193b3c 100644 --- a/crates/tower-api/src/models/app_version.rs +++ b/crates/tower-api/src/models/app_version.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/authentication_context.rs b/crates/tower-api/src/models/authentication_context.rs index 2181be53..16e7d68b 100644 --- a/crates/tower-api/src/models/authentication_context.rs +++ b/crates/tower-api/src/models/authentication_context.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/batch_schedule_params.rs b/crates/tower-api/src/models/batch_schedule_params.rs index 5293caec..81e52aa1 100644 --- a/crates/tower-api/src/models/batch_schedule_params.rs +++ b/crates/tower-api/src/models/batch_schedule_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/batch_schedule_response.rs b/crates/tower-api/src/models/batch_schedule_response.rs index b4920ec3..09532e94 100644 --- a/crates/tower-api/src/models/batch_schedule_response.rs +++ b/crates/tower-api/src/models/batch_schedule_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/cancel_run_response.rs b/crates/tower-api/src/models/cancel_run_response.rs index 7abc7d07..d7c0e02a 100644 --- a/crates/tower-api/src/models/cancel_run_response.rs +++ b/crates/tower-api/src/models/cancel_run_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/catalog.rs b/crates/tower-api/src/models/catalog.rs index d5531a9f..352181b2 100644 --- a/crates/tower-api/src/models/catalog.rs +++ b/crates/tower-api/src/models/catalog.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/catalog_credentials.rs b/crates/tower-api/src/models/catalog_credentials.rs index d810f815..284b8aee 100644 --- a/crates/tower-api/src/models/catalog_credentials.rs +++ b/crates/tower-api/src/models/catalog_credentials.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/catalog_fact.rs b/crates/tower-api/src/models/catalog_fact.rs new file mode 100644 index 00000000..7689d4d6 --- /dev/null +++ b/crates/tower-api/src/models/catalog_fact.rs @@ -0,0 +1,151 @@ +/* + * Tower API + * + * REST API to interact with Tower Services. + * + * The version of the OpenAPI document: v0.11.17 + * Contact: hello@tower.dev + * Generated by: https://openapi-generator.tech + */ +use crate::models; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, DefaultOnNull}; + +#[serde_as] +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct CatalogFact { + #[serde( + rename = "body", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub body: Option>, + /// How trustworthy the fact is. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "confidence")] + pub confidence: Confidence, + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "created_at")] + pub created_at: String, + /// The natural key of the fact within the catalog. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "name")] + pub name: String, + /// Descriptive path to what the fact is about, e.g. \"bronze.runs.deleted_at\". Empty for catalog-scoped facts. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "object")] + pub object: String, + /// What kind of object the fact is about. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "scope")] + pub scope: Scope, + /// Where the fact came from (agent id, user, ...). + #[serde(rename = "source", skip_serializing_if = "Option::is_none")] + pub source: Option, + /// The human-readable meaning of the fact. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "statement")] + pub statement: String, + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "updated_at")] + pub updated_at: String, +} + +impl CatalogFact { + pub fn new( + confidence: Confidence, + created_at: String, + name: String, + object: String, + scope: Scope, + statement: String, + updated_at: String, + ) -> CatalogFact { + CatalogFact { + body: None, + confidence, + created_at, + name, + object, + scope, + source: None, + statement, + updated_at, + } + } +} +/// How trustworthy the fact is. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +pub enum Confidence { + #[serde(rename = "confirmed")] + Confirmed, + #[serde(rename = "heuristic")] + Heuristic, + #[serde(rename = "inferred")] + Inferred, +} + +impl Default for Confidence { + fn default() -> Confidence { + Self::Confirmed + } +} + +impl<'de> Deserialize<'de> for Confidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "confirmed" => Ok(Self::Confirmed), + "heuristic" => Ok(Self::Heuristic), + "inferred" => Ok(Self::Inferred), + _ => Err(serde::de::Error::unknown_variant( + &s, + &["confirmed", "heuristic", "inferred"], + )), + } + } +} +/// What kind of object the fact is about. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +pub enum Scope { + #[serde(rename = "catalog")] + Catalog, + #[serde(rename = "namespace")] + Namespace, + #[serde(rename = "table")] + Table, + #[serde(rename = "column")] + Column, + #[serde(rename = "metric")] + Metric, +} + +impl Default for Scope { + fn default() -> Scope { + Self::Catalog + } +} + +impl<'de> Deserialize<'de> for Scope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "catalog" => Ok(Self::Catalog), + "namespace" => Ok(Self::Namespace), + "table" => Ok(Self::Table), + "column" => Ok(Self::Column), + "metric" => Ok(Self::Metric), + _ => Err(serde::de::Error::unknown_variant( + &s, + &["catalog", "namespace", "table", "column", "metric"], + )), + } + } +} diff --git a/crates/tower-api/src/models/catalog_property.rs b/crates/tower-api/src/models/catalog_property.rs index 8201ef42..4b15bd7e 100644 --- a/crates/tower-api/src/models/catalog_property.rs +++ b/crates/tower-api/src/models/catalog_property.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/claim_device_login_ticket_params.rs b/crates/tower-api/src/models/claim_device_login_ticket_params.rs index a6a83c70..0c5bf058 100644 --- a/crates/tower-api/src/models/claim_device_login_ticket_params.rs +++ b/crates/tower-api/src/models/claim_device_login_ticket_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/claim_device_login_ticket_response.rs b/crates/tower-api/src/models/claim_device_login_ticket_response.rs index 30ee0910..327fa284 100644 --- a/crates/tower-api/src/models/claim_device_login_ticket_response.rs +++ b/crates/tower-api/src/models/claim_device_login_ticket_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_account_params.rs b/crates/tower-api/src/models/create_account_params.rs index 8c0491b5..7aa1327d 100644 --- a/crates/tower-api/src/models/create_account_params.rs +++ b/crates/tower-api/src/models/create_account_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_account_params_flags_struct.rs b/crates/tower-api/src/models/create_account_params_flags_struct.rs index fed0ebf7..66d4c825 100644 --- a/crates/tower-api/src/models/create_account_params_flags_struct.rs +++ b/crates/tower-api/src/models/create_account_params_flags_struct.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_account_response.rs b/crates/tower-api/src/models/create_account_response.rs index 248fad0d..bbed8fba 100644 --- a/crates/tower-api/src/models/create_account_response.rs +++ b/crates/tower-api/src/models/create_account_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_api_key_params.rs b/crates/tower-api/src/models/create_api_key_params.rs index 613dfc27..4f026c03 100644 --- a/crates/tower-api/src/models/create_api_key_params.rs +++ b/crates/tower-api/src/models/create_api_key_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_api_key_response.rs b/crates/tower-api/src/models/create_api_key_response.rs index 9c08f6b5..c90b282b 100644 --- a/crates/tower-api/src/models/create_api_key_response.rs +++ b/crates/tower-api/src/models/create_api_key_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_app_params.rs b/crates/tower-api/src/models/create_app_params.rs index 39f5bacb..77b91935 100644 --- a/crates/tower-api/src/models/create_app_params.rs +++ b/crates/tower-api/src/models/create_app_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ @@ -27,6 +27,25 @@ pub struct CreateAppParams { #[serde_as(as = "DefaultOnNull")] #[serde(rename = "name")] pub name: String, + /// The amount of time in seconds that runs of this app can stay in pending state before being marked as failed. + #[serde( + rename = "pending_timeout", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub pending_timeout: Option>, + /// The retry policy for failed runs of this app. Defines different retry behavior for infrastructure (errored) vs application (crashed) failures. + #[serde(rename = "retry_policy", skip_serializing_if = "Option::is_none")] + pub retry_policy: Option, + /// The amount of time in seconds that runs of this app can stay in running state before being marked as failed. + #[serde( + rename = "running_timeout", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub running_timeout: Option>, /// A description of the app. #[serde(rename = "short_description", skip_serializing_if = "Option::is_none")] pub short_description: Option, @@ -41,6 +60,9 @@ pub struct CreateAppParams { skip_serializing_if = "Option::is_none" )] pub subdomain: Option>, + /// The tags for this app. + #[serde(rename = "tags", skip_serializing_if = "Option::is_none")] + pub tags: Option>, } impl CreateAppParams { @@ -49,9 +71,13 @@ impl CreateAppParams { schema: None, is_externally_accessible: None, name, + pending_timeout: None, + retry_policy: None, + running_timeout: None, short_description: None, slug: None, subdomain: None, + tags: None, } } } diff --git a/crates/tower-api/src/models/create_app_response.rs b/crates/tower-api/src/models/create_app_response.rs index e2a487e2..e1cac520 100644 --- a/crates/tower-api/src/models/create_app_response.rs +++ b/crates/tower-api/src/models/create_app_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_catalog_params.rs b/crates/tower-api/src/models/create_catalog_params.rs index 15de539e..33196ef6 100644 --- a/crates/tower-api/src/models/create_catalog_params.rs +++ b/crates/tower-api/src/models/create_catalog_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_catalog_response.rs b/crates/tower-api/src/models/create_catalog_response.rs index 183311b6..f4bb0866 100644 --- a/crates/tower-api/src/models/create_catalog_response.rs +++ b/crates/tower-api/src/models/create_catalog_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_device_login_ticket_response.rs b/crates/tower-api/src/models/create_device_login_ticket_response.rs index f896d8ed..1da72fec 100644 --- a/crates/tower-api/src/models/create_device_login_ticket_response.rs +++ b/crates/tower-api/src/models/create_device_login_ticket_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_environment_params.rs b/crates/tower-api/src/models/create_environment_params.rs index 5b722297..b1e70989 100644 --- a/crates/tower-api/src/models/create_environment_params.rs +++ b/crates/tower-api/src/models/create_environment_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_environment_response.rs b/crates/tower-api/src/models/create_environment_response.rs index 8a5ef1e5..0aed4bf2 100644 --- a/crates/tower-api/src/models/create_environment_response.rs +++ b/crates/tower-api/src/models/create_environment_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_guest_params.rs b/crates/tower-api/src/models/create_guest_params.rs index d3a8dc7c..7afc6838 100644 --- a/crates/tower-api/src/models/create_guest_params.rs +++ b/crates/tower-api/src/models/create_guest_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_guest_response.rs b/crates/tower-api/src/models/create_guest_response.rs index 3026a350..d5df6972 100644 --- a/crates/tower-api/src/models/create_guest_response.rs +++ b/crates/tower-api/src/models/create_guest_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_sandbox_secrets_params.rs b/crates/tower-api/src/models/create_sandbox_secrets_params.rs index 981ddaea..3918e1de 100644 --- a/crates/tower-api/src/models/create_sandbox_secrets_params.rs +++ b/crates/tower-api/src/models/create_sandbox_secrets_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_sandbox_secrets_response.rs b/crates/tower-api/src/models/create_sandbox_secrets_response.rs index 139af10e..46ac8d02 100644 --- a/crates/tower-api/src/models/create_sandbox_secrets_response.rs +++ b/crates/tower-api/src/models/create_sandbox_secrets_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_schedule_params.rs b/crates/tower-api/src/models/create_schedule_params.rs index bda61ef2..791809b0 100644 --- a/crates/tower-api/src/models/create_schedule_params.rs +++ b/crates/tower-api/src/models/create_schedule_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_schedule_response.rs b/crates/tower-api/src/models/create_schedule_response.rs index 02539f8a..a95ab056 100644 --- a/crates/tower-api/src/models/create_schedule_response.rs +++ b/crates/tower-api/src/models/create_schedule_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_secret_params.rs b/crates/tower-api/src/models/create_secret_params.rs index b71a4966..6e27c3ea 100644 --- a/crates/tower-api/src/models/create_secret_params.rs +++ b/crates/tower-api/src/models/create_secret_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_secret_response.rs b/crates/tower-api/src/models/create_secret_response.rs index 2e340862..3a8f27ed 100644 --- a/crates/tower-api/src/models/create_secret_response.rs +++ b/crates/tower-api/src/models/create_secret_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_service_account_api_key_params.rs b/crates/tower-api/src/models/create_service_account_api_key_params.rs index 51f48e40..a45d8088 100644 --- a/crates/tower-api/src/models/create_service_account_api_key_params.rs +++ b/crates/tower-api/src/models/create_service_account_api_key_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_service_account_api_key_response.rs b/crates/tower-api/src/models/create_service_account_api_key_response.rs index e8300707..47758632 100644 --- a/crates/tower-api/src/models/create_service_account_api_key_response.rs +++ b/crates/tower-api/src/models/create_service_account_api_key_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_service_account_params.rs b/crates/tower-api/src/models/create_service_account_params.rs index 59afb120..799df04a 100644 --- a/crates/tower-api/src/models/create_service_account_params.rs +++ b/crates/tower-api/src/models/create_service_account_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_service_account_response.rs b/crates/tower-api/src/models/create_service_account_response.rs index cb3a0624..2c84d6f4 100644 --- a/crates/tower-api/src/models/create_service_account_response.rs +++ b/crates/tower-api/src/models/create_service_account_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_session_params.rs b/crates/tower-api/src/models/create_session_params.rs index 5afa1060..c6259b64 100644 --- a/crates/tower-api/src/models/create_session_params.rs +++ b/crates/tower-api/src/models/create_session_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_session_response.rs b/crates/tower-api/src/models/create_session_response.rs index 4e82c314..a5239d45 100644 --- a/crates/tower-api/src/models/create_session_response.rs +++ b/crates/tower-api/src/models/create_session_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_team_params.rs b/crates/tower-api/src/models/create_team_params.rs index dedfe555..58119629 100644 --- a/crates/tower-api/src/models/create_team_params.rs +++ b/crates/tower-api/src/models/create_team_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_team_response.rs b/crates/tower-api/src/models/create_team_response.rs index 10836b9f..9bdd10c4 100644 --- a/crates/tower-api/src/models/create_team_response.rs +++ b/crates/tower-api/src/models/create_team_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_webhook_params.rs b/crates/tower-api/src/models/create_webhook_params.rs index 9ba5fb01..490bfabb 100644 --- a/crates/tower-api/src/models/create_webhook_params.rs +++ b/crates/tower-api/src/models/create_webhook_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/create_webhook_response.rs b/crates/tower-api/src/models/create_webhook_response.rs index ae0c04af..045ef73e 100644 --- a/crates/tower-api/src/models/create_webhook_response.rs +++ b/crates/tower-api/src/models/create_webhook_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_api_key_params.rs b/crates/tower-api/src/models/delete_api_key_params.rs index 8441f665..499f72a9 100644 --- a/crates/tower-api/src/models/delete_api_key_params.rs +++ b/crates/tower-api/src/models/delete_api_key_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_api_key_response.rs b/crates/tower-api/src/models/delete_api_key_response.rs index 58af156e..39ac6a3e 100644 --- a/crates/tower-api/src/models/delete_api_key_response.rs +++ b/crates/tower-api/src/models/delete_api_key_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_app_response.rs b/crates/tower-api/src/models/delete_app_response.rs index 091e8397..2946b48a 100644 --- a/crates/tower-api/src/models/delete_app_response.rs +++ b/crates/tower-api/src/models/delete_app_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_catalog_response.rs b/crates/tower-api/src/models/delete_catalog_response.rs index f2d6fc9e..1affd044 100644 --- a/crates/tower-api/src/models/delete_catalog_response.rs +++ b/crates/tower-api/src/models/delete_catalog_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_environment_response.rs b/crates/tower-api/src/models/delete_environment_response.rs index a245751e..24bbd310 100644 --- a/crates/tower-api/src/models/delete_environment_response.rs +++ b/crates/tower-api/src/models/delete_environment_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_guest_output_body.rs b/crates/tower-api/src/models/delete_guest_output_body.rs index a874b86b..fc40e6b7 100644 --- a/crates/tower-api/src/models/delete_guest_output_body.rs +++ b/crates/tower-api/src/models/delete_guest_output_body.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_schedule_params.rs b/crates/tower-api/src/models/delete_schedule_params.rs index 1ef1718e..cc5546ac 100644 --- a/crates/tower-api/src/models/delete_schedule_params.rs +++ b/crates/tower-api/src/models/delete_schedule_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_schedule_response.rs b/crates/tower-api/src/models/delete_schedule_response.rs index 6ca154fc..0a830666 100644 --- a/crates/tower-api/src/models/delete_schedule_response.rs +++ b/crates/tower-api/src/models/delete_schedule_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_secret_response.rs b/crates/tower-api/src/models/delete_secret_response.rs index 46af28c9..4ca7b11f 100644 --- a/crates/tower-api/src/models/delete_secret_response.rs +++ b/crates/tower-api/src/models/delete_secret_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_service_account_api_key_params.rs b/crates/tower-api/src/models/delete_service_account_api_key_params.rs index 60a91ac5..16ebdbaa 100644 --- a/crates/tower-api/src/models/delete_service_account_api_key_params.rs +++ b/crates/tower-api/src/models/delete_service_account_api_key_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_session_params.rs b/crates/tower-api/src/models/delete_session_params.rs index 4f6ac79a..de8f855f 100644 --- a/crates/tower-api/src/models/delete_session_params.rs +++ b/crates/tower-api/src/models/delete_session_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_session_response.rs b/crates/tower-api/src/models/delete_session_response.rs index 0b1713b9..d7fc8575 100644 --- a/crates/tower-api/src/models/delete_session_response.rs +++ b/crates/tower-api/src/models/delete_session_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_team_invitation_params.rs b/crates/tower-api/src/models/delete_team_invitation_params.rs index 7262f936..96a895b4 100644 --- a/crates/tower-api/src/models/delete_team_invitation_params.rs +++ b/crates/tower-api/src/models/delete_team_invitation_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_team_invitation_response.rs b/crates/tower-api/src/models/delete_team_invitation_response.rs index add0d639..ca73daa0 100644 --- a/crates/tower-api/src/models/delete_team_invitation_response.rs +++ b/crates/tower-api/src/models/delete_team_invitation_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_team_params.rs b/crates/tower-api/src/models/delete_team_params.rs index 00810799..e8175fa4 100644 --- a/crates/tower-api/src/models/delete_team_params.rs +++ b/crates/tower-api/src/models/delete_team_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_team_response.rs b/crates/tower-api/src/models/delete_team_response.rs index 45477a73..fcfc256f 100644 --- a/crates/tower-api/src/models/delete_team_response.rs +++ b/crates/tower-api/src/models/delete_team_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/delete_webhook_response.rs b/crates/tower-api/src/models/delete_webhook_response.rs index 7d0979dc..a0b3d6a6 100644 --- a/crates/tower-api/src/models/delete_webhook_response.rs +++ b/crates/tower-api/src/models/delete_webhook_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/deploy_app_request.rs b/crates/tower-api/src/models/deploy_app_request.rs index bc5124da..2f2de5cc 100644 --- a/crates/tower-api/src/models/deploy_app_request.rs +++ b/crates/tower-api/src/models/deploy_app_request.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/deploy_app_response.rs b/crates/tower-api/src/models/deploy_app_response.rs index 5918af15..ec69374d 100644 --- a/crates/tower-api/src/models/deploy_app_response.rs +++ b/crates/tower-api/src/models/deploy_app_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_account_body.rs b/crates/tower-api/src/models/describe_account_body.rs index 9e78b0cd..3cdef8a6 100644 --- a/crates/tower-api/src/models/describe_account_body.rs +++ b/crates/tower-api/src/models/describe_account_body.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_app_response.rs b/crates/tower-api/src/models/describe_app_response.rs index 14e258e4..38666c9e 100644 --- a/crates/tower-api/src/models/describe_app_response.rs +++ b/crates/tower-api/src/models/describe_app_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_app_version_response.rs b/crates/tower-api/src/models/describe_app_version_response.rs index b088230a..3258f6fd 100644 --- a/crates/tower-api/src/models/describe_app_version_response.rs +++ b/crates/tower-api/src/models/describe_app_version_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_authentication_context_body.rs b/crates/tower-api/src/models/describe_authentication_context_body.rs index 3c6f4cf7..c55cdd00 100644 --- a/crates/tower-api/src/models/describe_authentication_context_body.rs +++ b/crates/tower-api/src/models/describe_authentication_context_body.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_catalog_fact_response.rs b/crates/tower-api/src/models/describe_catalog_fact_response.rs new file mode 100644 index 00000000..04cde236 --- /dev/null +++ b/crates/tower-api/src/models/describe_catalog_fact_response.rs @@ -0,0 +1,29 @@ +/* + * Tower API + * + * REST API to interact with Tower Services. + * + * The version of the OpenAPI document: v0.11.17 + * Contact: hello@tower.dev + * Generated by: https://openapi-generator.tech + */ +use crate::models; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, DefaultOnNull}; + +#[serde_as] +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct DescribeCatalogFactResponse { + /// A URL to the JSON Schema for this object. + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + pub schema: Option, + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "fact")] + pub fact: models::CatalogFact, +} + +impl DescribeCatalogFactResponse { + pub fn new(fact: models::CatalogFact) -> DescribeCatalogFactResponse { + DescribeCatalogFactResponse { schema: None, fact } + } +} diff --git a/crates/tower-api/src/models/describe_catalog_response.rs b/crates/tower-api/src/models/describe_catalog_response.rs index e6ef2cd8..bf1f98cf 100644 --- a/crates/tower-api/src/models/describe_catalog_response.rs +++ b/crates/tower-api/src/models/describe_catalog_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_device_login_session_response.rs b/crates/tower-api/src/models/describe_device_login_session_response.rs index 253bc4fe..5a6bbf0b 100644 --- a/crates/tower-api/src/models/describe_device_login_session_response.rs +++ b/crates/tower-api/src/models/describe_device_login_session_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_email_preferences_body.rs b/crates/tower-api/src/models/describe_email_preferences_body.rs index fe820ed9..befb4ee8 100644 --- a/crates/tower-api/src/models/describe_email_preferences_body.rs +++ b/crates/tower-api/src/models/describe_email_preferences_body.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_environment_response.rs b/crates/tower-api/src/models/describe_environment_response.rs index fc8478f3..1657cafb 100644 --- a/crates/tower-api/src/models/describe_environment_response.rs +++ b/crates/tower-api/src/models/describe_environment_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_plan_response.rs b/crates/tower-api/src/models/describe_plan_response.rs index 78d72ef0..5bf3b864 100644 --- a/crates/tower-api/src/models/describe_plan_response.rs +++ b/crates/tower-api/src/models/describe_plan_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_run_graph_response.rs b/crates/tower-api/src/models/describe_run_graph_response.rs index 15e947e4..1d21caf4 100644 --- a/crates/tower-api/src/models/describe_run_graph_response.rs +++ b/crates/tower-api/src/models/describe_run_graph_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_run_links.rs b/crates/tower-api/src/models/describe_run_links.rs index c3b0afd4..2cf98a00 100644 --- a/crates/tower-api/src/models/describe_run_links.rs +++ b/crates/tower-api/src/models/describe_run_links.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_run_logs_response.rs b/crates/tower-api/src/models/describe_run_logs_response.rs index 438199cb..da5664b9 100644 --- a/crates/tower-api/src/models/describe_run_logs_response.rs +++ b/crates/tower-api/src/models/describe_run_logs_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_run_response.rs b/crates/tower-api/src/models/describe_run_response.rs index 4e77add1..23d86c2d 100644 --- a/crates/tower-api/src/models/describe_run_response.rs +++ b/crates/tower-api/src/models/describe_run_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_secrets_key_response.rs b/crates/tower-api/src/models/describe_secrets_key_response.rs index bb6637c2..9090dba3 100644 --- a/crates/tower-api/src/models/describe_secrets_key_response.rs +++ b/crates/tower-api/src/models/describe_secrets_key_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_service_account_response.rs b/crates/tower-api/src/models/describe_service_account_response.rs index 0ffb8535..668a4127 100644 --- a/crates/tower-api/src/models/describe_service_account_response.rs +++ b/crates/tower-api/src/models/describe_service_account_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_session_response.rs b/crates/tower-api/src/models/describe_session_response.rs index d59ee872..33bc35b7 100644 --- a/crates/tower-api/src/models/describe_session_response.rs +++ b/crates/tower-api/src/models/describe_session_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_team_response.rs b/crates/tower-api/src/models/describe_team_response.rs index 0cc217f5..b4a5d58c 100644 --- a/crates/tower-api/src/models/describe_team_response.rs +++ b/crates/tower-api/src/models/describe_team_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_webhook_response.rs b/crates/tower-api/src/models/describe_webhook_response.rs index d001a97a..99277e52 100644 --- a/crates/tower-api/src/models/describe_webhook_response.rs +++ b/crates/tower-api/src/models/describe_webhook_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/describe_whoami_response.rs b/crates/tower-api/src/models/describe_whoami_response.rs index cd115003..9dae2606 100644 --- a/crates/tower-api/src/models/describe_whoami_response.rs +++ b/crates/tower-api/src/models/describe_whoami_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/email_subscriptions.rs b/crates/tower-api/src/models/email_subscriptions.rs index 1376571b..de02f82c 100644 --- a/crates/tower-api/src/models/email_subscriptions.rs +++ b/crates/tower-api/src/models/email_subscriptions.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/encrypted_catalog_property.rs b/crates/tower-api/src/models/encrypted_catalog_property.rs index 37b6e2ed..3c953566 100644 --- a/crates/tower-api/src/models/encrypted_catalog_property.rs +++ b/crates/tower-api/src/models/encrypted_catalog_property.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/environment.rs b/crates/tower-api/src/models/environment.rs index 76d8ee3a..a84b27ea 100644 --- a/crates/tower-api/src/models/environment.rs +++ b/crates/tower-api/src/models/environment.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/error_detail.rs b/crates/tower-api/src/models/error_detail.rs index 3f750453..d67f1289 100644 --- a/crates/tower-api/src/models/error_detail.rs +++ b/crates/tower-api/src/models/error_detail.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/error_model.rs b/crates/tower-api/src/models/error_model.rs index 1e3c29bb..32c65920 100644 --- a/crates/tower-api/src/models/error_model.rs +++ b/crates/tower-api/src/models/error_model.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/event_alert.rs b/crates/tower-api/src/models/event_alert.rs index 7289bb4d..423dde06 100644 --- a/crates/tower-api/src/models/event_alert.rs +++ b/crates/tower-api/src/models/event_alert.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/event_error.rs b/crates/tower-api/src/models/event_error.rs index cc41e8e2..ad65d975 100644 --- a/crates/tower-api/src/models/event_error.rs +++ b/crates/tower-api/src/models/event_error.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/event_log.rs b/crates/tower-api/src/models/event_log.rs index 12e718ab..7a0ca024 100644 --- a/crates/tower-api/src/models/event_log.rs +++ b/crates/tower-api/src/models/event_log.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/event_shouldertap.rs b/crates/tower-api/src/models/event_shouldertap.rs index b236d798..170626c5 100644 --- a/crates/tower-api/src/models/event_shouldertap.rs +++ b/crates/tower-api/src/models/event_shouldertap.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/event_warning.rs b/crates/tower-api/src/models/event_warning.rs index de0d3471..fc49ee89 100644 --- a/crates/tower-api/src/models/event_warning.rs +++ b/crates/tower-api/src/models/event_warning.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/export_catalogs_params.rs b/crates/tower-api/src/models/export_catalogs_params.rs index 84ccdd1d..413ccc54 100644 --- a/crates/tower-api/src/models/export_catalogs_params.rs +++ b/crates/tower-api/src/models/export_catalogs_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/export_catalogs_response.rs b/crates/tower-api/src/models/export_catalogs_response.rs index fe1502b5..16f13f08 100644 --- a/crates/tower-api/src/models/export_catalogs_response.rs +++ b/crates/tower-api/src/models/export_catalogs_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/export_secrets_params.rs b/crates/tower-api/src/models/export_secrets_params.rs index de5e9205..3b61f881 100644 --- a/crates/tower-api/src/models/export_secrets_params.rs +++ b/crates/tower-api/src/models/export_secrets_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/export_secrets_response.rs b/crates/tower-api/src/models/export_secrets_response.rs index b7bc20c1..95a898a1 100644 --- a/crates/tower-api/src/models/export_secrets_response.rs +++ b/crates/tower-api/src/models/export_secrets_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/exported_catalog.rs b/crates/tower-api/src/models/exported_catalog.rs index 796b183b..ec48600c 100644 --- a/crates/tower-api/src/models/exported_catalog.rs +++ b/crates/tower-api/src/models/exported_catalog.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/exported_catalog_property.rs b/crates/tower-api/src/models/exported_catalog_property.rs index c6602b40..ee03db06 100644 --- a/crates/tower-api/src/models/exported_catalog_property.rs +++ b/crates/tower-api/src/models/exported_catalog_property.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/exported_secret.rs b/crates/tower-api/src/models/exported_secret.rs index 5d8d5c88..d81128da 100644 --- a/crates/tower-api/src/models/exported_secret.rs +++ b/crates/tower-api/src/models/exported_secret.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/feature.rs b/crates/tower-api/src/models/feature.rs index 889933c5..a4b8ac57 100644 --- a/crates/tower-api/src/models/feature.rs +++ b/crates/tower-api/src/models/feature.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/featurebase_identity.rs b/crates/tower-api/src/models/featurebase_identity.rs index c60b490f..e5fbe8ad 100644 --- a/crates/tower-api/src/models/featurebase_identity.rs +++ b/crates/tower-api/src/models/featurebase_identity.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/generate_app_statistics_response.rs b/crates/tower-api/src/models/generate_app_statistics_response.rs index 19798eed..5d8b8db5 100644 --- a/crates/tower-api/src/models/generate_app_statistics_response.rs +++ b/crates/tower-api/src/models/generate_app_statistics_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/generate_organization_usage_time_series_response.rs b/crates/tower-api/src/models/generate_organization_usage_time_series_response.rs index 7a9175be..d6c64432 100644 --- a/crates/tower-api/src/models/generate_organization_usage_time_series_response.rs +++ b/crates/tower-api/src/models/generate_organization_usage_time_series_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/generate_run_statistics_response.rs b/crates/tower-api/src/models/generate_run_statistics_response.rs index 00b5c52c..1d1c46de 100644 --- a/crates/tower-api/src/models/generate_run_statistics_response.rs +++ b/crates/tower-api/src/models/generate_run_statistics_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/generate_runner_credentials_response.rs b/crates/tower-api/src/models/generate_runner_credentials_response.rs index a26c9e30..a39529f9 100644 --- a/crates/tower-api/src/models/generate_runner_credentials_response.rs +++ b/crates/tower-api/src/models/generate_runner_credentials_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/get_feature_flag_response_body.rs b/crates/tower-api/src/models/get_feature_flag_response_body.rs index 74aaf154..6e19cb36 100644 --- a/crates/tower-api/src/models/get_feature_flag_response_body.rs +++ b/crates/tower-api/src/models/get_feature_flag_response_body.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/guest.rs b/crates/tower-api/src/models/guest.rs index f7bdedc5..8ca0a8a7 100644 --- a/crates/tower-api/src/models/guest.rs +++ b/crates/tower-api/src/models/guest.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/invite_team_member_params.rs b/crates/tower-api/src/models/invite_team_member_params.rs index 347388e5..db6c84f9 100644 --- a/crates/tower-api/src/models/invite_team_member_params.rs +++ b/crates/tower-api/src/models/invite_team_member_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/invite_team_member_response.rs b/crates/tower-api/src/models/invite_team_member_response.rs index 6e900adc..4d0d6d17 100644 --- a/crates/tower-api/src/models/invite_team_member_response.rs +++ b/crates/tower-api/src/models/invite_team_member_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/leave_team_response.rs b/crates/tower-api/src/models/leave_team_response.rs index ad6cfa68..8b8856d4 100644 --- a/crates/tower-api/src/models/leave_team_response.rs +++ b/crates/tower-api/src/models/leave_team_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_alerts_response.rs b/crates/tower-api/src/models/list_alerts_response.rs index e392ad06..6af2496d 100644 --- a/crates/tower-api/src/models/list_alerts_response.rs +++ b/crates/tower-api/src/models/list_alerts_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_api_keys_response.rs b/crates/tower-api/src/models/list_api_keys_response.rs index 9fe80bfb..af4f1ba6 100644 --- a/crates/tower-api/src/models/list_api_keys_response.rs +++ b/crates/tower-api/src/models/list_api_keys_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_app_environments_response.rs b/crates/tower-api/src/models/list_app_environments_response.rs index c03cace2..9101caf8 100644 --- a/crates/tower-api/src/models/list_app_environments_response.rs +++ b/crates/tower-api/src/models/list_app_environments_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_app_versions_response.rs b/crates/tower-api/src/models/list_app_versions_response.rs index 9b4e3bd7..0ce2f3d4 100644 --- a/crates/tower-api/src/models/list_app_versions_response.rs +++ b/crates/tower-api/src/models/list_app_versions_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_apps_response.rs b/crates/tower-api/src/models/list_apps_response.rs index 5e53b323..622c25b4 100644 --- a/crates/tower-api/src/models/list_apps_response.rs +++ b/crates/tower-api/src/models/list_apps_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_catalog_facts_response.rs b/crates/tower-api/src/models/list_catalog_facts_response.rs new file mode 100644 index 00000000..41c21216 --- /dev/null +++ b/crates/tower-api/src/models/list_catalog_facts_response.rs @@ -0,0 +1,32 @@ +/* + * Tower API + * + * REST API to interact with Tower Services. + * + * The version of the OpenAPI document: v0.11.17 + * Contact: hello@tower.dev + * Generated by: https://openapi-generator.tech + */ +use crate::models; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, DefaultOnNull}; + +#[serde_as] +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ListCatalogFactsResponse { + /// A URL to the JSON Schema for this object. + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + pub schema: Option, + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "facts")] + pub facts: Vec, +} + +impl ListCatalogFactsResponse { + pub fn new(facts: Vec) -> ListCatalogFactsResponse { + ListCatalogFactsResponse { + schema: None, + facts, + } + } +} diff --git a/crates/tower-api/src/models/list_catalogs_response.rs b/crates/tower-api/src/models/list_catalogs_response.rs index 7cea77a4..c21d39c4 100644 --- a/crates/tower-api/src/models/list_catalogs_response.rs +++ b/crates/tower-api/src/models/list_catalogs_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_environments_response.rs b/crates/tower-api/src/models/list_environments_response.rs index 790e360a..e9f268fc 100644 --- a/crates/tower-api/src/models/list_environments_response.rs +++ b/crates/tower-api/src/models/list_environments_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_guests_response.rs b/crates/tower-api/src/models/list_guests_response.rs index c977c610..a0870acc 100644 --- a/crates/tower-api/src/models/list_guests_response.rs +++ b/crates/tower-api/src/models/list_guests_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_my_team_invitations_response.rs b/crates/tower-api/src/models/list_my_team_invitations_response.rs index a4728346..e4a43c25 100644 --- a/crates/tower-api/src/models/list_my_team_invitations_response.rs +++ b/crates/tower-api/src/models/list_my_team_invitations_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_runners_response.rs b/crates/tower-api/src/models/list_runners_response.rs index df28b50c..7aac8cf1 100644 --- a/crates/tower-api/src/models/list_runners_response.rs +++ b/crates/tower-api/src/models/list_runners_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_runs_response.rs b/crates/tower-api/src/models/list_runs_response.rs index 5f696f6c..f86ede42 100644 --- a/crates/tower-api/src/models/list_runs_response.rs +++ b/crates/tower-api/src/models/list_runs_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_schedules_response.rs b/crates/tower-api/src/models/list_schedules_response.rs index bad406fd..ded64720 100644 --- a/crates/tower-api/src/models/list_schedules_response.rs +++ b/crates/tower-api/src/models/list_schedules_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_secret_environments_response.rs b/crates/tower-api/src/models/list_secret_environments_response.rs index 15d64352..a2337e8d 100644 --- a/crates/tower-api/src/models/list_secret_environments_response.rs +++ b/crates/tower-api/src/models/list_secret_environments_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_secrets_response.rs b/crates/tower-api/src/models/list_secrets_response.rs index 66f040f7..a3eb54e3 100644 --- a/crates/tower-api/src/models/list_secrets_response.rs +++ b/crates/tower-api/src/models/list_secrets_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_service_account_api_keys_response.rs b/crates/tower-api/src/models/list_service_account_api_keys_response.rs index d09973bc..2df25684 100644 --- a/crates/tower-api/src/models/list_service_account_api_keys_response.rs +++ b/crates/tower-api/src/models/list_service_account_api_keys_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_service_accounts_response.rs b/crates/tower-api/src/models/list_service_accounts_response.rs index fe6f1a3b..87fa954d 100644 --- a/crates/tower-api/src/models/list_service_accounts_response.rs +++ b/crates/tower-api/src/models/list_service_accounts_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_team_invitations_response.rs b/crates/tower-api/src/models/list_team_invitations_response.rs index 838e8933..cae3cd39 100644 --- a/crates/tower-api/src/models/list_team_invitations_response.rs +++ b/crates/tower-api/src/models/list_team_invitations_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_team_members_response.rs b/crates/tower-api/src/models/list_team_members_response.rs index a73fe28d..231c2daa 100644 --- a/crates/tower-api/src/models/list_team_members_response.rs +++ b/crates/tower-api/src/models/list_team_members_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_teams_response.rs b/crates/tower-api/src/models/list_teams_response.rs index 55a4e01e..f4cd0cd6 100644 --- a/crates/tower-api/src/models/list_teams_response.rs +++ b/crates/tower-api/src/models/list_teams_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/list_webhooks_response.rs b/crates/tower-api/src/models/list_webhooks_response.rs index 23388d78..23bb637e 100644 --- a/crates/tower-api/src/models/list_webhooks_response.rs +++ b/crates/tower-api/src/models/list_webhooks_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/mod.rs b/crates/tower-api/src/models/mod.rs index df23e3d5..bdcad19e 100644 --- a/crates/tower-api/src/models/mod.rs +++ b/crates/tower-api/src/models/mod.rs @@ -16,6 +16,8 @@ pub mod app_statistics; pub use self::app_statistics::AppStatistics; pub mod app_summary; pub use self::app_summary::AppSummary; +pub mod app_tag; +pub use self::app_tag::AppTag; pub mod app_version; pub use self::app_version::AppVersion; pub mod authentication_context; @@ -30,6 +32,8 @@ pub mod catalog; pub use self::catalog::Catalog; pub mod catalog_credentials; pub use self::catalog_credentials::CatalogCredentials; +pub mod catalog_fact; +pub use self::catalog_fact::CatalogFact; pub mod catalog_property; pub use self::catalog_property::CatalogProperty; pub mod claim_device_login_ticket_params; @@ -142,6 +146,8 @@ pub mod describe_app_version_response; pub use self::describe_app_version_response::DescribeAppVersionResponse; pub mod describe_authentication_context_body; pub use self::describe_authentication_context_body::DescribeAuthenticationContextBody; +pub mod describe_catalog_fact_response; +pub use self::describe_catalog_fact_response::DescribeCatalogFactResponse; pub mod describe_catalog_response; pub use self::describe_catalog_response::DescribeCatalogResponse; pub mod describe_device_login_session_response; @@ -238,6 +244,8 @@ pub mod list_app_versions_response; pub use self::list_app_versions_response::ListAppVersionsResponse; pub mod list_apps_response; pub use self::list_apps_response::ListAppsResponse; +pub mod list_catalog_facts_response; +pub use self::list_catalog_facts_response::ListCatalogFactsResponse; pub mod list_catalogs_response; pub use self::list_catalogs_response::ListCatalogsResponse; pub mod list_environments_response; @@ -362,6 +370,8 @@ pub mod sse_warning; pub use self::sse_warning::SseWarning; pub mod statistics_settings; pub use self::statistics_settings::StatisticsSettings; +pub mod tag_filter; +pub use self::tag_filter::TagFilter; pub mod team; pub use self::team::Team; pub mod team_invitation; @@ -384,6 +394,10 @@ pub mod update_app_params; pub use self::update_app_params::UpdateAppParams; pub mod update_app_response; pub use self::update_app_response::UpdateAppResponse; +pub mod update_catalog_fact_body; +pub use self::update_catalog_fact_body::UpdateCatalogFactBody; +pub mod update_catalog_fact_response; +pub use self::update_catalog_fact_response::UpdateCatalogFactResponse; pub mod update_catalog_params; pub use self::update_catalog_params::UpdateCatalogParams; pub mod update_catalog_response; diff --git a/crates/tower-api/src/models/organization.rs b/crates/tower-api/src/models/organization.rs index 3de9b3d0..61247d6b 100644 --- a/crates/tower-api/src/models/organization.rs +++ b/crates/tower-api/src/models/organization.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/organization_usage.rs b/crates/tower-api/src/models/organization_usage.rs index 9eeed669..18026159 100644 --- a/crates/tower-api/src/models/organization_usage.rs +++ b/crates/tower-api/src/models/organization_usage.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/pagination.rs b/crates/tower-api/src/models/pagination.rs index 54715275..9cd2012f 100644 --- a/crates/tower-api/src/models/pagination.rs +++ b/crates/tower-api/src/models/pagination.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/parameter.rs b/crates/tower-api/src/models/parameter.rs index afa6ec60..0226331c 100644 --- a/crates/tower-api/src/models/parameter.rs +++ b/crates/tower-api/src/models/parameter.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/plan.rs b/crates/tower-api/src/models/plan.rs index 02ed2d1b..9daedc53 100644 --- a/crates/tower-api/src/models/plan.rs +++ b/crates/tower-api/src/models/plan.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/refresh_session_params.rs b/crates/tower-api/src/models/refresh_session_params.rs index 4982f6e9..21101fff 100644 --- a/crates/tower-api/src/models/refresh_session_params.rs +++ b/crates/tower-api/src/models/refresh_session_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/refresh_session_response.rs b/crates/tower-api/src/models/refresh_session_response.rs index b3240290..cc00dc96 100644 --- a/crates/tower-api/src/models/refresh_session_response.rs +++ b/crates/tower-api/src/models/refresh_session_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/regenerate_guest_login_url_params.rs b/crates/tower-api/src/models/regenerate_guest_login_url_params.rs index e1d7659b..a512b9c8 100644 --- a/crates/tower-api/src/models/regenerate_guest_login_url_params.rs +++ b/crates/tower-api/src/models/regenerate_guest_login_url_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/regenerate_guest_login_url_response.rs b/crates/tower-api/src/models/regenerate_guest_login_url_response.rs index 94edb91f..f0a97ee9 100644 --- a/crates/tower-api/src/models/regenerate_guest_login_url_response.rs +++ b/crates/tower-api/src/models/regenerate_guest_login_url_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/remove_team_member_params.rs b/crates/tower-api/src/models/remove_team_member_params.rs index 0be8c14d..96d83918 100644 --- a/crates/tower-api/src/models/remove_team_member_params.rs +++ b/crates/tower-api/src/models/remove_team_member_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/remove_team_member_response.rs b/crates/tower-api/src/models/remove_team_member_response.rs index d79905c7..a11492fe 100644 --- a/crates/tower-api/src/models/remove_team_member_response.rs +++ b/crates/tower-api/src/models/remove_team_member_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/resend_team_invitation_params.rs b/crates/tower-api/src/models/resend_team_invitation_params.rs index deb4b7e6..4691e655 100644 --- a/crates/tower-api/src/models/resend_team_invitation_params.rs +++ b/crates/tower-api/src/models/resend_team_invitation_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/resend_team_invitation_response.rs b/crates/tower-api/src/models/resend_team_invitation_response.rs index 10dd58ec..17313fc5 100644 --- a/crates/tower-api/src/models/resend_team_invitation_response.rs +++ b/crates/tower-api/src/models/resend_team_invitation_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run.rs b/crates/tower-api/src/models/run.rs index b516fa44..2e38e158 100644 --- a/crates/tower-api/src/models/run.rs +++ b/crates/tower-api/src/models/run.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_app_initiator_data.rs b/crates/tower-api/src/models/run_app_initiator_data.rs index 7ba43d7a..37ee9516 100644 --- a/crates/tower-api/src/models/run_app_initiator_data.rs +++ b/crates/tower-api/src/models/run_app_initiator_data.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_app_params.rs b/crates/tower-api/src/models/run_app_params.rs index 105f1498..9917741f 100644 --- a/crates/tower-api/src/models/run_app_params.rs +++ b/crates/tower-api/src/models/run_app_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_app_response.rs b/crates/tower-api/src/models/run_app_response.rs index ceb758b7..4deee56f 100644 --- a/crates/tower-api/src/models/run_app_response.rs +++ b/crates/tower-api/src/models/run_app_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_attempt.rs b/crates/tower-api/src/models/run_attempt.rs index 24f406b2..b1e88712 100644 --- a/crates/tower-api/src/models/run_attempt.rs +++ b/crates/tower-api/src/models/run_attempt.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_creator.rs b/crates/tower-api/src/models/run_creator.rs index 2a6359cc..6139b8cd 100644 --- a/crates/tower-api/src/models/run_creator.rs +++ b/crates/tower-api/src/models/run_creator.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_failure_alert.rs b/crates/tower-api/src/models/run_failure_alert.rs index 5c2ef780..340b228e 100644 --- a/crates/tower-api/src/models/run_failure_alert.rs +++ b/crates/tower-api/src/models/run_failure_alert.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_graph_node.rs b/crates/tower-api/src/models/run_graph_node.rs index dc0dc02c..2ccaafc9 100644 --- a/crates/tower-api/src/models/run_graph_node.rs +++ b/crates/tower-api/src/models/run_graph_node.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_graph_run_id.rs b/crates/tower-api/src/models/run_graph_run_id.rs index 5570a7f6..60a9dc3a 100644 --- a/crates/tower-api/src/models/run_graph_run_id.rs +++ b/crates/tower-api/src/models/run_graph_run_id.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_initiator.rs b/crates/tower-api/src/models/run_initiator.rs index b0fb7e90..c859adec 100644 --- a/crates/tower-api/src/models/run_initiator.rs +++ b/crates/tower-api/src/models/run_initiator.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_initiator_details.rs b/crates/tower-api/src/models/run_initiator_details.rs index 85d8f1cb..5787e17f 100644 --- a/crates/tower-api/src/models/run_initiator_details.rs +++ b/crates/tower-api/src/models/run_initiator_details.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_log_line.rs b/crates/tower-api/src/models/run_log_line.rs index 25210cb2..5f85622f 100644 --- a/crates/tower-api/src/models/run_log_line.rs +++ b/crates/tower-api/src/models/run_log_line.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_parameter.rs b/crates/tower-api/src/models/run_parameter.rs index 5b411ba1..92c5e901 100644 --- a/crates/tower-api/src/models/run_parameter.rs +++ b/crates/tower-api/src/models/run_parameter.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_results.rs b/crates/tower-api/src/models/run_results.rs index 4482a64f..bb2d2185 100644 --- a/crates/tower-api/src/models/run_results.rs +++ b/crates/tower-api/src/models/run_results.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_retry_policy.rs b/crates/tower-api/src/models/run_retry_policy.rs index 0a2bd848..fb9beaa9 100644 --- a/crates/tower-api/src/models/run_retry_policy.rs +++ b/crates/tower-api/src/models/run_retry_policy.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_run_initiator_details.rs b/crates/tower-api/src/models/run_run_initiator_details.rs index 14261608..8cc3e977 100644 --- a/crates/tower-api/src/models/run_run_initiator_details.rs +++ b/crates/tower-api/src/models/run_run_initiator_details.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_statistics.rs b/crates/tower-api/src/models/run_statistics.rs index 97081109..9e3daa52 100644 --- a/crates/tower-api/src/models/run_statistics.rs +++ b/crates/tower-api/src/models/run_statistics.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/run_timeseries_point.rs b/crates/tower-api/src/models/run_timeseries_point.rs index 0b6b9813..38cb0a6f 100644 --- a/crates/tower-api/src/models/run_timeseries_point.rs +++ b/crates/tower-api/src/models/run_timeseries_point.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/runner.rs b/crates/tower-api/src/models/runner.rs index cb1d78ff..d7d50001 100644 --- a/crates/tower-api/src/models/runner.rs +++ b/crates/tower-api/src/models/runner.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/runner_credentials.rs b/crates/tower-api/src/models/runner_credentials.rs index e88eb6a2..d6a356b5 100644 --- a/crates/tower-api/src/models/runner_credentials.rs +++ b/crates/tower-api/src/models/runner_credentials.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/schedule.rs b/crates/tower-api/src/models/schedule.rs index b3068d9e..bfe69267 100644 --- a/crates/tower-api/src/models/schedule.rs +++ b/crates/tower-api/src/models/schedule.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/schedule_owner.rs b/crates/tower-api/src/models/schedule_owner.rs index 021ef974..fa51d221 100644 --- a/crates/tower-api/src/models/schedule_owner.rs +++ b/crates/tower-api/src/models/schedule_owner.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/schedule_run_initiator_details.rs b/crates/tower-api/src/models/schedule_run_initiator_details.rs index 7f7a154e..31177899 100644 --- a/crates/tower-api/src/models/schedule_run_initiator_details.rs +++ b/crates/tower-api/src/models/schedule_run_initiator_details.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/search_runs_response.rs b/crates/tower-api/src/models/search_runs_response.rs index 4d3c49f5..46525224 100644 --- a/crates/tower-api/src/models/search_runs_response.rs +++ b/crates/tower-api/src/models/search_runs_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/secret.rs b/crates/tower-api/src/models/secret.rs index 31f571e1..875456c3 100644 --- a/crates/tower-api/src/models/secret.rs +++ b/crates/tower-api/src/models/secret.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/server_sent_events_inner.rs b/crates/tower-api/src/models/server_sent_events_inner.rs index 28899f2d..500613a8 100644 --- a/crates/tower-api/src/models/server_sent_events_inner.rs +++ b/crates/tower-api/src/models/server_sent_events_inner.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/server_sent_events_inner_1.rs b/crates/tower-api/src/models/server_sent_events_inner_1.rs index 90daa796..3a685e7c 100644 --- a/crates/tower-api/src/models/server_sent_events_inner_1.rs +++ b/crates/tower-api/src/models/server_sent_events_inner_1.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/server_sent_events_inner_2.rs b/crates/tower-api/src/models/server_sent_events_inner_2.rs index 21a9914f..acd3ee24 100644 --- a/crates/tower-api/src/models/server_sent_events_inner_2.rs +++ b/crates/tower-api/src/models/server_sent_events_inner_2.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/service_account.rs b/crates/tower-api/src/models/service_account.rs index 09ca2f8e..af725e1e 100644 --- a/crates/tower-api/src/models/service_account.rs +++ b/crates/tower-api/src/models/service_account.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/service_account_creator.rs b/crates/tower-api/src/models/service_account_creator.rs index e87eba0f..6b932206 100644 --- a/crates/tower-api/src/models/service_account_creator.rs +++ b/crates/tower-api/src/models/service_account_creator.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/session.rs b/crates/tower-api/src/models/session.rs index 48f7d769..e50b4821 100644 --- a/crates/tower-api/src/models/session.rs +++ b/crates/tower-api/src/models/session.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/shoulder_tap.rs b/crates/tower-api/src/models/shoulder_tap.rs index f42f14d1..a1402155 100644 --- a/crates/tower-api/src/models/shoulder_tap.rs +++ b/crates/tower-api/src/models/shoulder_tap.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/sse_warning.rs b/crates/tower-api/src/models/sse_warning.rs index 69ff569b..93724481 100644 --- a/crates/tower-api/src/models/sse_warning.rs +++ b/crates/tower-api/src/models/sse_warning.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/statistics_settings.rs b/crates/tower-api/src/models/statistics_settings.rs index 78c10fed..1b928471 100644 --- a/crates/tower-api/src/models/statistics_settings.rs +++ b/crates/tower-api/src/models/statistics_settings.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/tag_filter.rs b/crates/tower-api/src/models/tag_filter.rs new file mode 100644 index 00000000..f398dd19 --- /dev/null +++ b/crates/tower-api/src/models/tag_filter.rs @@ -0,0 +1,75 @@ +/* + * Tower API + * + * REST API to interact with Tower Services. + * + * The version of the OpenAPI document: v0.11.17 + * Contact: hello@tower.dev + * Generated by: https://openapi-generator.tech + */ +use crate::models; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, DefaultOnNull}; + +#[serde_as] +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct TagFilter { + /// The tag name to search. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "name")] + pub name: String, + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "op")] + pub op: Op, + /// Required if operator is eq + #[serde(rename = "value", skip_serializing_if = "Option::is_none")] + pub value: Option, + /// Required if operator is in or notIn + #[serde(rename = "values", skip_serializing_if = "Option::is_none")] + pub values: Option>, +} + +impl TagFilter { + pub fn new(name: String, op: Op) -> TagFilter { + TagFilter { + name, + op, + value: None, + values: None, + } + } +} +/// +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +pub enum Op { + #[serde(rename = "eq")] + Eq, + #[serde(rename = "in")] + In, + #[serde(rename = "notIn")] + NotIn, +} + +impl Default for Op { + fn default() -> Op { + Self::Eq + } +} + +impl<'de> Deserialize<'de> for Op { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "eq" => Ok(Self::Eq), + "in" => Ok(Self::In), + "notIn" => Ok(Self::NotIn), + _ => Err(serde::de::Error::unknown_variant( + &s, + &["eq", "in", "notIn"], + )), + } + } +} diff --git a/crates/tower-api/src/models/team.rs b/crates/tower-api/src/models/team.rs index bf1ea3d1..1a3378ae 100644 --- a/crates/tower-api/src/models/team.rs +++ b/crates/tower-api/src/models/team.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/team_invitation.rs b/crates/tower-api/src/models/team_invitation.rs index 50f3b889..cb2d6174 100644 --- a/crates/tower-api/src/models/team_invitation.rs +++ b/crates/tower-api/src/models/team_invitation.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/team_membership.rs b/crates/tower-api/src/models/team_membership.rs index 87df841b..54703232 100644 --- a/crates/tower-api/src/models/team_membership.rs +++ b/crates/tower-api/src/models/team_membership.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/test_webhook_response.rs b/crates/tower-api/src/models/test_webhook_response.rs index d3bb267d..fd43fd86 100644 --- a/crates/tower-api/src/models/test_webhook_response.rs +++ b/crates/tower-api/src/models/test_webhook_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/token.rs b/crates/tower-api/src/models/token.rs index 22d36299..e6619048 100644 --- a/crates/tower-api/src/models/token.rs +++ b/crates/tower-api/src/models/token.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_account_params.rs b/crates/tower-api/src/models/update_account_params.rs index b53d1373..531f2c4d 100644 --- a/crates/tower-api/src/models/update_account_params.rs +++ b/crates/tower-api/src/models/update_account_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ @@ -50,6 +50,8 @@ pub enum ExecutionRegion { UsEast1, #[serde(rename = "us-west-2")] UsWest2, + #[serde(rename = "eu-west-1")] + EuWest1, } impl Default for ExecutionRegion { @@ -68,9 +70,10 @@ impl<'de> Deserialize<'de> for ExecutionRegion { "eu-central-1" => Ok(Self::EuCentral1), "us-east-1" => Ok(Self::UsEast1), "us-west-2" => Ok(Self::UsWest2), + "eu-west-1" => Ok(Self::EuWest1), _ => Err(serde::de::Error::unknown_variant( &s, - &["eu-central-1", "us-east-1", "us-west-2"], + &["eu-central-1", "us-east-1", "us-west-2", "eu-west-1"], )), } } diff --git a/crates/tower-api/src/models/update_account_response.rs b/crates/tower-api/src/models/update_account_response.rs index 0c5c98e3..d1f93fa3 100644 --- a/crates/tower-api/src/models/update_account_response.rs +++ b/crates/tower-api/src/models/update_account_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_app_environment_params.rs b/crates/tower-api/src/models/update_app_environment_params.rs index b443cfe1..15576bad 100644 --- a/crates/tower-api/src/models/update_app_environment_params.rs +++ b/crates/tower-api/src/models/update_app_environment_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_app_environment_response.rs b/crates/tower-api/src/models/update_app_environment_response.rs index 87636db2..56f4df95 100644 --- a/crates/tower-api/src/models/update_app_environment_response.rs +++ b/crates/tower-api/src/models/update_app_environment_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_app_params.rs b/crates/tower-api/src/models/update_app_params.rs index 5dda3098..0097d767 100644 --- a/crates/tower-api/src/models/update_app_params.rs +++ b/crates/tower-api/src/models/update_app_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ @@ -17,7 +17,7 @@ pub struct UpdateAppParams { /// A URL to the JSON Schema for this object. #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] pub schema: Option, - /// New description for the App + /// Deprecated: use short_description instead. #[serde( rename = "description", default, @@ -52,6 +52,14 @@ pub struct UpdateAppParams { skip_serializing_if = "Option::is_none" )] pub running_timeout: Option>, + /// New description for the app. + #[serde( + rename = "short_description", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub short_description: Option>, /// New status for the App #[serde( rename = "status", @@ -68,6 +76,9 @@ pub struct UpdateAppParams { skip_serializing_if = "Option::is_none" )] pub subdomain: Option>, + /// The tags for this app. + #[serde(rename = "tags", skip_serializing_if = "Option::is_none")] + pub tags: Option>, } impl UpdateAppParams { @@ -79,8 +90,10 @@ impl UpdateAppParams { pending_timeout: None, retry_policy: None, running_timeout: None, + short_description: None, status: None, subdomain: None, + tags: None, } } } diff --git a/crates/tower-api/src/models/update_app_response.rs b/crates/tower-api/src/models/update_app_response.rs index 72d20b2e..b7cdab5f 100644 --- a/crates/tower-api/src/models/update_app_response.rs +++ b/crates/tower-api/src/models/update_app_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_catalog_fact_body.rs b/crates/tower-api/src/models/update_catalog_fact_body.rs new file mode 100644 index 00000000..70942a3b --- /dev/null +++ b/crates/tower-api/src/models/update_catalog_fact_body.rs @@ -0,0 +1,129 @@ +/* + * Tower API + * + * REST API to interact with Tower Services. + * + * The version of the OpenAPI document: v0.11.17 + * Contact: hello@tower.dev + * Generated by: https://openapi-generator.tech + */ +use crate::models; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, DefaultOnNull}; + +#[serde_as] +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UpdateCatalogFactBody { + /// A URL to the JSON Schema for this object. + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + pub schema: Option, + /// Optional structured payload (SQL, unit, enum values) as a JSON string. + #[serde(rename = "body", skip_serializing_if = "Option::is_none")] + pub body: Option, + /// How trustworthy the fact is. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "confidence")] + pub confidence: Confidence, + /// Descriptive path to what the fact is about, e.g. \"bronze.runs.deleted_at\". Empty for catalog-scoped facts. + #[serde(rename = "object", skip_serializing_if = "Option::is_none")] + pub object: Option, + /// What kind of object the fact is about. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "scope")] + pub scope: Scope, + /// Where the fact came from (agent id, user, ...). + #[serde(rename = "source", skip_serializing_if = "Option::is_none")] + pub source: Option, + /// The human-readable meaning of the fact. + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "statement")] + pub statement: String, +} + +impl UpdateCatalogFactBody { + pub fn new(confidence: Confidence, scope: Scope, statement: String) -> UpdateCatalogFactBody { + UpdateCatalogFactBody { + schema: None, + body: None, + confidence, + object: None, + scope, + source: None, + statement, + } + } +} +/// How trustworthy the fact is. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +pub enum Confidence { + #[serde(rename = "confirmed")] + Confirmed, + #[serde(rename = "heuristic")] + Heuristic, + #[serde(rename = "inferred")] + Inferred, +} + +impl Default for Confidence { + fn default() -> Confidence { + Self::Confirmed + } +} + +impl<'de> Deserialize<'de> for Confidence { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "confirmed" => Ok(Self::Confirmed), + "heuristic" => Ok(Self::Heuristic), + "inferred" => Ok(Self::Inferred), + _ => Err(serde::de::Error::unknown_variant( + &s, + &["confirmed", "heuristic", "inferred"], + )), + } + } +} +/// What kind of object the fact is about. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +pub enum Scope { + #[serde(rename = "catalog")] + Catalog, + #[serde(rename = "namespace")] + Namespace, + #[serde(rename = "table")] + Table, + #[serde(rename = "column")] + Column, + #[serde(rename = "metric")] + Metric, +} + +impl Default for Scope { + fn default() -> Scope { + Self::Catalog + } +} + +impl<'de> Deserialize<'de> for Scope { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.to_lowercase().as_str() { + "catalog" => Ok(Self::Catalog), + "namespace" => Ok(Self::Namespace), + "table" => Ok(Self::Table), + "column" => Ok(Self::Column), + "metric" => Ok(Self::Metric), + _ => Err(serde::de::Error::unknown_variant( + &s, + &["catalog", "namespace", "table", "column", "metric"], + )), + } + } +} diff --git a/crates/tower-api/src/models/update_catalog_fact_response.rs b/crates/tower-api/src/models/update_catalog_fact_response.rs new file mode 100644 index 00000000..a880eafd --- /dev/null +++ b/crates/tower-api/src/models/update_catalog_fact_response.rs @@ -0,0 +1,29 @@ +/* + * Tower API + * + * REST API to interact with Tower Services. + * + * The version of the OpenAPI document: v0.11.17 + * Contact: hello@tower.dev + * Generated by: https://openapi-generator.tech + */ +use crate::models; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_with::{serde_as, DefaultOnNull}; + +#[serde_as] +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UpdateCatalogFactResponse { + /// A URL to the JSON Schema for this object. + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + pub schema: Option, + #[serde_as(as = "DefaultOnNull")] + #[serde(rename = "fact")] + pub fact: models::CatalogFact, +} + +impl UpdateCatalogFactResponse { + pub fn new(fact: models::CatalogFact) -> UpdateCatalogFactResponse { + UpdateCatalogFactResponse { schema: None, fact } + } +} diff --git a/crates/tower-api/src/models/update_catalog_params.rs b/crates/tower-api/src/models/update_catalog_params.rs index 253f762c..98c75a2c 100644 --- a/crates/tower-api/src/models/update_catalog_params.rs +++ b/crates/tower-api/src/models/update_catalog_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_catalog_response.rs b/crates/tower-api/src/models/update_catalog_response.rs index 0e0a354f..012a5405 100644 --- a/crates/tower-api/src/models/update_catalog_response.rs +++ b/crates/tower-api/src/models/update_catalog_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_email_preferences_body.rs b/crates/tower-api/src/models/update_email_preferences_body.rs index a37dc305..0110b72d 100644 --- a/crates/tower-api/src/models/update_email_preferences_body.rs +++ b/crates/tower-api/src/models/update_email_preferences_body.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_environment_params.rs b/crates/tower-api/src/models/update_environment_params.rs index 1406bad2..3dbfaab2 100644 --- a/crates/tower-api/src/models/update_environment_params.rs +++ b/crates/tower-api/src/models/update_environment_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_environment_response.rs b/crates/tower-api/src/models/update_environment_response.rs index 8f4c546a..77acbdee 100644 --- a/crates/tower-api/src/models/update_environment_response.rs +++ b/crates/tower-api/src/models/update_environment_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_my_team_invitation_params.rs b/crates/tower-api/src/models/update_my_team_invitation_params.rs index 704e34c8..4d410c0e 100644 --- a/crates/tower-api/src/models/update_my_team_invitation_params.rs +++ b/crates/tower-api/src/models/update_my_team_invitation_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_my_team_invitation_response.rs b/crates/tower-api/src/models/update_my_team_invitation_response.rs index 984f765d..7b89b844 100644 --- a/crates/tower-api/src/models/update_my_team_invitation_response.rs +++ b/crates/tower-api/src/models/update_my_team_invitation_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_organization_params.rs b/crates/tower-api/src/models/update_organization_params.rs index 5f9dfc8a..a7928d5b 100644 --- a/crates/tower-api/src/models/update_organization_params.rs +++ b/crates/tower-api/src/models/update_organization_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_organization_response.rs b/crates/tower-api/src/models/update_organization_response.rs index 835b1e85..8d3b9342 100644 --- a/crates/tower-api/src/models/update_organization_response.rs +++ b/crates/tower-api/src/models/update_organization_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_plan_params.rs b/crates/tower-api/src/models/update_plan_params.rs index 1a59564d..8d5453c6 100644 --- a/crates/tower-api/src/models/update_plan_params.rs +++ b/crates/tower-api/src/models/update_plan_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_plan_response.rs b/crates/tower-api/src/models/update_plan_response.rs index 5fc6a07c..ac46aa70 100644 --- a/crates/tower-api/src/models/update_plan_response.rs +++ b/crates/tower-api/src/models/update_plan_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_schedule_params.rs b/crates/tower-api/src/models/update_schedule_params.rs index e3ba4d2a..353fee57 100644 --- a/crates/tower-api/src/models/update_schedule_params.rs +++ b/crates/tower-api/src/models/update_schedule_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_schedule_response.rs b/crates/tower-api/src/models/update_schedule_response.rs index cf991e41..25c07220 100644 --- a/crates/tower-api/src/models/update_schedule_response.rs +++ b/crates/tower-api/src/models/update_schedule_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_secret_params.rs b/crates/tower-api/src/models/update_secret_params.rs index 35d0a4f4..24022a3a 100644 --- a/crates/tower-api/src/models/update_secret_params.rs +++ b/crates/tower-api/src/models/update_secret_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_secret_response.rs b/crates/tower-api/src/models/update_secret_response.rs index 2dcc7fca..6fe7b48c 100644 --- a/crates/tower-api/src/models/update_secret_response.rs +++ b/crates/tower-api/src/models/update_secret_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_service_account_params.rs b/crates/tower-api/src/models/update_service_account_params.rs index 56ca33f9..e6a748ee 100644 --- a/crates/tower-api/src/models/update_service_account_params.rs +++ b/crates/tower-api/src/models/update_service_account_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_service_account_response.rs b/crates/tower-api/src/models/update_service_account_response.rs index 4102c0da..2ae9c535 100644 --- a/crates/tower-api/src/models/update_service_account_response.rs +++ b/crates/tower-api/src/models/update_service_account_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_team_member_params.rs b/crates/tower-api/src/models/update_team_member_params.rs index 90f25229..bec1cf7e 100644 --- a/crates/tower-api/src/models/update_team_member_params.rs +++ b/crates/tower-api/src/models/update_team_member_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_team_member_response.rs b/crates/tower-api/src/models/update_team_member_response.rs index 81a883b6..c8afc1b3 100644 --- a/crates/tower-api/src/models/update_team_member_response.rs +++ b/crates/tower-api/src/models/update_team_member_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_team_params.rs b/crates/tower-api/src/models/update_team_params.rs index 1cdd488e..9a91486e 100644 --- a/crates/tower-api/src/models/update_team_params.rs +++ b/crates/tower-api/src/models/update_team_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ @@ -43,6 +43,8 @@ pub enum ExecutionRegion { UsEast1, #[serde(rename = "us-west-2")] UsWest2, + #[serde(rename = "eu-west-1")] + EuWest1, } impl Default for ExecutionRegion { @@ -61,9 +63,10 @@ impl<'de> Deserialize<'de> for ExecutionRegion { "eu-central-1" => Ok(Self::EuCentral1), "us-east-1" => Ok(Self::UsEast1), "us-west-2" => Ok(Self::UsWest2), + "eu-west-1" => Ok(Self::EuWest1), _ => Err(serde::de::Error::unknown_variant( &s, - &["eu-central-1", "us-east-1", "us-west-2"], + &["eu-central-1", "us-east-1", "us-west-2", "eu-west-1"], )), } } diff --git a/crates/tower-api/src/models/update_team_response.rs b/crates/tower-api/src/models/update_team_response.rs index de2fd4ed..1d3606a8 100644 --- a/crates/tower-api/src/models/update_team_response.rs +++ b/crates/tower-api/src/models/update_team_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_user_params.rs b/crates/tower-api/src/models/update_user_params.rs index 66bf1d7d..2f2c68e1 100644 --- a/crates/tower-api/src/models/update_user_params.rs +++ b/crates/tower-api/src/models/update_user_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_user_response.rs b/crates/tower-api/src/models/update_user_response.rs index 7748e56e..051691c7 100644 --- a/crates/tower-api/src/models/update_user_response.rs +++ b/crates/tower-api/src/models/update_user_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_webhook_params.rs b/crates/tower-api/src/models/update_webhook_params.rs index 56457448..1bb54639 100644 --- a/crates/tower-api/src/models/update_webhook_params.rs +++ b/crates/tower-api/src/models/update_webhook_params.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/update_webhook_response.rs b/crates/tower-api/src/models/update_webhook_response.rs index 5495cce1..1a75fd49 100644 --- a/crates/tower-api/src/models/update_webhook_response.rs +++ b/crates/tower-api/src/models/update_webhook_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/usage_limit.rs b/crates/tower-api/src/models/usage_limit.rs index 3a9ec254..9726cf29 100644 --- a/crates/tower-api/src/models/usage_limit.rs +++ b/crates/tower-api/src/models/usage_limit.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/usage_metric_time_series_point.rs b/crates/tower-api/src/models/usage_metric_time_series_point.rs index e23c2210..6c4bdef2 100644 --- a/crates/tower-api/src/models/usage_metric_time_series_point.rs +++ b/crates/tower-api/src/models/usage_metric_time_series_point.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/user.rs b/crates/tower-api/src/models/user.rs index 219f1814..e96c7257 100644 --- a/crates/tower-api/src/models/user.rs +++ b/crates/tower-api/src/models/user.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/vend_catalog_credentials_body.rs b/crates/tower-api/src/models/vend_catalog_credentials_body.rs index 0848bd39..6c54274a 100644 --- a/crates/tower-api/src/models/vend_catalog_credentials_body.rs +++ b/crates/tower-api/src/models/vend_catalog_credentials_body.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/vend_catalog_credentials_response.rs b/crates/tower-api/src/models/vend_catalog_credentials_response.rs index c2a8bdaf..461dd693 100644 --- a/crates/tower-api/src/models/vend_catalog_credentials_response.rs +++ b/crates/tower-api/src/models/vend_catalog_credentials_response.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-api/src/models/webhook.rs b/crates/tower-api/src/models/webhook.rs index 88690ab8..3f6031bd 100644 --- a/crates/tower-api/src/models/webhook.rs +++ b/crates/tower-api/src/models/webhook.rs @@ -3,7 +3,7 @@ * * REST API to interact with Tower Services. * - * The version of the OpenAPI document: v0.11.12 + * The version of the OpenAPI document: v0.11.17 * Contact: hello@tower.dev * Generated by: https://openapi-generator.tech */ diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index ae6a3507..a76fc97f 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -179,6 +179,7 @@ pub async fn list_apps( num_runs: Some(0), sort: None, filter: None, + tag_filter: None, environment: environment.clone(), }; unwrap_api_response(tower_api::apis::default_api::list_apps(api_config, params)).await @@ -204,6 +205,10 @@ pub async fn create_app( slug: None, is_externally_accessible: None, subdomain: None, + pending_timeout: None, + retry_policy: None, + running_timeout: None, + tags: None, }, }; @@ -259,6 +264,8 @@ pub async fn describe_run_logs( name: name.to_string(), seq, start_at: None, + head: None, + tail: None, }; unwrap_api_response(tower_api::apis::default_api::describe_run_logs( @@ -426,6 +433,106 @@ pub async fn describe_catalog( .await } +/// The CLI surface calls these "knowledge", but the backend and its generated +/// client still call them catalog "facts" — hence the `..._fact`/`CatalogFact` +/// names threaded through these wrappers. +pub async fn list_catalog_knowledge( + config: &Config, + catalog: &str, + env: &str, + scope: Option<&str>, + object: Option<&str>, +) -> Result< + tower_api::models::ListCatalogFactsResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::ListCatalogFactsParams { + catalog: catalog.to_string(), + environment: Some(env.to_string()), + scope: scope.map(str::to_string), + object: object.map(str::to_string), + }; + + unwrap_api_response(tower_api::apis::default_api::list_catalog_facts( + api_config, params, + )) + .await +} + +pub async fn describe_catalog_knowledge( + config: &Config, + catalog: &str, + name: &str, + env: &str, +) -> Result< + tower_api::models::DescribeCatalogFactResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::DescribeCatalogFactParams { + catalog: catalog.to_string(), + name: name.to_string(), + environment: Some(env.to_string()), + }; + + unwrap_api_response(tower_api::apis::default_api::describe_catalog_fact( + api_config, params, + )) + .await +} + +/// Upserts a piece of catalog knowledge: the server creates it under `name` +/// when it doesn't exist yet and replaces it otherwise. +pub async fn update_catalog_knowledge( + config: &Config, + catalog: &str, + name: &str, + env: &str, + body: tower_api::models::UpdateCatalogFactBody, +) -> Result< + tower_api::models::UpdateCatalogFactResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::UpdateCatalogFactParams { + catalog: catalog.to_string(), + name: name.to_string(), + environment: Some(env.to_string()), + update_catalog_fact_body: body, + }; + + unwrap_api_response(tower_api::apis::default_api::update_catalog_fact( + api_config, params, + )) + .await +} + +/// Deletes a piece of catalog knowledge. The endpoint responds 204 with no +/// body, which `unwrap_api_response` would treat as an error, so success maps +/// to `()` directly; the generated client already surfaces 4xx/5xx as `Err`. +pub async fn delete_catalog_knowledge( + config: &Config, + catalog: &str, + name: &str, + env: &str, +) -> Result<(), Error> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::DeleteCatalogFactParams { + catalog: catalog.to_string(), + name: name.to_string(), + environment: Some(env.to_string()), + }; + + tower_api::apis::default_api::delete_catalog_fact(api_config, params) + .await + .map(|_| ()) +} + pub async fn list_secrets( config: &Config, env: &str, @@ -900,6 +1007,39 @@ impl ResponseEntity for tower_api::apis::default_api::DescribeCatalogSuccess { } } +impl ResponseEntity for tower_api::apis::default_api::ListCatalogFactsSuccess { + type Data = tower_api::models::ListCatalogFactsResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + +impl ResponseEntity for tower_api::apis::default_api::DescribeCatalogFactSuccess { + type Data = tower_api::models::DescribeCatalogFactResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + +impl ResponseEntity for tower_api::apis::default_api::UpdateCatalogFactSuccess { + type Data = tower_api::models::UpdateCatalogFactResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + impl ResponseEntity for tower_api::apis::default_api::VendCatalogCredentialsSuccess { type Data = tower_api::models::VendCatalogCredentialsResponse; diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index b0bd8799..cebc682a 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -5,7 +5,8 @@ use futures_util::StreamExt; use std::io::{IsTerminal, Read}; use std::time::{Duration, Instant}; use tower_api::models::{ - vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse, + catalog_fact, update_catalog_fact_body, vend_catalog_credentials_body, CatalogCredentials, + CatalogFact, DescribeCatalogResponse, UpdateCatalogFactBody, }; use tower_duckdb::{guard, params, run_query, Hardening, Limits, QueryResult, Session}; use tower_telemetry::debug; @@ -127,6 +128,7 @@ pub fn catalogs_cmd() -> Command { .short_about("Vend short-lived catalog credentials for external tools"), ), ) + .subcommand(knowledge_cmd()) .subcommand( Command::new("query") .arg( @@ -1324,6 +1326,344 @@ fn snippets( snippets } +/// The values `--scope` accepts, mirroring `catalog_fact::Scope`. +const KNOWLEDGE_SCOPES: [&str; 5] = ["catalog", "namespace", "table", "column", "metric"]; + +/// The values `--confidence` accepts, mirroring `catalog_fact::Confidence`. +const KNOWLEDGE_CONFIDENCES: [&str; 3] = ["confirmed", "heuristic", "inferred"]; + +fn knowledge_cmd() -> Command { + let catalog_arg = Arg::new("catalog_name") + .value_parser(value_parser!(String)) + .index(1) + .required(true) + .help("Name of the catalog"); + let name_arg = Arg::new("name") + .value_parser(value_parser!(String)) + .index(2) + .required(true) + .help("Name of the knowledge entry"); + let environment_arg = Arg::new("environment") + .short('e') + .long("environment") + .default_value("default") + .value_parser(value_parser!(String)) + .help("Environment the catalog belongs to") + .action(ArgAction::Set); + + Command::new("knowledge") + .about(beta::STORAGE.short_about( + "Store and retrieve knowledge about the semantics of the data in a catalog", + )) + .after_help( + "Knowledge lets agents (and people) record context about a catalog's data — \ + semantics, ontology, conventions — scoped to the catalog itself or to a \ + namespace, table, column, or metric within it.", + ) + .arg_required_else_help(true) + .subcommand( + Command::new("list") + .arg(catalog_arg.clone()) + .arg(environment_arg.clone()) + .arg( + Arg::new("scope") + .long("scope") + .value_parser(KNOWLEDGE_SCOPES) + .help("Only list knowledge with this scope") + .action(ArgAction::Set), + ) + .arg( + Arg::new("object") + .long("object") + .value_parser(value_parser!(String)) + .help("Only list knowledge about this object path, e.g. bronze.runs.deleted_at") + .action(ArgAction::Set), + ) + .about("List the knowledge recorded for a catalog"), + ) + .subcommand( + Command::new("show") + .arg(catalog_arg.clone()) + .arg(name_arg.clone()) + .arg(environment_arg.clone()) + .about("Show the full details of a knowledge entry, including its body"), + ) + .subcommand( + Command::new("set") + .arg(catalog_arg.clone()) + .arg(name_arg.clone()) + .arg(environment_arg.clone()) + .arg( + Arg::new("statement") + .long("statement") + .value_parser(value_parser!(String)) + .required(true) + .help("The human-readable meaning of the entry") + .action(ArgAction::Set), + ) + .arg( + Arg::new("scope") + .long("scope") + .default_value("catalog") + .value_parser(KNOWLEDGE_SCOPES) + .help("What kind of object the entry is about") + .action(ArgAction::Set), + ) + .arg( + Arg::new("object") + .long("object") + .value_parser(value_parser!(String)) + .help("Path to what the entry is about, e.g. bronze.runs.deleted_at; omit for catalog-scoped knowledge") + .action(ArgAction::Set), + ) + .arg( + Arg::new("confidence") + .long("confidence") + .default_value("confirmed") + .value_parser(KNOWLEDGE_CONFIDENCES) + .help("How trustworthy the entry is") + .action(ArgAction::Set), + ) + .arg( + Arg::new("source") + .long("source") + .value_parser(value_parser!(String)) + .help("Where the knowledge came from (agent id, user, ...)") + .action(ArgAction::Set), + ) + .arg( + Arg::new("body") + .long("body") + .value_parser(value_parser!(String)) + .help("Optional structured payload (SQL, unit, enum values) as a JSON string") + .action(ArgAction::Set), + ) + .about("Create a knowledge entry, or replace it if one with the same name exists"), + ) + .subcommand( + Command::new("delete") + .arg(catalog_arg) + .arg(name_arg) + .arg(environment_arg) + .about("Delete a knowledge entry from a catalog"), + ) +} + +pub async fn do_knowledge_list(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); + + let catalog = args + .get_one::("catalog_name") + .expect("catalog_name is required"); + let env = cmd::get_string_flag(args, "environment"); + let scope = args.get_one::("scope").map(String::as_str); + let object = args.get_one::("object").map(String::as_str); + + let response = out + .with_spinner( + "Listing knowledge", + api::list_catalog_knowledge(&config, catalog, &env, scope, object), + ) + .await; + + let headers = vec!["Name", "Scope", "Object", "Confidence", "Statement"] + .into_iter() + .map(str::to_string) + .collect(); + let data = response + .facts + .iter() + .map(|entry| { + vec![ + entry.name.clone(), + knowledge_scope_str(entry.scope).to_string(), + entry.object.clone(), + knowledge_confidence_str(entry.confidence).to_string(), + truncate_statement(&entry.statement, 80), + ] + }) + .collect(); + out.table(headers, data, Some(&response.facts)); +} + +pub async fn do_knowledge_show(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); + + let catalog = args + .get_one::("catalog_name") + .expect("catalog_name is required"); + let name = args + .get_one::("name") + .expect("name is required"); + let env = cmd::get_string_flag(args, "environment"); + + let response = out + .with_spinner( + "Fetching knowledge", + api::describe_catalog_knowledge(&config, catalog, name, &env), + ) + .await; + + let human = knowledge_details_text(catalog, &env, &response.fact); + out.text(&human, &response); +} + +pub async fn do_knowledge_set(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); + + let catalog = args + .get_one::("catalog_name") + .expect("catalog_name is required"); + let name = args + .get_one::("name") + .expect("name is required"); + let env = cmd::get_string_flag(args, "environment"); + let statement = cmd::get_string_flag(args, "statement"); + let scope = cmd::get_string_flag(args, "scope"); + let confidence = cmd::get_string_flag(args, "confidence"); + let object = args.get_one::("object").cloned(); + let source = args.get_one::("source").cloned(); + let body = args.get_one::("body").cloned(); + + // The API carries the body as a JSON string; catch malformed JSON here so + // the error names the flag instead of surfacing as a server-side 400. + if let Some(body) = &body { + if let Err(err) = serde_json::from_str::(body) { + out.die(&format!("--body is not valid JSON: {}", err)); + } + } + + let knowledge_body = UpdateCatalogFactBody { + schema: None, + body, + confidence: parse_knowledge_confidence(&confidence), + object, + scope: parse_knowledge_scope(&scope), + source, + statement, + }; + + let response = out + .with_spinner( + "Saving knowledge", + api::update_catalog_knowledge(&config, catalog, name, &env, knowledge_body), + ) + .await; + + out.success_with_data( + &format!("Knowledge '{}' saved in catalog '{}'", name, catalog), + Some(&response), + ); +} + +pub async fn do_knowledge_delete(out: &output::Out, config: Config, args: &ArgMatches) { + beta::notify_once(out, &beta::STORAGE); + + let catalog = args + .get_one::("catalog_name") + .expect("catalog_name is required"); + let name = args + .get_one::("name") + .expect("name is required"); + let env = cmd::get_string_flag(args, "environment"); + + out.with_spinner( + "Deleting knowledge", + api::delete_catalog_knowledge(&config, catalog, name, &env), + ) + .await; + + out.success(&format!( + "Knowledge '{}' deleted from catalog '{}'", + name, catalog + )); +} + +fn knowledge_scope_str(scope: catalog_fact::Scope) -> &'static str { + match scope { + catalog_fact::Scope::Catalog => "catalog", + catalog_fact::Scope::Namespace => "namespace", + catalog_fact::Scope::Table => "table", + catalog_fact::Scope::Column => "column", + catalog_fact::Scope::Metric => "metric", + } +} + +fn knowledge_confidence_str(confidence: catalog_fact::Confidence) -> &'static str { + match confidence { + catalog_fact::Confidence::Confirmed => "confirmed", + catalog_fact::Confidence::Heuristic => "heuristic", + catalog_fact::Confidence::Inferred => "inferred", + } +} + +fn parse_knowledge_scope(scope: &str) -> update_catalog_fact_body::Scope { + match scope { + "namespace" => update_catalog_fact_body::Scope::Namespace, + "table" => update_catalog_fact_body::Scope::Table, + "column" => update_catalog_fact_body::Scope::Column, + "metric" => update_catalog_fact_body::Scope::Metric, + _ => update_catalog_fact_body::Scope::Catalog, + } +} + +fn parse_knowledge_confidence(confidence: &str) -> update_catalog_fact_body::Confidence { + match confidence { + "heuristic" => update_catalog_fact_body::Confidence::Heuristic, + "inferred" => update_catalog_fact_body::Confidence::Inferred, + _ => update_catalog_fact_body::Confidence::Confirmed, + } +} + +/// Trims a statement to fit a table cell, on a char boundary, marking the cut +/// with an ellipsis. The full statement is always in the JSON output. +fn truncate_statement(statement: &str, max_chars: usize) -> String { + if statement.chars().count() <= max_chars { + statement.to_string() + } else { + let truncated: String = statement + .chars() + .take(max_chars.saturating_sub(1)) + .collect(); + format!("{}…", truncated) + } +} + +fn knowledge_details_text(catalog: &str, env: &str, entry: &CatalogFact) -> String { + let mut out = String::new(); + + out.push_str(&detail_line("Name", &entry.name)); + out.push_str(&detail_line("Catalog", catalog)); + out.push_str(&detail_line("Environment", env)); + out.push_str(&detail_line("Scope", knowledge_scope_str(entry.scope))); + if !entry.object.is_empty() { + out.push_str(&detail_line("Object", &entry.object)); + } + out.push_str(&detail_line( + "Confidence", + knowledge_confidence_str(entry.confidence), + )); + if let Some(source) = entry.source.as_deref().filter(|s| !s.is_empty()) { + out.push_str(&detail_line("Source", source)); + } + out.push_str(&detail_line("Created", &entry.created_at)); + out.push_str(&detail_line("Updated", &entry.updated_at)); + + out.push('\n'); + out.push_str(&header_line("Statement")); + out.push_str(&entry.statement); + out.push('\n'); + + if let Some(body) = entry.body.as_ref().and_then(|b| b.as_ref()) { + out.push('\n'); + out.push_str(&header_line("Body")); + out.push_str(&serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())); + out.push('\n'); + } + + out +} + #[cfg(test)] mod tests { use super::{ @@ -1837,4 +2177,257 @@ mod tests { } } } + + #[test] + fn knowledge_command_is_marked_beta() { + let mut command = catalogs_cmd(); + let knowledge_help = command + .find_subcommand_mut("knowledge") + .expect("knowledge command should exist") + .render_help() + .to_string(); + assert!(knowledge_help.contains("[beta]")); + } + + #[test] + fn knowledge_delete_accepts_catalog_and_name() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "knowledge", "delete", "my-catalog", "my-entry"]) + .expect("knowledge delete should parse"); + + let (_, knowledge_args) = matches.subcommand().expect("expected knowledge subcommand"); + let (_, delete_args) = knowledge_args.subcommand().expect("expected delete subcommand"); + assert_eq!( + delete_args.get_one::("catalog_name").unwrap(), + "my-catalog" + ); + assert_eq!( + delete_args.get_one::("name").unwrap(), + "my-entry" + ); + } + + #[test] + fn knowledge_details_text_includes_body_and_optional_fields() { + use tower_api::models::{catalog_fact, CatalogFact}; + + let mut entry = CatalogFact::new( + catalog_fact::Confidence::Inferred, + "2026-07-22T00:00:00Z".to_string(), + "soft-deletes".to_string(), + "bronze.runs.deleted_at".to_string(), + catalog_fact::Scope::Column, + "deleted_at marks soft-deleted rows".to_string(), + "2026-07-22T01:00:00Z".to_string(), + ); + entry.source = Some("agent-42".to_string()); + entry.body = Some(Some(serde_json::json!({"sql": "deleted_at IS NULL"}))); + + let text = super::knowledge_details_text("my-catalog", "production", &entry); + + assert!(text.contains("soft-deletes")); + assert!(text.contains("my-catalog")); + assert!(text.contains("production")); + assert!(text.contains("column")); + assert!(text.contains("bronze.runs.deleted_at")); + assert!(text.contains("inferred")); + assert!(text.contains("agent-42")); + assert!(text.contains("deleted_at marks soft-deleted rows")); + assert!(text.contains("deleted_at IS NULL")); + + // Optional fields drop out when absent. + entry.source = None; + entry.body = None; + entry.object = String::new(); + let text = super::knowledge_details_text("my-catalog", "production", &entry); + assert!(!text.contains("Source")); + assert!(!text.contains("Body")); + assert!(!text.contains("Object")); + } + + #[test] + fn knowledge_list_accepts_filters() { + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "knowledge", + "list", + "my-catalog", + "--scope", + "table", + "--object", + "bronze.runs", + "-e", + "production", + ]) + .expect("knowledge list should parse"); + + let (_, knowledge_args) = matches.subcommand().expect("expected knowledge subcommand"); + let (_, list_args) = knowledge_args.subcommand().expect("expected list subcommand"); + + assert_eq!( + list_args.get_one::("catalog_name").unwrap(), + "my-catalog" + ); + assert_eq!(list_args.get_one::("scope").unwrap(), "table"); + assert_eq!( + list_args.get_one::("object").unwrap(), + "bronze.runs" + ); + assert_eq!( + list_args.get_one::("environment").unwrap(), + "production" + ); + } + + #[test] + fn knowledge_list_rejects_invalid_scope() { + let result = catalogs_cmd().try_get_matches_from([ + "catalogs", + "knowledge", + "list", + "my-catalog", + "--scope", + "bogus", + ]); + assert!(result.is_err()); + } + + #[test] + fn knowledge_scope_and_confidence_round_trip() { + use tower_api::models::{catalog_fact, update_catalog_fact_body}; + + for scope in super::KNOWLEDGE_SCOPES { + let parsed = super::parse_knowledge_scope(scope); + let rendered = match parsed { + update_catalog_fact_body::Scope::Catalog => catalog_fact::Scope::Catalog, + update_catalog_fact_body::Scope::Namespace => catalog_fact::Scope::Namespace, + update_catalog_fact_body::Scope::Table => catalog_fact::Scope::Table, + update_catalog_fact_body::Scope::Column => catalog_fact::Scope::Column, + update_catalog_fact_body::Scope::Metric => catalog_fact::Scope::Metric, + }; + assert_eq!(super::knowledge_scope_str(rendered), scope); + } + + for confidence in super::KNOWLEDGE_CONFIDENCES { + let parsed = super::parse_knowledge_confidence(confidence); + let rendered = match parsed { + update_catalog_fact_body::Confidence::Confirmed => { + catalog_fact::Confidence::Confirmed + } + update_catalog_fact_body::Confidence::Heuristic => { + catalog_fact::Confidence::Heuristic + } + update_catalog_fact_body::Confidence::Inferred => { + catalog_fact::Confidence::Inferred + } + }; + assert_eq!(super::knowledge_confidence_str(rendered), confidence); + } + } + + #[test] + fn knowledge_set_accepts_all_fields() { + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "knowledge", + "set", + "my-catalog", + "my-entry", + "--statement", + "deleted_at marks soft-deleted rows", + "--scope", + "column", + "--object", + "bronze.runs.deleted_at", + "--confidence", + "inferred", + "--source", + "agent-42", + "--body", + "{\"sql\": \"deleted_at IS NULL\"}", + ]) + .expect("knowledge set with all flags should parse"); + + let (_, knowledge_args) = matches.subcommand().expect("expected knowledge subcommand"); + let (_, set_args) = knowledge_args.subcommand().expect("expected set subcommand"); + + assert_eq!(set_args.get_one::("scope").unwrap(), "column"); + assert_eq!( + set_args.get_one::("object").unwrap(), + "bronze.runs.deleted_at" + ); + assert_eq!( + set_args.get_one::("confidence").unwrap(), + "inferred" + ); + assert_eq!(set_args.get_one::("source").unwrap(), "agent-42"); + assert_eq!( + set_args.get_one::("body").unwrap(), + "{\"sql\": \"deleted_at IS NULL\"}" + ); + } + + #[test] + fn knowledge_set_defaults_scope_and_confidence() { + let matches = catalogs_cmd() + .try_get_matches_from([ + "catalogs", + "knowledge", + "set", + "my-catalog", + "my-entry", + "--statement", + "deleted_at is a soft-delete marker", + ]) + .expect("knowledge set should parse"); + + let (_, knowledge_args) = matches.subcommand().expect("expected knowledge subcommand"); + let (_, set_args) = knowledge_args.subcommand().expect("expected set subcommand"); + + assert_eq!(set_args.get_one::("scope").unwrap(), "catalog"); + assert_eq!( + set_args.get_one::("confidence").unwrap(), + "confirmed" + ); + assert!(set_args.get_one::("object").is_none()); + assert!(set_args.get_one::("source").is_none()); + assert!(set_args.get_one::("body").is_none()); + } + + #[test] + fn knowledge_set_requires_statement() { + let result = + catalogs_cmd().try_get_matches_from(["catalogs", "knowledge", "set", "my-catalog", "f"]); + assert!(result.is_err()); + } + + #[test] + fn knowledge_show_requires_catalog_and_name() { + let result = + catalogs_cmd().try_get_matches_from(["catalogs", "knowledge", "show", "my-catalog"]); + assert!(result.is_err()); + + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "knowledge", "show", "my-catalog", "my-entry"]) + .expect("knowledge show should parse"); + let (_, knowledge_args) = matches.subcommand().expect("expected knowledge subcommand"); + let (_, show_args) = knowledge_args.subcommand().expect("expected show subcommand"); + assert_eq!(show_args.get_one::("name").unwrap(), "my-entry"); + assert_eq!( + show_args.get_one::("environment").unwrap(), + "default" + ); + } + + #[test] + fn truncate_statement_preserves_short_and_marks_long() { + assert_eq!(super::truncate_statement("short", 80), "short"); + let long = "x".repeat(100); + let truncated = super::truncate_statement(&long, 80); + assert_eq!(truncated.chars().count(), 80); + assert!(truncated.ends_with('…')); + } + } diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index a4d8b48f..02cd8803 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -154,6 +154,24 @@ impl App { Some(("query", args)) => { catalogs::do_query(&out, sessionized_config, args).await } + Some(("knowledge", knowledge_matches)) => match knowledge_matches.subcommand() { + Some(("list", args)) => { + catalogs::do_knowledge_list(&out, sessionized_config, args).await + } + Some(("show", args)) => { + catalogs::do_knowledge_show(&out, sessionized_config, args).await + } + Some(("set", args)) => { + catalogs::do_knowledge_set(&out, sessionized_config, args).await + } + Some(("delete", args)) => { + catalogs::do_knowledge_delete(&out, sessionized_config, args).await + } + _ => { + catalogs::catalogs_cmd().print_help().unwrap(); + std::process::exit(2); + } + }, _ => { catalogs::catalogs_cmd().print_help().unwrap(); std::process::exit(2); diff --git a/crates/tower-cmd/src/util/apps.rs b/crates/tower-cmd/src/util/apps.rs index 4cadce8a..bedcec17 100644 --- a/crates/tower-cmd/src/util/apps.rs +++ b/crates/tower-cmd/src/util/apps.rs @@ -85,6 +85,10 @@ pub async fn ensure_app_exists( slug: None, is_externally_accessible: None, subdomain: None, + pending_timeout: None, + retry_policy: None, + running_timeout: None, + tags: None, }, }, ) From 0b4df107ee8314b8f8d166a1e9699a09507ca1c5 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Mon, 27 Jul 2026 15:29:38 +0100 Subject: [PATCH 14/18] Add query support to local MCP server (#328) * Add query support to local MCP server Adds a tower_catalogs_query MCP tool that runs one read-only SQL statement against a Tower-managed storage catalog, and extends tower_catalogs_show to list the catalog's namespaces and tables. Agent queries go through the tower-duckdb guard (single read-only statement, denied functions and file/URL table references rejected), run hardened (Hardening::agent()), and are capped by Limits::agent() (1000 rows / 1 MiB / 60s). Rows come back as positional arrays so duplicate column names survive. * test(catalogs): gated MCP catalog e2e scenarios Behave scenarios for the MCP catalog tools: show lists tables, a read-only query succeeds, writes and multi-statement SQL are rejected. Skipped unless TOWER_TEST_CATALOG (and a real TOWER_URL) is set, since they exercise the real attach -> Iceberg -> query path. * style: apply black formatting to mcp_steps.py --- crates/tower-cmd/src/catalogs.rs | 186 ++++++++++++++---- crates/tower-cmd/src/mcp.rs | 87 +++++++- tests/integration/features/environment.py | 19 ++ .../integration/features/mcp_catalogs.feature | 35 ++++ tests/integration/features/steps/mcp_steps.py | 117 +++++++++++ 5 files changed, 400 insertions(+), 44 deletions(-) create mode 100644 tests/integration/features/mcp_catalogs.feature diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index cebc682a..1bc6ea19 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -379,49 +379,10 @@ async fn fetch_catalog_tables( ) -> Result { let mut spinner = out.spinner("Listing tables..."); - let response = match api::vend_catalog_credentials( - config, - name, - env, - vend_catalog_credentials_body::Mode::Read, - ) - .await - { - Ok(response) => response, - Err(err) => { - spinner.failure(out); - return Err(err.to_string()); - } - }; - - let token = response.credentials.oauth_token.clone(); - - // `--full` needs every table's column schema. Going through DuckDB means a - // `DESCRIBE` per table, each of which fully opens the Iceberg table (reading - // manifests from object storage) — slow, and unbounded for tables with heavy - // metadata. The Iceberg REST catalog's `loadTable` returns the schema - // straight from table metadata with no manifest I/O, so `--full` talks to - // the catalog directly. The plain listing stays on DuckDB. let result = if full { - fetch_catalog_columns_via_rest(&response.credentials).await + list_catalog_columns(config, name, env).await } else { - let setup = attach_statements( - name, - &response.credentials, - vend_catalog_credentials_body::Mode::Read, - ); - let db_name = name.to_string(); - tokio::task::spawn_blocking(move || { - run_query( - &setup, - "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", - params![db_name], - &Limits::none(), - ) - }) - .await - .map_err(|err| err.to_string()) - .and_then(|inner| inner.map_err(|err| err.to_string())) + list_catalog_tables(config, name, env).await }; match result { @@ -431,11 +392,68 @@ async fn fetch_catalog_tables( } Err(err) => { spinner.failure(out); - Err(redact_token(&err, &token)) + Err(err) } } } +/// Attaches a storage catalog read-only and returns its (namespace, table) +/// rows via `SHOW ALL TABLES`. Shared by the CLI `show` and the MCP server. +/// Errors are returned with the OAuth token redacted. +pub(crate) async fn list_catalog_tables( + config: &Config, + name: &str, + env: &str, +) -> Result { + let response = + api::vend_catalog_credentials(config, name, env, vend_catalog_credentials_body::Mode::Read) + .await + .map_err(|err| err.to_string())?; + + let token = response.credentials.oauth_token.clone(); + let setup = attach_statements( + name, + &response.credentials, + vend_catalog_credentials_body::Mode::Read, + ); + let db_name = name.to_string(); + + tokio::task::spawn_blocking(move || { + run_query( + &setup, + "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", + params![db_name], + &Limits::none(), + ) + }) + .await + .map_err(|err| err.to_string()) + .and_then(|inner| inner.map_err(|err| err.to_string())) + .map_err(|err| redact_token(&err, &token)) +} + +/// The `--full` listing: every table's column schema. Going through DuckDB +/// means a `DESCRIBE` per table, each of which fully opens the Iceberg table +/// (reading manifests from object storage) — slow, and unbounded for tables +/// with heavy metadata. The Iceberg REST catalog's `loadTable` returns the +/// schema straight from table metadata with no manifest I/O, so this talks to +/// the catalog directly. The plain listing stays on DuckDB. +async fn list_catalog_columns( + config: &Config, + name: &str, + env: &str, +) -> Result { + let response = + api::vend_catalog_credentials(config, name, env, vend_catalog_credentials_body::Mode::Read) + .await + .map_err(|err| err.to_string())?; + + let token = response.credentials.oauth_token.clone(); + fetch_catalog_columns_via_rest(&response.credentials) + .await + .map_err(|err| redact_token(&err, &token)) +} + /// DuckDB errors can echo the failing statement, and the setup batch contains /// the vended OAuth token — scrub it before the message reaches any output. fn redact_token(message: &str, token: &str) -> String { @@ -608,6 +626,88 @@ async fn execute_catalog_query( } } +/// Agent-facing counterpart of `do_query`, used by the MCP server: the same +/// read-only gate, storage-type check, sandbox, and ceilings, but errors are +/// returned rather than printed, and the caps are non-negotiable — an agent +/// has no `--write` or `--max-rows` escape hatch. +pub(crate) async fn query_catalog_for_agent( + config: &Config, + name: &str, + env: &str, + sql: String, +) -> Result { + let sql_to_check = sql.clone(); + let verdict = tokio::task::spawn_blocking(move || guard::classify_read_only(&sql_to_check)) + .await + .map_err(|err| format!("Could not validate the query: {err}"))? + .map_err(|err| format!("Could not validate the query: {err}"))?; + match verdict { + guard::ReadOnlyCheck::Allowed => {} + guard::ReadOnlyCheck::Empty => { + return Err("No SQL statement provided.".to_string()); + } + guard::ReadOnlyCheck::Multiple => { + return Err("Only a single SQL statement can be run per query.".to_string()); + } + guard::ReadOnlyCheck::NotReadOnly => { + return Err( + "This query is read-only; only a single SELECT statement is allowed.".to_string(), + ); + } + guard::ReadOnlyCheck::DeniedFunction(function) => { + return Err(format!( + "'{function}' is not allowed in a read-only query: it changes engine state, runs SQL built at runtime, or reads outside the catalog." + )); + } + guard::ReadOnlyCheck::DeniedTableReference(reference) => { + return Err(format!( + "'{reference}' is a file or URL, not a table in this catalog. Read-only queries can only read the catalog's own tables." + )); + } + } + + let response = api::describe_catalog(config, name, env) + .await + .map_err(|err| format!("Fetching catalog details failed: {err}"))?; + if !is_storage_catalog_type(Some(&response.catalog.r#type)) { + return Err(format!( + "Querying is only supported for {} catalogs; '{}' has type '{}'.", + STORAGE_CATALOG_TYPE, name, response.catalog.r#type + )); + } + + let response = + api::vend_catalog_credentials(config, name, env, vend_catalog_credentials_body::Mode::Read) + .await + .map_err(|err| format!("Running query failed: {err}"))?; + + let token = response.credentials.oauth_token.clone(); + let setup = attach_statements( + name, + &response.credentials, + vend_catalog_credentials_body::Mode::Read, + ); + let result = tokio::task::spawn_blocking(move || -> Result { + let session = Session::open()?; + session.run_setup(&setup)?; + session.harden(&Hardening::agent())?; + session.query(&sql, [], &Limits::agent()) + }) + .await; + + match result { + Ok(Ok(query_result)) => Ok(query_result), + Ok(Err(err)) => Err(format!( + "Query failed: {}", + redact_token(&err.to_string(), &token) + )), + Err(err) => Err(format!( + "Query execution panicked: {}", + redact_token(&err.to_string(), &token) + )), + } +} + fn read_sql_from_stdin(out: &output::Out) -> String { let mut stdin = std::io::stdin(); if stdin.is_terminal() { @@ -1077,7 +1177,7 @@ fn json_array_to_strings(value: Option<&serde_json::Value>) -> Vec { } } -fn is_storage_catalog_type(catalog_type: Option<&str>) -> bool { +pub(crate) fn is_storage_catalog_type(catalog_type: Option<&str>) -> bool { catalog_type == Some(STORAGE_CATALOG_TYPE) } diff --git a/crates/tower-cmd/src/mcp.rs b/crates/tower-cmd/src/mcp.rs index fc301457..7ccbce56 100644 --- a/crates/tower-cmd/src/mcp.rs +++ b/crates/tower-cmd/src/mcp.rs @@ -199,6 +199,18 @@ struct ShowCatalogRequest { environment: Option, } +#[derive(Debug, Deserialize, JsonSchema)] +struct QueryCatalogRequest { + /// Name of the Tower-managed storage catalog to query + name: String, + /// A single read-only SQL statement. Tables must be fully qualified as + /// ..
(there is no default schema). Writes, DDL, + /// and multiple statements are rejected. + sql: String, + /// The environment the catalog belongs to (defaults to "default") + environment: Option, +} + pub fn mcp_cmd() -> Command { Command::new("mcp-server") .about("Runs an MCP server for LLM interaction") @@ -719,7 +731,9 @@ impl TowerService { } } - #[tool(description = "Show details for a catalog, including its property names")] + #[tool( + description = "Show a catalog's details: its property names and, for Tower-managed storage catalogs, the namespaces and tables you can query." + )] async fn tower_catalogs_show( &self, Parameters(request): Parameters, @@ -739,17 +753,88 @@ impl TowerService { }) }) .collect(); + + // Only Tower-managed storage catalogs expose queryable tables; + // for anything else `tables` stays null. A listing failure is + // surfaced in `tables_error` without failing the whole call. + let (tables, tables_error) = if crate::catalogs::is_storage_catalog_type(Some( + &catalog.r#type, + )) { + match crate::catalogs::list_catalog_tables( + &self.config, + &request.name, + environment, + ) + .await + { + Ok(result) => ( + Value::Array( + result + .rows + .iter() + .map(|row| { + json!({ + "namespace": row.first().cloned().unwrap_or(Value::Null), + "table": row.get(1).cloned().unwrap_or(Value::Null), + }) + }) + .collect(), + ), + Value::Null, + ), + Err(e) => (Value::Null, Value::String(e)), + } + } else { + (Value::Null, Value::Null) + }; + Self::json_success(json!({ "name": catalog.name, "type": catalog.r#type, "environment": catalog.environment, "properties": properties, + "tables": tables, + "tables_error": tables_error, })) } Err(e) => Self::error_result("Failed to show catalog", e), } } + #[tool( + description = "Run one read-only SQL statement against a Tower-managed storage (Iceberg) catalog and return the columns plus rows as positional arrays (one value per column, in column order). Fully qualify tables as \"\".\"\".\"
\"; call tower_catalogs_show first to list a catalog's namespaces and tables. Only a single statement runs per call, and writes/DDL are rejected. Results are capped (1000 rows, 1 MiB, 60s) — when a cap was hit, the response sets \"truncated\": true, so narrow with WHERE/LIMIT or aggregate." + )] + async fn tower_catalogs_query( + &self, + Parameters(request): Parameters, + ) -> Result { + let environment = request.environment.as_deref().unwrap_or("default"); + let sql = request.sql.trim().to_string(); + if sql.is_empty() { + return Self::text_error("No SQL statement provided.".to_string()); + } + + // Agents get read-only, sandboxed, result-capped access; positional rows + // preserve duplicate column names (e.g. from joins) that an object keyed + // by name would silently collapse. + match crate::catalogs::query_catalog_for_agent( + &self.config, + &request.name, + environment, + sql, + ) + .await + { + Ok(result) => Self::json_success(json!({ + "columns": result.columns, + "rows": result.rows, + "row_count": result.rows.len(), + "truncated": result.is_truncated(), + })), + Err(e) => Self::text_error(e), + } + } + #[tool(description = "List teams you belong to")] async fn tower_teams_list(&self) -> Result { if self.config.api_key.is_some() { diff --git a/tests/integration/features/environment.py b/tests/integration/features/environment.py index b6fb2d71..c3227b4b 100644 --- a/tests/integration/features/environment.py +++ b/tests/integration/features/environment.py @@ -16,6 +16,25 @@ def before_all(context): def before_scenario(context, scenario): + # Catalog scenarios hit the real attach -> Iceberg -> query path, which the + # mock server can't stand up. Skip them unless a storage catalog is + # configured (and rely on a real TOWER_URL + session being provided too). + if "catalogs" in scenario.effective_tags: + catalog = os.environ.get("TOWER_TEST_CATALOG") + if not catalog: + scenario.skip( + "set TOWER_TEST_CATALOG (and a real TOWER_URL) to run catalog tests" + ) + return + context.test_catalog = catalog + context.test_catalog_env = os.environ.get("TOWER_TEST_CATALOG_ENV", "default") + context.test_catalog_table = os.environ.get("TOWER_TEST_CATALOG_TABLE") + if "catalog-data" in scenario.effective_tags and not context.test_catalog_table: + scenario.skip( + "set TOWER_TEST_CATALOG_TABLE to run the catalog data query test" + ) + return + # Create a temporary working directory for this scenario context.temp_dir = tempfile.mkdtemp(prefix="tower_test_") context.original_cwd = os.getcwd() diff --git a/tests/integration/features/mcp_catalogs.feature b/tests/integration/features/mcp_catalogs.feature new file mode 100644 index 00000000..97671112 --- /dev/null +++ b/tests/integration/features/mcp_catalogs.feature @@ -0,0 +1,35 @@ +@catalogs +Feature: MCP catalog querying + As a developer using the Tower MCP server with an agent + I want to query Tower-managed storage catalogs safely + So that an agent can explore data without write access or unbounded results + + # These scenarios exercise the real attach -> Iceberg -> query path, so they + # are skipped unless a storage catalog is configured (see environment.py): + # TOWER_TEST_CATALOG name of a tower-catalog storage catalog + # TOWER_URL a real server (not the mock) with a valid session + # TOWER_TEST_CATALOG_ENV environment the catalog lives in (default: default) + # TOWER_TEST_CATALOG_TABLE optional "cat"."ns"."tbl" for the data query below + + Scenario: Show a storage catalog lists its tables + When I show the test catalog via MCP + Then I should receive a success response + And the catalog response should list tables + + Scenario: Run a read-only query against the catalog + When I query the test catalog with SQL "SELECT 1 AS one" via MCP + Then I should receive query results + And the query result columns should include "one" + + Scenario: Write statements are rejected + When I query the test catalog with SQL "DROP TABLE does_not_exist" via MCP + Then I should receive an error response about a read-only query + + Scenario: Multiple statements are rejected + When I query the test catalog with SQL "SELECT 1; SELECT 2" via MCP + Then I should receive an error response about a single statement + + @catalog-data + Scenario: Query a configured table returns positional rows + When I query the configured catalog table via MCP + Then I should receive query results diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 1fa07c3d..9a668c4b 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -904,3 +904,120 @@ def step_then_receive_workflow_help_stdio(context): @given('I have a simple hello world application named "{app_name}"') def step_create_hello_world_app_named(context, app_name): create_towerfile(context, app_name=app_name) + + +# --- Catalog querying (gated on TOWER_TEST_CATALOG; see environment.py) ------- + + +def _first_text_content(response): + """Return the first text block of an MCP response, or ''.""" + for item in response.get("content", []): + if isinstance(item, dict): + if item.get("type") == "text": + return item.get("text", "") + elif getattr(item, "type", None) == "text": + return getattr(item, "text", "") + return "" + + +def _parse_json_content(response): + """Parse the response's text block as JSON, or None if it isn't JSON.""" + try: + return json.loads(_first_text_content(response)) + except (ValueError, TypeError): + return None + + +@when("I show the test catalog via MCP") +@async_run_until_complete +async def step_show_test_catalog(context): + await call_mcp_tool( + context, + "tower_catalogs_show", + {"name": context.test_catalog, "environment": context.test_catalog_env}, + ) + + +@when('I query the test catalog with SQL "{sql}" via MCP') +@async_run_until_complete +async def step_query_test_catalog(context, sql): + await call_mcp_tool( + context, + "tower_catalogs_query", + { + "name": context.test_catalog, + "environment": context.test_catalog_env, + "sql": sql, + }, + ) + + +@when("I query the configured catalog table via MCP") +@async_run_until_complete +async def step_query_configured_table(context): + await call_mcp_tool( + context, + "tower_catalogs_query", + { + "name": context.test_catalog, + "environment": context.test_catalog_env, + "sql": f"SELECT * FROM {context.test_catalog_table} LIMIT 5", + }, + ) + + +@then("the catalog response should list tables") +def step_catalog_lists_tables(context): + data = _parse_json_content(context.mcp_response) + assert ( + data is not None + ), f"Expected JSON content, got: {context.mcp_response.get('content')}" + assert "tables" in data, f"Response should include a 'tables' field, got: {data}" + + +@then("I should receive query results") +def step_receive_query_results(context): + assert context.mcp_response.get( + "success", False + ), f"Expected a successful query, got: {context.mcp_response}" + data = _parse_json_content(context.mcp_response) + assert ( + data is not None + ), f"Expected JSON content, got: {context.mcp_response.get('content')}" + assert isinstance( + data.get("columns"), list + ), f"Expected a 'columns' list, got: {data}" + assert isinstance(data.get("rows"), list), f"Expected a 'rows' list, got: {data}" + # Rows are positional arrays (one value per column), not objects keyed by name. + for row in data["rows"]: + assert isinstance(row, list), f"Expected positional row arrays, got: {row}" + + +@then('the query result columns should include "{column}"') +def step_query_columns_include(context, column): + data = _parse_json_content(context.mcp_response) + assert data is not None and column in data.get( + "columns", [] + ), f"Expected column '{column}', got: {data}" + + +@then("I should receive an error response about a read-only query") +def step_error_read_only(context): + assert is_error_response( + context.mcp_response + ), f"Expected an error response, got: {context.mcp_response}" + text = str(context.mcp_response).lower() + assert ( + "read-only" in text or "not allowed" in text + ), f"Error should mention the read-only restriction, got: {context.mcp_response}" + + +@then("I should receive an error response about a single statement") +def step_error_single_statement(context): + assert is_error_response( + context.mcp_response + ), f"Expected an error response, got: {context.mcp_response}" + text = str(context.mcp_response).lower() + assert ( + "single" in text and "statement" in text + ), f"Error should mention the single-statement rule, got: {context.mcp_response}" From 23730c6d40efc75b60100c9ce54c8dd0dbaa7178 Mon Sep 17 00:00:00 2001 From: Brad Heller Date: Mon, 27 Jul 2026 15:39:11 +0100 Subject: [PATCH 15/18] chore: Bump version to v0.3.70-rc.2 --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f728341f..9aa3b379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -841,7 +841,7 @@ dependencies = [ [[package]] name = "config" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "base64", "chrono", @@ -1033,7 +1033,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "aes-gcm", "base64", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "testutils" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "pem", "rsa", @@ -4765,7 +4765,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "config", "pyo3", @@ -4793,7 +4793,7 @@ dependencies = [ [[package]] name = "tower-api" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "reqwest", "serde", @@ -4805,7 +4805,7 @@ dependencies = [ [[package]] name = "tower-cmd" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "axum", "bytes", @@ -4854,7 +4854,7 @@ dependencies = [ [[package]] name = "tower-duckdb" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "chrono", "duckdb", @@ -4889,7 +4889,7 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-package" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "async-compression", "flate2", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "tower-runtime" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "async-trait", "chrono", @@ -4938,7 +4938,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-telemetry" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "tracing", "tracing-appender", @@ -4947,7 +4947,7 @@ dependencies = [ [[package]] name = "tower-uv" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "async-compression", "async_zip", @@ -4967,7 +4967,7 @@ dependencies = [ [[package]] name = "tower-version" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d02601b7..e4f94ed9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.3.70-rc.1" +version = "0.3.70-rc.2" description = "Tower is the best way to host Python data apps in production" # Matches rust-toolchain.toml. The two had drifted: the toolchain has been 1.88 # for a while, and the dependency tree (testcontainers and its transitive deps, diff --git a/pyproject.toml b/pyproject.toml index c2a830e8..ee907391 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "tower" -version = "0.3.70rc1" +version = "0.3.70rc2" description = "Tower CLI and runtime environment for Tower." authors = [{ name = "Tower Computing Inc.", email = "brad@tower.dev" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index 06b37752..b5a176c8 100644 --- a/uv.lock +++ b/uv.lock @@ -2077,7 +2077,7 @@ wheels = [ [[package]] name = "tower" -version = "0.3.70rc1" +version = "0.3.70rc2" source = { editable = "." } dependencies = [ { name = "attrs" }, From a3f9c551629dbc0613e5f2bc297951c1cf692d9e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:28:45 +0200 Subject: [PATCH 16/18] chore: regenerate SKILL.md (#326) Co-authored-by: bradhe <310958+bradhe@users.noreply.github.com> --- plugin/skills/tower/SKILL.md | 74 +++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/plugin/skills/tower/SKILL.md b/plugin/skills/tower/SKILL.md index 8d1b31e9..e34070c1 100644 --- a/plugin/skills/tower/SKILL.md +++ b/plugin/skills/tower/SKILL.md @@ -203,7 +203,7 @@ Cancel a running app run ### `tower catalogs` -Interact with the catalogs in your Tower account +Interact with the catalogs in your Tower account (includes Storage [beta]) #### `tower catalogs list` @@ -214,20 +214,21 @@ List all of your catalogs - `-e`, `--environment` — List catalogs in this environment - `-a`, `--all` — List catalogs across all environments - `--type` — Filter catalogs by type, e.g. tower-catalog -- `--storage` — List Tower-managed storage catalogs +- `--storage` — List Tower-managed storage catalogs [beta] #### `tower catalogs show` -Show the details of a catalog, including its property names +Show the details of a catalog, including its properties and tables **Arguments:** - `` *(required)* — Name of the catalog - `-e`, `--environment` — Environment the catalog belongs to +- `--full` — List each table's columns and their types #### `tower catalogs credentials` -Vend short-lived catalog credentials for external tools +Vend short-lived catalog credentials for external tools [beta] **Arguments:** @@ -237,6 +238,69 @@ Vend short-lived catalog credentials for external tools - `--format` — Snippet format to print - `--show-token` — Print the vended OAuth token in normal output +#### `tower catalogs knowledge` + +Store and retrieve knowledge about the semantics of the data in a catalog [beta] + +##### `tower catalogs knowledge list` + +List the knowledge recorded for a catalog + +**Arguments:** + +- `` *(required)* — Name of the catalog +- `-e`, `--environment` — Environment the catalog belongs to +- `--scope` — Only list knowledge with this scope +- `--object` — Only list knowledge about this object path, e.g. bronze.runs.deleted_at + +##### `tower catalogs knowledge show` + +Show the full details of a knowledge entry, including its body + +**Arguments:** + +- `` *(required)* — Name of the catalog +- `` *(required)* — Name of the knowledge entry +- `-e`, `--environment` — Environment the catalog belongs to + +##### `tower catalogs knowledge set` + +Create a knowledge entry, or replace it if one with the same name exists + +**Arguments:** + +- `` *(required)* — Name of the catalog +- `` *(required)* — Name of the knowledge entry +- `-e`, `--environment` — Environment the catalog belongs to +- `--statement` *(required)* — The human-readable meaning of the entry +- `--scope` — What kind of object the entry is about +- `--object` — Path to what the entry is about, e.g. bronze.runs.deleted_at; omit for catalog-scoped knowledge +- `--confidence` — How trustworthy the entry is +- `--source` — Where the knowledge came from (agent id, user, ...) +- `--body` — Optional structured payload (SQL, unit, enum values) as a JSON string + +##### `tower catalogs knowledge delete` + +Delete a knowledge entry from a catalog + +**Arguments:** + +- `` *(required)* — Name of the catalog +- `` *(required)* — Name of the knowledge entry +- `-e`, `--environment` — Environment the catalog belongs to + +#### `tower catalogs query` + +Run a SQL query against a catalog using DuckDB [beta] + +**Arguments:** + +- `` *(required)* — Name of the catalog to query +- `-s`, `--sql` — SQL statement to execute; read from stdin when omitted +- `-e`, `--environment` — Environment the catalog belongs to +- `-w`, `--write` — Allow write statements by vending read-write credentials; queries are read-only by default +- `--max-rows` — Maximum rows to return; 0 for no limit. Also lifts the result size limit, so a large result can exhaust memory + ### `tower schedules` Manage schedules for your Tower apps @@ -346,7 +410,7 @@ Deploy your latest code to Tower - `-f`, `--create` — Automatically force creation of the app if it doesn't already exist - `-e`, `--environment` — The environment to deploy to - `--all` — Deploy to all environments -- `--idempotency-key ` — Reuse the existing version deployed with this key instead of creating a new one. Defaults to the current git commit SHA when the working tree is clean, so deploying unchanged source to multiple environments collapses to a single version. +- `--idempotency-key` — Reuse the existing version deployed with this key instead of creating a new one. Defaults to the current git commit SHA when the working tree is clean. - `--no-idempotency-key` — Never send an idempotency key, even on a clean git tree ### `tower run` From 425ead1277068f3f206b4d33ce0c021a235b9af9 Mon Sep 17 00:00:00 2001 From: Konstantinos St Date: Thu, 30 Jul 2026 09:28:48 -0300 Subject: [PATCH 17/18] chore: become mcp 2.0.0 compatible (#335) --- pyproject.toml | 2 +- tests/integration/features/steps/mcp_steps.py | 2 +- uv.lock | 272 +++++++++++++++--- 3 files changed, 236 insertions(+), 40 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee907391..88aeae52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,7 +89,7 @@ extend-exclude = ''' test = [ "aiohttp==3.10.11", "behave==1.3.3", - "mcp>=1.1.0; python_version>='3.10'", + "mcp>=2,<3; python_version>='3.10'", "pytest==8.3.5", "pytest-httpx==0.35.0", "pytest-env>=1.1.3", diff --git a/tests/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 9a668c4b..d0751db7 100644 --- a/tests/integration/features/steps/mcp_steps.py +++ b/tests/integration/features/steps/mcp_steps.py @@ -35,7 +35,7 @@ async def logging_callback(params): await session.initialize() result = await session.call_tool(tool_name, args) return { - "success": not result.isError, + "success": not result.is_error, "content": result.content, "result": result, "captured_logs": captured_logs, diff --git a/uv.lock b/uv.lock index b5a176c8..742bd604 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "agate" @@ -216,6 +220,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -288,6 +377,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, +] + [[package]] name = "cucumber-expressions" version = "18.0.1" @@ -623,6 +762,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -639,12 +791,19 @@ wheels = [ ] [[package]] -name = "httpx-sse" -version = "0.4.1" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -677,11 +836,11 @@ wheels = [ [[package]] name = "idna" -version = "3.10" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -843,24 +1002,40 @@ msgpack = [ [[package]] name = "mcp" -version = "1.14.0" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/95/fd/d6e941a52446198b73e5e4a953441f667f1469aeb06fb382d9f6729d6168/mcp-1.14.0.tar.gz", hash = "sha256:2e7d98b195e08b2abc1dc6191f6f3dc0059604ac13ee6a40f88676274787fac4", size = 454855, upload-time = "2025-09-11T17:40:48.667Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/7b/84b0dd4c2c5a499d2c5d63fb7a1224c25fc4c8b6c24623fa7a566471480d/mcp-1.14.0-py3-none-any.whl", hash = "sha256:b2d27feba27b4c53d41b58aa7f4d090ae0cb740cbc4e339af10f8cbe54c4e19d", size = 163805, upload-time = "2025-09-11T17:40:46.891Z" }, + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, ] [[package]] @@ -1152,6 +1327,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/e2/1cdfbc19c6917b1a878e5270a221904290113d0fce0df3599edd79d77906/openapi_python_client-0.28.1-py3-none-any.whl", hash = "sha256:9cf2f139f3c718f376f6a6981ed8ae37671f0fb2dd0f1d9504b2f583af1f82f9", size = 183195, upload-time = "2026-01-10T23:19:40.618Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "orderly-set" version = "5.5.0" @@ -1387,6 +1574,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1473,20 +1669,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, -] - [[package]] name = "pygments" version = "2.19.2" @@ -1537,6 +1719,20 @@ sql-sqlite = [ { name = "sqlalchemy" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.2.4" @@ -1654,15 +1850,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, -] - [[package]] name = "python-multipart" version = "0.0.20" @@ -2162,7 +2349,7 @@ dev = [ { name = "behave", specifier = "==1.3.3" }, { name = "black", specifier = "==24.10.0" }, { name = "dirty-equals", specifier = ">=0.10.0" }, - { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.1.0" }, + { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=2,<3" }, { name = "mypy", specifier = "==1.13.0" }, { name = "openapi-python-client", specifier = "==0.28.1" }, { name = "pre-commit", specifier = "==4.0.1" }, @@ -2176,7 +2363,7 @@ test = [ { name = "aiohttp", specifier = "==3.10.11" }, { name = "behave", specifier = "==1.3.3" }, { name = "dirty-equals", specifier = ">=0.10.0" }, - { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=1.1.0" }, + { name = "mcp", marker = "python_full_version >= '3.10'", specifier = ">=2,<3" }, { name = "pytest", specifier = "==8.3.5" }, { name = "pytest-env", specifier = ">=1.1.3" }, { name = "pytest-httpx", specifier = "==0.35.0" }, @@ -2195,6 +2382,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.21.1" From ddb342292166a69aa14dec3aa5be7b6baa341bc0 Mon Sep 17 00:00:00 2001 From: Konstantinos Stefanidis Vozikis Date: Thu, 30 Jul 2026 15:08:45 +0200 Subject: [PATCH 18/18] chore: upgrade labels to 0.3.70 release --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9aa3b379..1d8f7edb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -841,7 +841,7 @@ dependencies = [ [[package]] name = "config" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "base64", "chrono", @@ -1033,7 +1033,7 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "aes-gcm", "base64", @@ -4456,7 +4456,7 @@ dependencies = [ [[package]] name = "testutils" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "pem", "rsa", @@ -4765,7 +4765,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "config", "pyo3", @@ -4793,7 +4793,7 @@ dependencies = [ [[package]] name = "tower-api" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "reqwest", "serde", @@ -4805,7 +4805,7 @@ dependencies = [ [[package]] name = "tower-cmd" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "axum", "bytes", @@ -4854,7 +4854,7 @@ dependencies = [ [[package]] name = "tower-duckdb" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "chrono", "duckdb", @@ -4889,7 +4889,7 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-package" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "async-compression", "flate2", @@ -4914,7 +4914,7 @@ dependencies = [ [[package]] name = "tower-runtime" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "async-trait", "chrono", @@ -4938,7 +4938,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-telemetry" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "tracing", "tracing-appender", @@ -4947,7 +4947,7 @@ dependencies = [ [[package]] name = "tower-uv" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "async-compression", "async_zip", @@ -4967,7 +4967,7 @@ dependencies = [ [[package]] name = "tower-version" -version = "0.3.70-rc.2" +version = "0.3.70" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index e4f94ed9..2866d01d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.3.70-rc.2" +version = "0.3.70" description = "Tower is the best way to host Python data apps in production" # Matches rust-toolchain.toml. The two had drifted: the toolchain has been 1.88 # for a while, and the dependency tree (testcontainers and its transitive deps, diff --git a/pyproject.toml b/pyproject.toml index 88aeae52..8d84c009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "tower" -version = "0.3.70rc2" +version = "0.3.70" description = "Tower CLI and runtime environment for Tower." authors = [{ name = "Tower Computing Inc.", email = "brad@tower.dev" }] readme = "README.md" diff --git a/uv.lock b/uv.lock index 742bd604..1403d801 100644 --- a/uv.lock +++ b/uv.lock @@ -2264,7 +2264,7 @@ wheels = [ [[package]] name = "tower" -version = "0.3.70rc2" +version = "0.3.70" source = { editable = "." } dependencies = [ { name = "attrs" },