diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index bf9e6eb5..fa9006dc 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -42,7 +42,12 @@ jobs: - uses: actions/checkout@v6 with: 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 }} @@ -72,6 +77,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 @@ -92,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 }} @@ -117,6 +127,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 @@ -137,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 }} @@ -170,7 +185,12 @@ jobs: - uses: actions/checkout@v6 with: 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 }} @@ -204,16 +224,20 @@ jobs: - uses: actions/checkout@v6 with: 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 }} - 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 +261,24 @@ 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 + - 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 }} @@ -262,35 +287,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,11 +313,14 @@ jobs: - uses: actions/checkout@v6 with: submodules: recursive + persist-credentials: false - 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: @@ -337,40 +344,41 @@ 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 }} + - 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: 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 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 diff --git a/Cargo.lock b/Cargo.lock index 013fb4b7..1d8f7edb 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" @@ -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.13.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" @@ -289,9 +501,21 @@ 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" +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" @@ -302,6 +526,80 @@ 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" +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 +616,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 +656,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 +691,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" @@ -375,7 +714,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -489,9 +828,20 @@ 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" +version = "0.3.70" dependencies = [ "base64", "chrono", @@ -501,6 +851,7 @@ dependencies = [ "serde", "serde_json", "snafu", + "tempfile", "testutils", "tokio", "tower-api", @@ -528,6 +879,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" @@ -562,6 +933,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" @@ -605,13 +985,26 @@ 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.13.1", + "crossterm_winapi", + "parking_lot", + "rustix 0.38.44", + "winapi", +] + [[package]] name = "crossterm" 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", @@ -632,9 +1025,15 @@ 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" +version = "0.3.70" dependencies = [ "aes-gcm", "base64", @@ -847,6 +1246,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" @@ -950,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" @@ -965,6 +1386,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" @@ -1015,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" @@ -1026,6 +1476,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" @@ -1062,6 +1524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -1071,6 +1534,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" @@ -1090,6 +1559,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" @@ -1224,8 +1699,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]] @@ -1236,10 +1713,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" @@ -1298,22 +1789,55 @@ 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", ] +[[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" @@ -1333,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" @@ -1405,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" @@ -1446,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" @@ -1655,8 +2217,8 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.9.1", - "crossterm", + "bitflags 2.13.1", + "crossterm 0.29.0", "dyn-clone", "fuzzy-matcher", "unicode-segmentation", @@ -1678,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", ] @@ -1742,6 +2304,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" @@ -1784,12 +2356,86 @@ dependencies = [ "spin", ] +[[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" @@ -1802,11 +2448,20 @@ 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", ] +[[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" @@ -1849,9 +2504,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "maplit" @@ -1966,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", @@ -2003,6 +2664,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" @@ -2020,6 +2691,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" @@ -2083,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", ] @@ -2114,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" @@ -2155,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" @@ -2266,6 +2977,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" @@ -2273,7 +2990,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", ] @@ -2308,6 +3025,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" @@ -2326,6 +3052,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" @@ -2384,6 +3130,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" @@ -2399,6 +3201,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" @@ -2430,6 +3244,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" @@ -2468,6 +3293,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" @@ -2483,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]] @@ -2567,6 +3407,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" @@ -2575,6 +3424,7 @@ checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64", "bytes", + "futures-channel", "futures-core", "futures-util", "http", @@ -2588,6 +3438,7 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "serde", @@ -2638,6 +3489,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" @@ -2723,12 +3603,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" @@ -2744,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", @@ -2757,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", @@ -2778,12 +3681,34 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ + "web-time", "zeroize", ] @@ -2843,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" @@ -2905,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" @@ -3066,7 +4023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3125,6 +4082,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" @@ -3210,7 +4179,7 @@ checksum = "a0ef947f358b9c238923f764c72a4a9d42f2d637c46e059dbd319d6e7cfb4f82" dependencies = [ "lazy_static", "maplit", - "strum", + "strum 0.24.1", ] [[package]] @@ -3254,13 +4223,45 @@ 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" 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]] @@ -3276,6 +4277,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" @@ -3357,6 +4370,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" @@ -3406,9 +4425,38 @@ 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.69" +version = "0.3.70" dependencies = [ "pem", "rsa", @@ -3505,6 +4553,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" @@ -3515,6 +4572,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" @@ -3628,8 +4700,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -3641,6 +4713,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" @@ -3650,9 +4731,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]] @@ -3663,7 +4765,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tower" -version = "0.3.69" +version = "0.3.70" dependencies = [ "config", "pyo3", @@ -3691,7 +4793,7 @@ dependencies = [ [[package]] name = "tower-api" -version = "0.3.69" +version = "0.3.70" dependencies = [ "reqwest", "serde", @@ -3703,7 +4805,7 @@ dependencies = [ [[package]] name = "tower-cmd" -version = "0.3.69" +version = "0.3.70" dependencies = [ "axum", "bytes", @@ -3738,8 +4840,9 @@ dependencies = [ "tokio-test", "tokio-util", "toml", - "toml_edit", + "toml_edit 0.22.27", "tower-api", + "tower-duckdb", "tower-package", "tower-runtime", "tower-telemetry", @@ -3749,13 +4852,24 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "tower-duckdb" +version = "0.3.70" +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", @@ -3775,7 +4889,7 @@ checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] name = "tower-package" -version = "0.3.69" +version = "0.3.70" dependencies = [ "async-compression", "flate2", @@ -3800,7 +4914,7 @@ dependencies = [ [[package]] name = "tower-runtime" -version = "0.3.69" +version = "0.3.70" dependencies = [ "async-trait", "chrono", @@ -3824,7 +4938,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tower-telemetry" -version = "0.3.69" +version = "0.3.70" dependencies = [ "tracing", "tracing-appender", @@ -3833,7 +4947,7 @@ dependencies = [ [[package]] name = "tower-uv" -version = "0.3.69" +version = "0.3.70" dependencies = [ "async-compression", "async_zip", @@ -3853,7 +4967,7 @@ dependencies = [ [[package]] name = "tower-version" -version = "0.3.69" +version = "0.3.70" dependencies = [ "anyhow", "chrono", @@ -4075,6 +5189,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" @@ -4124,6 +5244,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", ] @@ -4283,7 +5404,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -4316,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]] @@ -4331,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]] @@ -4379,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" @@ -4630,13 +5766,19 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.1", ] [[package]] @@ -4645,6 +5787,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" @@ -4758,3 +5909,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..2866d01d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,13 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.3.69" +version = "0.3.70" 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,6 +32,7 @@ config = { path = "crates/config" } crypto = { path = "crates/crypto" } ctrlc = "3" dirs = "5" +duckdb = { version = "~1.10504.0", features = ["bundled", "json"] } flate2 = "1" fs2 = "0.4" futures = "0.3" @@ -67,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/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-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/Cargo.toml b/crates/tower-cmd/Cargo.toml index dac7d31b..93c5fe01 100644 --- a/crates/tower-cmd/Cargo.toml +++ b/crates/tower-cmd/Cargo.toml @@ -29,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/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/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 new file mode 100644 index 00000000..09e746c4 --- /dev/null +++ b/crates/tower-cmd/src/beta.rs @@ -0,0 +1,100 @@ +use std::io::{self, IsTerminal}; + +use tower_telemetry::debug; + +use crate::output::{self, Out}; + +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(out: &Out, feature: &BetaFeature) { + let stderr_is_terminal = io::stderr().is_terminal(); + + if !should_notify(out.interactive(), out.foreground(), 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), + } +} + +/// 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}; + + #[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_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 e52a3f11..1bc6ea19 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -1,17 +1,27 @@ 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, + 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; -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 +51,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), ) @@ -65,7 +75,13 @@ 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"), + .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( Command::new("credentials") @@ -107,11 +123,60 @@ 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"), + ), + ) + .subcommand(knowledge_cmd()) + .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), + ) + .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'", + ), ) } -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") { @@ -120,11 +185,16 @@ pub async fn do_list(config: Config, args: &ArgMatches) { args.get_one::("type").map(String::as_str) }; - let catalogs = output::with_spinner( - "Listing catalogs", - api::list_catalogs(&config, &env, all, catalog_type), - ) - .await; + if is_storage_catalog_type(catalog_type) { + beta::notify_once(out, &beta::STORAGE); + } + + let catalogs = out + .with_spinner( + "Listing catalogs", + api::list_catalogs(&config, &env, all, catalog_type), + ) + .await; let headers = vec!["Name", "Type", "Environment"] .into_iter() @@ -140,10 +210,12 @@ 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) { +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") .expect("catalog_name is required"); @@ -158,11 +230,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, @@ -173,22 +246,939 @@ 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(out: &output::Out, config: Config, args: &ArgMatches) { + let name = args + .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, + 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, full).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)) 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!["Namespace".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| { + 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(), + ), + 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, + full: bool, +) -> Result { + let mut spinner = out.spinner("Listing tables..."); + + let result = if full { + list_catalog_columns(config, name, env).await + } else { + list_catalog_tables(config, name, env).await + }; + + match result { + Ok(query_result) => { + spinner.success(out); + Ok(query_result) + } + Err(err) => { + spinner.failure(out); + 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)) } -pub async fn do_show(config: Config, args: &ArgMatches) { +/// 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 { + 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"); - match api::describe_catalog(&config, name, &env).await { - Ok(response) => { - let human = catalog_details_text(&response); - output::text(&human, &response); + 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"); + // 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. 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, + config: &Config, + name: &str, + env: &str, + sql: String, + write: bool, + limits: Limits, +) -> 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); + // 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)) => { + 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) + )); + } + } +} + +/// 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() { + 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 +} + +/// 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), + ), + ] +} + +/// 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, + truncated: None, + }) +} + +/// 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)); } - Err(err) => output::tower_error_and_die(err, "Fetching catalog details failed"), } + 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 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)); + 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 { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + other => other.to_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(), + } +} + +pub(crate) 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 { @@ -206,12 +1196,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 { @@ -347,7 +1349,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() }; @@ -392,9 +1394,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()), ], ), @@ -418,42 +1426,384 @@ fn snippets( snippets } -#[cfg(test)] -mod tests { - use super::{catalogs_cmd, parse_mode, snippets, token_export_command}; - use tower_api::models::{vend_catalog_credentials_body, CatalogCredentials}; - - #[test] - fn list_defaults_to_default_environment() { - let matches = catalogs_cmd() - .try_get_matches_from(["catalogs", "list"]) - .expect("list should parse with no args"); - - let (_, list_args) = matches.subcommand().expect("expected list subcommand"); - - assert_eq!( - list_args.get_one::("environment").unwrap(), - "default" - ); - assert_eq!(list_args.get_one::("all").copied(), Some(false)); - } - - #[test] - fn list_accepts_environment_flag() { - let matches = catalogs_cmd() - .try_get_matches_from(["catalogs", "list", "-e", "production"]) - .expect("list -e should parse"); - - let (_, list_args) = matches.subcommand().expect("expected list subcommand"); - - assert_eq!( - list_args.get_one::("environment").unwrap(), - "production" - ); - } - - #[test] - fn list_accepts_all_flag() { +/// 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::{ + 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() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "list"]) + .expect("list should parse with no args"); + + let (_, list_args) = matches.subcommand().expect("expected list subcommand"); + + assert_eq!( + list_args.get_one::("environment").unwrap(), + "default" + ); + assert_eq!(list_args.get_one::("all").copied(), Some(false)); + } + + #[test] + fn list_accepts_environment_flag() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "list", "-e", "production"]) + .expect("list -e should parse"); + + let (_, list_args) = matches.subcommand().expect("expected list subcommand"); + + assert_eq!( + list_args.get_one::("environment").unwrap(), + "production" + ); + } + + #[test] + fn list_accepts_all_flag() { let matches = catalogs_cmd() .try_get_matches_from(["catalogs", "list", "--all"]) .expect("list --all should parse"); @@ -495,6 +1845,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"]); @@ -575,6 +1963,231 @@ 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_query( + &setup, + "SELECT \"schema\", name FROM (SHOW ALL TABLES) WHERE database = ? ORDER BY \"schema\", name", + params!["memory"], + &Limits::none(), + ) + .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 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); + + 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 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!( + default.max_total_bytes, + Some(tower_duckdb::guard::AGENT_MAX_RESULT_BYTES) + ); + } + + #[test] + 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!( + 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 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!(query_limits(true, Some(25)).max_rows, Some(25)); + } + #[test] fn token_export_command_fetches_token_without_printing_it() { let credentials = CatalogCredentials::new( @@ -664,4 +2277,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/deploy.rs b/crates/tower-cmd/src/deploy.rs index c5328f38..74c864d9 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, } -pub async fn do_deploy(config: Config, args: &ArgMatches) { +/// 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(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); let target = if args.get_flag("all") { DeployTarget::All @@ -71,35 +114,37 @@ 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(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, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { debug!("Building package from directory: {:?}", dir); @@ -115,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(), @@ -123,26 +169,36 @@ 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).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, target: DeployTarget, + idempotency_key: Option, ) -> Result<(), crate::Error> { let (environment, all_environments) = match &target { DeployTarget::All => (None, true), @@ -150,11 +206,13 @@ async fn do_deploy_package( }; let res = util::deploy::deploy_app_package( + out, &api_config, &towerfile.app.name, package, environment, all_environments, + idempotency_key.as_deref(), ) .await; @@ -171,16 +229,50 @@ 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()) { + out.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 +280,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 +379,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/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 9eb84280..02cd8803 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; @@ -82,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" @@ -119,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); @@ -141,11 +146,32 @@ 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 + } + 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); @@ -156,9 +182,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); @@ -169,12 +199,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(); @@ -185,25 +215,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); @@ -304,3 +344,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/mcp.rs b/crates/tower-cmd/src/mcp.rs index 6d406c22..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") @@ -236,11 +248,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 +264,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 +277,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 +298,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 +474,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 @@ -725,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, @@ -745,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() { @@ -812,7 +891,23 @@ 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); + + // 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, + deploy_target, + idempotency_key, + ) + .await + { Ok(_) => Self::text_success("Deploy completed successfully".to_string()), Err(e) => Self::error_result("Deploy failed", e), } @@ -829,15 +924,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() { @@ -881,10 +978,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 862e8839..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,417 +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, +} + +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_json(self) -> bool { - matches!(self, Self::Json) + /// 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) } - pub fn is_normal(self) -> bool { - matches!(self, Self::Normal) + /// 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) } -} - -static OUTPUT_MODE: OnceLock = OnceLock::new(); -static CURRENT_SENDER: Mutex>> = Mutex::new(None); -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 fn set_output_mode(mode: OutputMode) { - OUTPUT_MODE.set(mode).ok(); - if mode.is_mcp() { - control::set_override(false); + fn is_json(&self) -> bool { + self.format == Format::Json } -} - -pub fn get_output_mode() -> OutputMode { - OUTPUT_MODE.get().copied().unwrap_or(OutputMode::Normal) -} - -pub fn set_current_sender(sender: UnboundedSender) { - *CURRENT_SENDER.lock().unwrap() = Some(sender); -} -pub fn clear_current_sender() { - *CURRENT_SENDER.lock().unwrap() = None; -} + /// 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 + } -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(); - } + /// 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 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)); + /// 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 success(msg: &str) { - success_with_data(msg, None::); -} - -pub fn success_with_data(msg: &str, data: Option) { - let mut response = serde_json::json!({ - "result": "success", - "message": msg - }); + pub fn newline(&self) { + self.write("\n"); + } - if let Some(data) = data { - response["data"] = serde_json::to_value(data).unwrap(); + pub fn banner(&self) { + self.write(BANNER_TEXT); } - let line = format!("{} {}\n", "Success!".green(), msg); - text(&line, &response); -} + 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 enum LogLineType { - Remote, - Local, -} + pub fn success(&self, msg: &str) { + self.success_with_data(msg, None::); + } -fn format_timestamp(timestamp: &str, t: LogLineType) -> String { - let ts = timestamp.bold(); + pub fn success_with_data(&self, msg: &str, data: Option) { + let mut response = serde_json::json!({ + "result": "success", + "message": msg + }); - let sep = "|".bold(); + if let Some(data) = data { + response["data"] = serde_json::to_value(data).unwrap(); + } - match t { - LogLineType::Remote => format!("{} {}", ts.yellow(), sep.yellow()), - LogLineType::Local => format!("{} {}", ts.green(), sep.green()), + let line = format!("{} {}\n", "Success!".green(), msg); + self.text(&line, &response); } -} - -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) + /// 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); } - 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 log_line(&self, timestamp: &str, message: &str, t: LogLineType) { + let line = format!("{} {}\n", format_timestamp(timestamp, t), message); + self.write(&line); + } -pub fn header(text: &str) { - let line = format!("{}\n", text.bold().green()); - write(&line); -} + 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 detail(label: &str, value: &str) { - let line = format!("{} {}\n", format!("{}:", label).bold().green(), value); - write(&line); -} + 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 title(text: &str) -> String { - text.bold().green().to_string() -} + let line = format!("{} {}\n", "Package error:".red(), msg); + self.write(&line); + } -pub fn placeholder(text: &str) -> String { - text.white().dimmed().italic().to_string() -} + 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) + } + }; -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", "Config 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) + // 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); } - // 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())); - } - for error in errors { - let msg = format!( - " • {}", - error.message.as_deref().unwrap_or("Unknown error") - ); - write(&format!("{}\n", msg.red())); + 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); } } -} -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; + 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); + } + } else { + for item in items { + let line = format!(" * {}\n", item); + let line = line.replace("\n", "\n "); + let line = format!("{}\n", line); + self.write(&line); + } } - }; + } - match err.status { - StatusCode::CONFLICT => { - output_full_error_details(&error_model); - } - StatusCode::UNPROCESSABLE_ENTITY => { - output_full_error_details(&error_model); - } - StatusCode::INTERNAL_SERVER_ERROR => { - error("The Tower API encountered an internal error. Maybe try again later on."); + /// 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); } - StatusCode::NOT_FOUND => { - 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::UNAUTHORIZED => { - error( - "You aren't authorized to do that! Are you logged in? Run `tower login` to login.", - ); + } + + 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, } - _ => { - 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."); + } + + /// 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())) @@ -446,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) { @@ -590,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..bedcec17 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 { @@ -84,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, }, }, ) @@ -91,12 +96,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 cc19fcb9..3ce5c5de 100644 --- a/crates/tower-cmd/src/util/deploy.rs +++ b/crates/tower-cmd/src/util/deploy.rs @@ -15,17 +15,19 @@ 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, content_type: &str, + idempotency_key: Option<&str>, progress_cb: Box, ) -> Result> { let package_hash = match compute_sha256_file(&file_path).await { 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"); } }; @@ -38,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 )); @@ -57,6 +59,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. @@ -102,11 +111,13 @@ 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, environment: Option<&str>, all_environments: bool, + idempotency_key: Option<&str>, ) -> Result> { let progress_bar = Arc::new(Mutex::new(output::progress_bar("Deploying to Tower..."))); @@ -122,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 @@ -146,10 +157,12 @@ pub async fn deploy_app_package( // Upload the package let response = upload_file_with_progress( + out, api_config, url, package_path, "application/tar", + idempotency_key, progress_callback, ) .await?; @@ -157,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 new file mode 100644 index 00000000..7ae6955b --- /dev/null +++ b/crates/tower-cmd/src/util/git.rs @@ -0,0 +1,141 @@ +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/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 }), ); 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}" + ); + } +} + diff --git a/dist-workspace.toml b/dist-workspace.toml index 93b242f5..b256677c 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -42,3 +42,16 @@ 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 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 = "messense/rust-musl-cross:aarch64-musl", host = "aarch64-unknown-linux-gnu" } diff --git a/plugin/skills/tower/SKILL.md b/plugin/skills/tower/SKILL.md index 760e46e1..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,6 +410,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. +- `--no-idempotency-key` — Never send an idempotency key, even on a clean git tree ### `tower run` diff --git a/pyproject.toml b/pyproject.toml index 829727a3..8d84c009 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "tower" -version = "0.3.69" +version = "0.3.70" description = "Tower CLI and runtime environment for Tower." authors = [{ name = "Tower Computing Inc.", email = "brad@tower.dev" }] readme = "README.md" @@ -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/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/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/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/integration/features/steps/mcp_steps.py b/tests/integration/features/steps/mcp_steps.py index 1fa07c3d..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, @@ -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}" 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: diff --git a/uv.lock b/uv.lock index 671d0068..1403d801 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" @@ -559,7 +698,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 +708,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 +718,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" }, @@ -626,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" @@ -642,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]] @@ -680,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]] @@ -846,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]] @@ -1155,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" @@ -1390,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" @@ -1476,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" @@ -1540,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" @@ -1657,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" @@ -2080,7 +2264,7 @@ wheels = [ [[package]] name = "tower" -version = "0.3.69" +version = "0.3.70" source = { editable = "." } dependencies = [ { name = "attrs" }, @@ -2165,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" }, @@ -2179,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" }, @@ -2198,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"