From feddb7eadba944489c0b35d3c3b5b31f5802630f Mon Sep 17 00:00:00 2001 From: Basile Marchand Date: Sat, 23 May 2026 18:27:57 +0200 Subject: [PATCH 1/5] ci: modernize release workflow, add pre-release support, fail-fast packaging - Replace archived actions/create-release@v1 and actions/upload-release-asset@v1 with softprops/action-gh-release@v2 (single step for tag + assets) - Add pre-release support via SemVer-suffixed tags (-rc.N, -beta.N, -alpha.N): auto-detected from the tag, propagated to prerelease/make_latest flags - Gate release on unitest + e2e jobs via workflow_call (release.yml now orchestrates build + unitest + e2e + release with explicit needs) - Make unitest.yml callable from other workflows (workflow_call trigger) - Drop continue-on-error on .deb and .rpm builds so packaging failures surface immediately instead of silently producing incomplete releases - Declare explicit contents: write permission on release workflow --- .github/workflows/build.yml | 2 - .github/workflows/release.yml | 202 +++++++++++++++------------------- .github/workflows/unitest.yml | 4 +- 3 files changed, 94 insertions(+), 114 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d64088b..66567e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,11 +94,9 @@ jobs: - name: Build Debian package run: cargo deb --target x86_64-unknown-linux-musl - continue-on-error: true # optional - name: Build RPM package run: cargo generate-rpm --target x86_64-unknown-linux-musl - continue-on-error: true # optional - name: Upload Debian package uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b26bb0b..00d6df0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,133 +3,113 @@ on: push: tags: - 'v*.*.*' + - 'v*.*.*-rc.*' + - 'v*.*.*-beta.*' + - 'v*.*.*-alpha.*' - '[0-9]+.*.*' + - '[0-9]+.*.*-rc.*' + - '[0-9]+.*.*-beta.*' + - '[0-9]+.*.*-alpha.*' + +permissions: + contents: write jobs: build: uses: ./.github/workflows/build.yml with: artifact-name: release - create-release: - runs-on: ubuntu-latest - outputs: - upload_url: ${{ steps.create_release.outputs.upload_url }} - version: ${{ steps.version.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Extract version from tag - id: version - run: | - # Extract version from tag (remove 'v' prefix if present) - TAG_NAME=${GITHUB_REF#refs/tags/} - VERSION=${TAG_NAME#v} - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT - - name: Verify version matches Cargo.toml - run: | - sudo apt-get update -y - sudo apt-get install -y wget - wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 - chmod +x /usr/local/bin/yq - - CARGO_VERSION=$(yq eval '.package.version' Cargo.toml) - if [ "$CARGO_VERSION" != "${{ steps.version.outputs.version }}" ]; then - echo "Error: Tag version (${{ steps.version.outputs.version }}) does not match Cargo.toml version ($CARGO_VERSION)" - exit 1 - fi - echo "Version verified: $CARGO_VERSION" - - name: release - uses: actions/create-release@v1 - id: create_release - with: - draft: false - body_path: CHANGELOG.md - prerelease: false - release_name: Release ${{ steps.version.outputs.version }} - tag_name: ${{ steps.version.outputs.tag }} - env: - GITHUB_TOKEN: ${{ github.token }} - upload-artifacts: + unitest: + uses: ./.github/workflows/unitest.yml + + e2e: + uses: ./.github/workflows/e2e.yml + needs: [build] + with: + artifact-id: ${{ needs.build.outputs.deb-artifact }} + + release: runs-on: ubuntu-latest - needs: [create-release,build] + needs: [build, unitest, e2e] steps: - - name: Download package - uses: actions/download-artifact@v4 - with: - artifact-ids: ${{ needs.build.outputs.deb-artifact }} - path: ./deb/ - merge-multiple: true + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: | + # Extract version from tag (remove 'v' prefix if present) + TAG_NAME=${GITHUB_REF#refs/tags/} + VERSION=${TAG_NAME#v} + # Detect pre-release: any SemVer build metadata or pre-release suffix + # is signaled by a '-' in the version string (e.g. 0.2.0-rc.1). + if [[ "$VERSION" == *-* ]]; then + PRERELEASE=true + else + PRERELEASE=false + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "prerelease=$PRERELEASE" >> "$GITHUB_OUTPUT" - - name: Find deb file - id: find-deb - run: echo "deb-file=$(find ./deb -name '*.deb' -type f | head -n1)" >> $GITHUB_OUTPUT + - name: Verify version matches Cargo.toml + run: | + sudo apt-get update -y + sudo apt-get install -y wget - - name: upload linux artifact - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ${{ steps.find-deb.outputs.deb-file }} - asset_name: ${{ github.event.repository.name }}_${{ needs.create-release.outputs.version }}.deb - asset_content_type: application/vnd.debian.binary-package - - - name: Download RPM package - uses: actions/download-artifact@v4 - with: - artifact-ids: ${{ needs.build.outputs.rpm-artifact }} - path: ./rpm/ - merge-multiple: true + wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + chmod +x /usr/local/bin/yq - - name: Find RPM file - id: find-rpm - run: echo "rpm-file=$(find ./rpm -name '*.rpm' -type f | head -n1)" >> $GITHUB_OUTPUT + CARGO_VERSION=$(yq eval '.package.version' Cargo.toml) + if [ "$CARGO_VERSION" != "${{ steps.version.outputs.version }}" ]; then + echo "Error: Tag version (${{ steps.version.outputs.version }}) does not match Cargo.toml version ($CARGO_VERSION)" + exit 1 + fi + echo "Version verified: $CARGO_VERSION" - - name: Upload RPM release asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ${{ steps.find-rpm.outputs.rpm-file }} - asset_name: ${{ github.event.repository.name }}_${{ needs.create-release.outputs.version }}.rpm - asset_content_type: application/x-rpm + - name: Download Debian package + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ needs.build.outputs.deb-artifact }} + path: ./deb/ + merge-multiple: true - - name: Download binary - uses: actions/download-artifact@v4 - with: - artifact-ids: ${{ needs.build.outputs.bin-artifact }} - path: ./bin/ - merge-multiple: true + - name: Download RPM package + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ needs.build.outputs.rpm-artifact }} + path: ./rpm/ + merge-multiple: true - - name: Create binary tarball - run: tar -czf ${{ github.event.repository.name }}_${{ needs.create-release.outputs.version }}.tar.gz -C ./bin . + - name: Download binary + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ needs.build.outputs.bin-artifact }} + path: ./bin/ + merge-multiple: true - - name: Upload binary tarball - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ./${{ github.event.repository.name }}_${{ needs.create-release.outputs.version }}.tar.gz - asset_name: ${{ github.event.repository.name }}_${{ needs.create-release.outputs.version }}.tar.gz - asset_content_type: application/gzip + - name: Download man page + uses: actions/download-artifact@v4 + with: + artifact-ids: ${{ needs.build.outputs.man-artifact }} + path: ./man/ + merge-multiple: true - - name: Download man page - uses: actions/download-artifact@v4 - with: - artifact-ids: ${{ needs.build.outputs.man-artifact }} - path: ./man/ - merge-multiple: true + - name: Create binary tarball + run: tar -czf ${{ github.event.repository.name }}_${{ steps.version.outputs.version }}.tar.gz -C ./bin . - - name: Upload man page - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ github.token }} - with: - upload_url: ${{ needs.create-release.outputs.upload_url }} - asset_path: ./man/cave.1 - asset_name: cave.1 - asset_content_type: application/x-troff-man \ No newline at end of file + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: Release ${{ steps.version.outputs.version }} + body_path: CHANGELOG.md + draft: false + prerelease: ${{ steps.version.outputs.prerelease }} + make_latest: ${{ steps.version.outputs.prerelease == 'true' && 'false' || 'true' }} + files: | + ./deb/*.deb + ./rpm/*.rpm + ./${{ github.event.repository.name }}_${{ steps.version.outputs.version }}.tar.gz + ./man/cave.1 diff --git a/.github/workflows/unitest.yml b/.github/workflows/unitest.yml index b42d26d..6937c7f 100644 --- a/.github/workflows/unitest.yml +++ b/.github/workflows/unitest.yml @@ -1,5 +1,7 @@ name: unitests -on: [push] +on: + push: + workflow_call: jobs: run-cargo-unitests: runs-on: ubuntu-latest From 5ea7b41930accf85b2ee1805f52d578c4509c1c9 Mon Sep 17 00:00:00 2001 From: Basile Marchand Date: Sat, 23 May 2026 18:29:13 +0200 Subject: [PATCH 2/5] ci: harmonize action versions and tighten permissions - Bump actions/checkout to v5 in doc.yml and release.yml (was v4) - Replace unmaintained actions-rs/toolchain@v1 with dtolnay/rust-toolchain@stable in doc.yml and unitest.yml - Declare explicit contents: read permissions on build.yml, unitest.yml, e2e.yml, pr.yml (defense-in-depth, doc.yml already had its block) - Remove 'edited' from pr.yml trigger types to avoid rebuilds on PR title/description edits --- .github/workflows/build.yml | 3 +++ .github/workflows/doc.yml | 7 ++----- .github/workflows/e2e.yml | 4 ++++ .github/workflows/pr.yml | 5 ++++- .github/workflows/release.yml | 2 +- .github/workflows/unitest.yml | 9 +++++---- 6 files changed, 19 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66567e4..c2744f5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,9 @@ on: description: "Man page artifact ID" value: ${{ jobs.build-binary-and-package.outputs.man-artifact }} +permissions: + contents: read + jobs: build-binary-and-package: runs-on: ubuntu-latest diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 1546b41..bcc7948 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -16,7 +16,7 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: persist-credentials: false - name: Install dependencies @@ -24,10 +24,7 @@ jobs: sudo apt-get update sudo apt-get install -y pkg-config libssl-dev protobuf-compiler - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true + uses: dtolnay/rust-toolchain@stable - name: build doc run: cargo doc --no-deps --release - name: add index file diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a9f6e74..1d4b519 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -5,6 +5,10 @@ on: artifact-id: required: true type: string + +permissions: + contents: read + jobs: run-e2e-tests: runs-on: ubuntu-22.04 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 7e74cfb..23933cc 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,7 +2,10 @@ name: PR Build and Tests on: pull_request: branches: [main] - types: [opened, reopened, synchronize, edited] + types: [opened, reopened, synchronize] + +permissions: + contents: read jobs: build: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00d6df0..ad7300b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: needs: [build, unitest, e2e] steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Extract version from tag id: version diff --git a/.github/workflows/unitest.yml b/.github/workflows/unitest.yml index 6937c7f..6b6f7ee 100644 --- a/.github/workflows/unitest.yml +++ b/.github/workflows/unitest.yml @@ -2,6 +2,10 @@ name: unitests on: push: workflow_call: + +permissions: + contents: read + jobs: run-cargo-unitests: runs-on: ubuntu-latest @@ -14,10 +18,7 @@ jobs: sudo apt-get update sudo apt-get install -y pkg-config libssl-dev protobuf-compiler - name: Install Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true + uses: dtolnay/rust-toolchain@stable - name: Pull Docker image run: docker pull simvia/code_aster:17.3.1 - name: Run tests From ad39d0c26b2731cd866916f7c176a73284e65ea9 Mon Sep 17 00:00:00 2001 From: Basile Marchand Date: Sat, 23 May 2026 18:31:11 +0200 Subject: [PATCH 3/5] ci: extract setup-rust composite action and rename unittest workflow - Add reusable composite action .github/actions/setup-rust that installs apt build deps and a stable Rust toolchain, with optional cross-compile target and a sudo toggle for in-container usage - Refactor build.yml, doc.yml, and unittest.yml to use the composite, removing duplicated install steps - Rename unitest.yml to unittest.yml (typo fix); update job names and the workflow_call reference in release.yml accordingly --- .github/actions/setup-rust/action.yml | 43 +++++++++++++++++++ .github/workflows/build.yml | 11 ++--- .github/workflows/doc.yml | 8 +--- .github/workflows/release.yml | 6 +-- .../workflows/{unitest.yml => unittest.yml} | 14 +++--- 5 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 .github/actions/setup-rust/action.yml rename .github/workflows/{unitest.yml => unittest.yml} (52%) diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 0000000..f1d71fe --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,43 @@ +name: "Setup Rust" +description: > + Install system build dependencies and a stable Rust toolchain. + Optionally adds a cross-compilation target. Does not configure cargo cache; + callers manage their own cache strategy. + +inputs: + target: + description: "Optional rustup target to add (e.g. x86_64-unknown-linux-musl)." + required: false + default: "" + apt-packages: + description: "Space-separated list of apt packages to install." + required: false + default: "pkg-config libssl-dev protobuf-compiler" + sudo: + description: > + Whether to prefix apt-get calls with sudo. Set to "false" when running + inside a container where sudo is unavailable (e.g. rust:bullseye). + required: false + default: "true" + +runs: + using: "composite" + steps: + - name: Install system dependencies + shell: bash + run: | + if [ "${{ inputs.sudo }}" = "true" ]; then + SUDO="sudo" + else + SUDO="" + fi + $SUDO apt-get update + $SUDO apt-get install -y ${{ inputs.apt-packages }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Add rustup target + if: ${{ inputs.target != '' }} + shell: bash + run: rustup target add ${{ inputs.target }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2744f5..1e4a6d3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,11 +58,12 @@ jobs: restore-keys: | ${{ runner.os }}-cargo-target- - - name: Install system dependencies - run: apt-get update && apt-get install -y protobuf-compiler musl musl-dev musl-tools pkg-config - - - name: Install rustup depencencies - run: rustup target add x86_64-unknown-linux-musl + - name: Install system dependencies and Rust toolchain + uses: ./.github/actions/setup-rust + with: + target: x86_64-unknown-linux-musl + apt-packages: "protobuf-compiler musl musl-dev musl-tools pkg-config" + sudo: "false" - name: Build release binary run: cargo build --release --target x86_64-unknown-linux-musl diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index bcc7948..46be533 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -19,12 +19,8 @@ jobs: uses: actions/checkout@v5 with: persist-credentials: false - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libssl-dev protobuf-compiler - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - name: Install system dependencies and Rust toolchain + uses: ./.github/actions/setup-rust - name: build doc run: cargo doc --no-deps --release - name: add index file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad7300b..9b6383e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,8 +20,8 @@ jobs: with: artifact-name: release - unitest: - uses: ./.github/workflows/unitest.yml + unittest: + uses: ./.github/workflows/unittest.yml e2e: uses: ./.github/workflows/e2e.yml @@ -31,7 +31,7 @@ jobs: release: runs-on: ubuntu-latest - needs: [build, unitest, e2e] + needs: [build, unittest, e2e] steps: - name: Checkout repository uses: actions/checkout@v5 diff --git a/.github/workflows/unitest.yml b/.github/workflows/unittest.yml similarity index 52% rename from .github/workflows/unitest.yml rename to .github/workflows/unittest.yml index 6b6f7ee..81d5440 100644 --- a/.github/workflows/unitest.yml +++ b/.github/workflows/unittest.yml @@ -1,4 +1,4 @@ -name: unitests +name: unittests on: push: workflow_call: @@ -7,19 +7,15 @@ permissions: contents: read jobs: - run-cargo-unitests: + run-cargo-unittests: runs-on: ubuntu-latest steps: - run: docker info - name: Check out repository code uses: actions/checkout@v5 - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libssl-dev protobuf-compiler - - name: Install Rust - uses: dtolnay/rust-toolchain@stable + - name: Install system dependencies and Rust toolchain + uses: ./.github/actions/setup-rust - name: Pull Docker image run: docker pull simvia/code_aster:17.3.1 - name: Run tests - run: cargo test \ No newline at end of file + run: cargo test From dcf9f72b891d004ed1e6c5227a2056d697545f99 Mon Sep 17 00:00:00 2001 From: Basile Marchand Date: Sat, 23 May 2026 18:40:06 +0200 Subject: [PATCH 4/5] ci: allow manual build dispatch for artifact testing - Add workflow_dispatch trigger to build.yml so the build can be launched manually from the Actions UI on any branch - Default artifact-name prefix is 'manual-' (overridable from the UI) to avoid collision with automatic build artifacts - Make the artifact-name expression null-safe (|| '') to handle the three trigger contexts uniformly (push/workflow_dispatch/workflow_call) --- .github/workflows/build.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e4a6d3..a6c875f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,13 @@ name: Builds on: push: branches: [main] + workflow_dispatch: + inputs: + artifact-name: + description: "Prefix for artifact names (e.g. 'manual-', 'test-rc1-')" + required: false + default: "manual-" + type: string workflow_call: inputs: artifact-name: @@ -72,14 +79,14 @@ jobs: uses: actions/upload-artifact@v4 id: upload-bin with: - name: ${{ inputs.artifact-name }}compiled-binary + name: ${{ inputs.artifact-name || '' }}compiled-binary path: target/x86_64-unknown-linux-musl/release/cave - name: Upload man page uses: actions/upload-artifact@v4 id: upload-man with: - name: ${{ inputs.artifact-name }}man-page + name: ${{ inputs.artifact-name || '' }}man-page path: man/cave.1 - name: Cache cargo tools @@ -106,12 +113,12 @@ jobs: uses: actions/upload-artifact@v4 id: upload-deb with: - name: ${{ inputs.artifact-name }}deb-package + name: ${{ inputs.artifact-name || '' }}deb-package path: target/x86_64-unknown-linux-musl/debian/* - name: Upload RPM package uses: actions/upload-artifact@v4 id: upload-rpm with: - name: ${{ inputs.artifact-name }}rpm-package + name: ${{ inputs.artifact-name || '' }}rpm-package path: target/x86_64-unknown-linux-musl/generate-rpm/* \ No newline at end of file From 38a02da9cb05c8af28bd6cf5d71923ecd7bcfee5 Mon Sep 17 00:00:00 2001 From: Basile Marchand Date: Sat, 23 May 2026 18:44:31 +0200 Subject: [PATCH 5/5] Qarnot cloud computing interface --- Cargo.lock | 1270 +++++++++++++++++++++++++++++++---- Cargo.toml | 8 +- src/cli.rs | 90 +++ src/main.rs | 33 +- src/manage.rs | 23 +- src/qarnot/bucket.rs | 235 +++++++ src/qarnot/client.rs | 207 ++++++ src/qarnot/config.rs | 276 ++++++++ src/qarnot/error.rs | 82 +++ src/qarnot/export_parser.rs | 162 +++++ src/qarnot/mod.rs | 26 + src/qarnot/registry.rs | 102 +++ src/qarnot/task.rs | 389 +++++++++++ src/qarnot_commands.rs | 456 +++++++++++++ 14 files changed, 3224 insertions(+), 135 deletions(-) create mode 100644 src/qarnot/bucket.rs create mode 100644 src/qarnot/client.rs create mode 100644 src/qarnot/config.rs create mode 100644 src/qarnot/error.rs create mode 100644 src/qarnot/export_parser.rs create mode 100644 src/qarnot/mod.rs create mode 100644 src/qarnot/registry.rs create mode 100644 src/qarnot/task.rs create mode 100644 src/qarnot_commands.rs diff --git a/Cargo.lock b/Cargo.lock index c524821..0cce040 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,21 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aho-corasick" version = "1.1.3" @@ -26,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -113,6 +104,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" @@ -120,18 +117,415 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "backtrace" -version = "0.3.75" +name = "aws-config" +version = "1.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2" dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", + "aws-credential-types", + "aws-runtime", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 1.4.0", + "time", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ed8e8c52d2dc2390ad9f15647fe663f71e9780b4262c190fbb823a32721566" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.133.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "237aba2985e3c0a83e199cc7aa9a64a16c599875bc98170f00932f6199f19922" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.104.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aa6622798e19e6a76b690562085dd4771c736cd48343464a53ab4ae2f2c9f84" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e8e65f4f81fcccdeb6c3eca2af17ac21d421a1786a26a394aecf421d616d3a" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.14", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.40", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517089205f18ab4adc5a3e02888cb139bbbbb2e168eac9f396216925d1fbeaf5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc117c179ecf39a62a0a3f49f600e9ac26a7ad7dd172177999f83933af776c32" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056b66dbce2f81cc0c1e2b05bb402eb58f8a3530479d650efadd5bbae9a4050b" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", ] [[package]] @@ -140,6 +534,22 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -152,6 +562,15 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.12.0" @@ -171,9 +590,19 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytes-utils" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] [[package]] name = "cave" @@ -181,6 +610,11 @@ version = "0.1.8" dependencies = [ "anyhow", "assert_cmd", + "aws-config", + "aws-credential-types", + "aws-sdk-s3", + "aws-smithy-runtime-api", + "aws-smithy-types", "chrono", "chrono-tz", "clap", @@ -205,10 +639,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -228,8 +665,9 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -313,6 +751,21 @@ dependencies = [ "roff", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colorchoice" version = "1.0.4" @@ -329,6 +782,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation" version = "0.9.4" @@ -339,18 +798,114 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -389,6 +944,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.15.0" @@ -449,6 +1010,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "float-cmp" version = "0.9.0" @@ -464,6 +1031,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -488,6 +1061,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.31" @@ -536,6 +1115,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -559,12 +1148,6 @@ dependencies = [ "wasi 0.14.2+wasi-0.2.4", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "h2" version = "0.3.27" @@ -576,7 +1159,26 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -590,12 +1192,38 @@ version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "0.2.12" @@ -607,6 +1235,16 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -614,7 +1252,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -630,6 +1291,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -640,9 +1310,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -654,6 +1324,27 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.24.2" @@ -661,11 +1352,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", - "http", - "hyper", - "rustls", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.40", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.5.10", "tokio", - "tokio-rustls", + "tower-service", + "tracing", ] [[package]] @@ -806,18 +1537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown", -] - -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", + "hashbrown 0.15.4", ] [[package]] @@ -843,9 +1563,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" @@ -871,6 +1591,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.77" @@ -889,9 +1619,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.174" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" @@ -931,6 +1661,25 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.7.5" @@ -943,24 +1692,15 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - [[package]] name = "mio" -version = "1.0.4" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -970,21 +1710,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" [[package]] -name = "num-traits" -version = "0.2.19" +name = "num-conv" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "autocfg", + "num-traits", ] [[package]] -name = "object" -version = "0.36.7" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "memchr", + "autocfg", ] [[package]] @@ -1025,6 +1771,12 @@ dependencies = [ "syn", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-src" version = "300.5.2+3.5.2" @@ -1053,6 +1805,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot" version = "0.12.4" @@ -1171,6 +1929,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "predicates" version = "2.1.5" @@ -1214,18 +1978,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1294,6 +2058,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -1306,16 +2076,16 @@ version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ - "base64", + "base64 0.21.7", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", - "hyper", - "hyper-rustls", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", "ipnet", "js-sys", "log", @@ -1323,7 +2093,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", @@ -1331,7 +2101,7 @@ dependencies = [ "sync_wrapper", "system-configuration", "tokio", - "tokio-rustls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", @@ -1362,10 +2132,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88f8660c1ff60292143c98d08fc6e2f654d722db50410e3f3797d40baaf9d8f3" [[package]] -name = "rustc-demangle" -version = "0.1.26" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] [[package]] name = "rustix" @@ -1388,17 +2161,52 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", + "base64 0.21.7", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", ] [[package]] @@ -1411,6 +2219,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1419,9 +2239,18 @@ checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] name = "scopeguard" @@ -1439,6 +2268,29 @@ dependencies = [ "untrusted", ] +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.9.1", + "core-foundation 0.10.1", + "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.27" @@ -1447,18 +2299,28 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -1489,6 +2351,28 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.3", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1534,14 +2418,20 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1554,11 +2444,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" -version = "2.0.104" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -1589,7 +2485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1652,6 +2548,36 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -1664,29 +2590,26 @@ dependencies = [ [[package]] name = "tokio" -version = "1.47.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.6.0", + "socket2 0.6.3", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -1699,15 +2622,25 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "rustls", + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", "tokio", ] [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -1716,6 +2649,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -1724,19 +2673,31 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -1747,6 +2708,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.18" @@ -1770,6 +2737,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1799,6 +2772,18 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -1949,7 +2934,7 @@ checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.1.3", "windows-result", "windows-strings", ] @@ -1982,13 +2967,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]] @@ -1997,7 +2988,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]] @@ -2027,6 +3018,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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.48.5" @@ -2173,6 +3173,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.0" @@ -2218,6 +3224,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 1a1ceaf..72b2b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,13 +17,19 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" term_size = "0.3" colored = "2.0" -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } chrono-tz = "0.8" uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["full"] } log = "0.4.28" env_logger = "0.11.8" semver = "1.0.27" +# Qarnot integration +aws-sdk-s3 = { version = "1.65", default-features = false, features = ["rustls", "rt-tokio"] } +aws-config = { version = "1.5", default-features = false, features = ["rustls", "rt-tokio"] } +aws-credential-types = "1.2" +aws-smithy-runtime-api = "1.7" +aws-smithy-types = "1.2" [build-dependencies] clap = { version = "4.5.21", features = ["derive"] } diff --git a/src/cli.rs b/src/cli.rs index 5aa88ec..44f02e0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -44,6 +44,96 @@ pub enum Command { #[command(subcommand)] action: ConfigAction, }, + /// Submit a code_aster job to Qarnot cloud + Submit { + /// Path to the .export file to submit. + export_file: String, + /// Optional job name (default: derived from the export filename). + #[arg(short, long)] + name: Option, + /// Override the hardware constraint configured in .qarnotconfig. + #[arg(long)] + hardware: Option, + /// Wait for the task to complete and download results in the current directory. + #[arg(short, long)] + wait: bool, + }, + /// Manage Qarnot cloud jobs + Qarnot { + #[command(subcommand)] + action: QarnotAction, + }, +} + +#[derive(Subcommand, Debug)] +pub enum QarnotAction { + /// Configure Qarnot credentials and defaults. + Config { + /// Qarnot API token. + #[arg(long)] + token: Option, + /// Qarnot account email (used as S3 access key id). + #[arg(long)] + email: Option, + /// Override the API URL (defaults to https://api.qarnot.com). + #[arg(long)] + api_url: Option, + /// Profile name (defaults to "code-aster"). + #[arg(long)] + profile: Option, + /// Docker tag passed as the DOCKER_TAG constant. + #[arg(long)] + docker_tag: Option, + /// Number of cluster slots (constant SETUP_CLUSTER_NB_SLOTS). + #[arg(long)] + slots: Option, + /// Hardware constraint string. + #[arg(long)] + hardware: Option, + /// Write to ./.qarnotconfig (local override) instead of ~/.qarnotconfig. + #[arg(long)] + local: bool, + }, + /// Show the resolved Qarnot configuration (global + local merge). + Show, + /// List jobs submitted from this machine. + List, + /// Show the status of a Qarnot job. + Status { + /// Job UUID. + uuid: String, + }, + /// Download the result bucket of a Qarnot job into a local directory. + Download { + /// Job UUID. + uuid: String, + /// Output directory (defaults to ./-results). + #[arg(short, long)] + output: Option, + }, + /// Print the stdout accumulated by a Qarnot job. + Stdout { + /// Job UUID. + uuid: String, + }, + /// Print the stderr accumulated by a Qarnot job. + Stderr { + /// Job UUID. + uuid: String, + }, + /// Abort a running Qarnot job. + Abort { + /// Job UUID. + uuid: String, + }, + /// Delete a Qarnot job (and optionally its bucket). + Delete { + /// Job UUID. + uuid: String, + /// Also delete the associated bucket. + #[arg(long)] + purge: bool, + }, } #[derive(Subcommand, Debug)] diff --git a/src/main.rs b/src/main.rs index 37758f5..cdb9d67 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,15 +12,18 @@ mod cli; mod config; mod docker; mod manage; +mod qarnot; +mod qarnot_commands; mod telemetry; use clap::Parser; -use cli::{Cli, Command, ConfigAction}; +use cli::{Cli, Command, ConfigAction, QarnotAction}; use config::*; use env_logger::Builder; use log::debug; use log::LevelFilter; use manage::*; +use qarnot_commands::*; use std::env; use std::io; use std::process; @@ -94,6 +97,34 @@ fn main() -> io::Result<()> { // ConfigAction::EraseRegistry => set_registry(None), } } + Command::Submit { + export_file, + name, + hardware, + wait, + } => submit_qarnot(export_file, name, hardware, wait), + Command::Qarnot { action } => match action { + QarnotAction::Config { + token, + email, + api_url, + profile, + docker_tag, + slots, + hardware, + local, + } => qarnot_config_set( + token, email, api_url, profile, docker_tag, slots, hardware, local, + ), + QarnotAction::Show => qarnot_config_show(), + QarnotAction::List => qarnot_list_jobs(), + QarnotAction::Status { uuid } => qarnot_status(&uuid), + QarnotAction::Download { uuid, output } => qarnot_download(&uuid, output), + QarnotAction::Stdout { uuid } => qarnot_stdout(&uuid), + QarnotAction::Stderr { uuid } => qarnot_stderr(&uuid), + QarnotAction::Abort { uuid } => qarnot_abort(&uuid), + QarnotAction::Delete { uuid, purge } => qarnot_delete(&uuid, purge), + }, }; if let Err(e) = result { diff --git a/src/manage.rs b/src/manage.rs index aabb342..fb7a95c 100644 --- a/src/manage.rs +++ b/src/manage.rs @@ -61,6 +61,8 @@ pub enum CaveError { TelemetryError(String), /// Error parsing version from GitHub VersionParseError(String), + /// Qarnot integration error (API, S3, config). + QarnotError(String), } impl fmt::Display for CaveError { @@ -98,6 +100,8 @@ impl fmt::Display for CaveError { write!(f, "telemetry error: {}", msg), CaveError::VersionParseError(msg) => write!(f, "Version parse error: {}", msg), + CaveError::QarnotError(msg) => + write!(f, "Qarnot error: {}", msg), } } } @@ -108,6 +112,12 @@ impl From for CaveError { } } +impl From for CaveError { + fn from(e: crate::qarnot::error::QarnotError) -> Self { + CaveError::QarnotError(e.to_string()) + } +} + /// Sets the `code_aster` version to use, with an option to set it as the default. /// /// - If `version` is `"stable"` or `"testing"`, resolves to the real version via [`version_under_tag`]. @@ -209,12 +219,18 @@ pub fn run_aster(args: &Vec) -> Result<(), CaveError> { _ => (None, args.to_vec()), }; - docker_aster(&version, DockerMode::RunAster { export_file: &export, args: &rest_args })?; + docker_aster( + &version, + DockerMode::RunAster { + export_file: &export, + args: &rest_args, + }, + )?; Ok(()) } -/// Start interactive shell in the container -/// +/// Start interactive shell in the container +/// /// # Errors /// - [`CaveError::VersionNotInstalled`] if the configured version is not installed locally. /// - [`CaveError::FileNotFound`] if the `.export` file does not exist. @@ -230,7 +246,6 @@ pub fn shell_aster() -> Result<(), CaveError> { Ok(()) } - /// Prints a list of locally available versions filtered by an optionnal prefix. /// /// # Example diff --git a/src/qarnot/bucket.rs b/src/qarnot/bucket.rs new file mode 100644 index 0000000..bac2f64 --- /dev/null +++ b/src/qarnot/bucket.rs @@ -0,0 +1,235 @@ +//! S3-compatible bucket operations against Qarnot's storage backend. +//! +//! Qarnot exposes its object storage through an S3-compatible endpoint that +//! uses the user email as the access key and the API token as the secret key. +//! This module wraps `aws-sdk-s3` with a small synchronous facade so the rest +//! of cave doesn't have to deal with `async`/`tokio` runtimes. + +use crate::qarnot::error::QarnotError; +use aws_sdk_s3::config::{Credentials, Region}; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::Client as S3Client; +use std::path::{Path, PathBuf}; +use tokio::runtime::Runtime; + +/// Build an S3 client targeting the Qarnot storage endpoint. +/// +/// # Arguments +/// * `endpoint_url` - URL returned by `GET /settings` (or the user-supplied one). +/// * `email` - Qarnot account email; used as `aws_access_key_id`. +/// * `api_token` - Qarnot API token; used as `aws_secret_access_key`. +pub fn build_s3_client(endpoint_url: &str, email: &str, api_token: &str) -> S3Client { + let credentials = Credentials::new(email, api_token, None, None, "qarnot"); + let cfg = aws_sdk_s3::Config::builder() + .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest()) + .endpoint_url(endpoint_url) + .region(Region::new("qarnot")) + .credentials_provider(credentials) + .force_path_style(true) + .build(); + S3Client::from_conf(cfg) +} + +/// Synchronous wrapper around an [`S3Client`] dedicated to one bucket. +pub struct Bucket { + pub name: String, + client: S3Client, + runtime: Runtime, +} + +impl Bucket { + /// Build a new [`Bucket`] handle. This does *not* create the bucket on the + /// remote side. + pub fn new(name: String, client: S3Client) -> Result { + let runtime = Runtime::new().map_err(|e| { + QarnotError::S3Error(format!("Failed to start tokio runtime: {}", e)) + })?; + Ok(Self { + name, + client, + runtime, + }) + } + + /// Create the bucket on the remote side. Idempotent: if the bucket already + /// exists for this user, the call silently succeeds. + pub fn create(&self) -> Result<(), QarnotError> { + let client = self.client.clone(); + let name = self.name.clone(); + self.runtime.block_on(async move { + match client.create_bucket().bucket(&name).send().await { + Ok(_) => Ok(()), + Err(err) => { + let msg = err.to_string(); + // Both AWS S3 and most S3-compatible backends return + // either `BucketAlreadyOwnedByYou` or `BucketAlreadyExists` + // when the bucket is already present. + if msg.contains("BucketAlreadyOwnedByYou") + || msg.contains("BucketAlreadyExists") + { + Ok(()) + } else { + Err(QarnotError::BucketError(format!( + "Failed to create bucket '{}': {}", + name, msg + ))) + } + } + } + }) + } + + /// Upload a single local file at the bucket root. The remote key is the + /// file's basename. + pub fn upload_file(&self, local_path: &Path) -> Result<(), QarnotError> { + let key = local_path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + QarnotError::S3Error(format!( + "Invalid file name: {}", + local_path.display() + )) + })? + .to_string(); + let bucket = self.name.clone(); + let client = self.client.clone(); + let path = local_path.to_path_buf(); + + self.runtime.block_on(async move { + let body = ByteStream::from_path(&path).await.map_err(|e| { + QarnotError::S3Error(format!( + "Cannot read '{}': {}", + path.display(), + e + )) + })?; + client + .put_object() + .bucket(&bucket) + .key(&key) + .body(body) + .send() + .await + .map_err(|e| { + QarnotError::S3Error(format!( + "Upload of '{}' to bucket '{}' failed: {}", + path.display(), + bucket, + e + )) + })?; + Ok::<(), QarnotError>(()) + }) + } + + /// Upload several files at the bucket root, printing a progress line per + /// file. + pub fn upload_files(&self, files: &[PathBuf]) -> Result<(), QarnotError> { + for (idx, file) in files.iter().enumerate() { + println!( + " [{}/{}] {}", + idx + 1, + files.len(), + file.display() + ); + self.upload_file(file)?; + } + Ok(()) + } + + /// List every object key in the bucket. + pub fn list_keys(&self) -> Result, QarnotError> { + let bucket = self.name.clone(); + let client = self.client.clone(); + + self.runtime.block_on(async move { + let mut keys: Vec = Vec::new(); + let mut continuation: Option = None; + loop { + let mut req = client.list_objects_v2().bucket(&bucket); + if let Some(token) = &continuation { + req = req.continuation_token(token); + } + let resp = req.send().await.map_err(|e| { + QarnotError::S3Error(format!( + "ListObjectsV2 on '{}' failed: {}", + bucket, e + )) + })?; + if let Some(contents) = resp.contents { + for obj in contents { + if let Some(k) = obj.key { + keys.push(k); + } + } + } + let truncated = resp.is_truncated.unwrap_or(false); + if !truncated { + break; + } + continuation = resp.next_continuation_token; + } + Ok::, QarnotError>(keys) + }) + } + + /// Download every object in the bucket into `output_dir`. Subdirectories + /// in object keys (`/`-separated) are created on the fly. + pub fn download_all(&self, output_dir: &Path) -> Result { + if !output_dir.exists() { + std::fs::create_dir_all(output_dir).map_err(|e| { + QarnotError::S3Error(format!( + "Cannot create output dir {}: {}", + output_dir.display(), + e + )) + })?; + } + + let keys = self.list_keys()?; + let bucket = self.name.clone(); + let client = self.client.clone(); + let out = output_dir.to_path_buf(); + let total = keys.len(); + + self.runtime.block_on(async move { + for (idx, key) in keys.iter().enumerate() { + println!(" [{}/{}] {}", idx + 1, total, key); + let resp = client + .get_object() + .bucket(&bucket) + .key(key) + .send() + .await + .map_err(|e| { + QarnotError::S3Error(format!( + "GetObject '{}' from '{}' failed: {}", + key, bucket, e + )) + })?; + let local_path = out.join(key); + if let Some(parent) = local_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + QarnotError::S3Error(format!( + "Cannot create dir {}: {}", + parent.display(), + e + )) + })?; + } + let bytes = resp.body.collect().await.map_err(|e| { + QarnotError::S3Error(format!("Read body of '{}': {}", key, e)) + })?; + std::fs::write(&local_path, bytes.into_bytes()).map_err(|e| { + QarnotError::S3Error(format!( + "Write {}: {}", + local_path.display(), + e + )) + })?; + } + Ok::(total) + }) + } +} diff --git a/src/qarnot/client.rs b/src/qarnot/client.rs new file mode 100644 index 0000000..a2a68f9 --- /dev/null +++ b/src/qarnot/client.rs @@ -0,0 +1,207 @@ +//! HTTP client wrapping the Qarnot REST API. +//! +//! The client takes care of authentication (the API token is sent as the raw +//! `Authorization` header value, matching the behaviour of the official +//! Python SDK) and exposes a small set of synchronous methods around the +//! endpoints cave actually needs: +//! +//! - submit a task, +//! - retrieve a task status, +//! - read stdout/stderr, +//! - abort or delete a task. +//! +//! The S3 client used for bucket operations is built once at construction +//! time and re-used by [`crate::qarnot::bucket::Bucket`]. + +use crate::qarnot::bucket::{build_s3_client, Bucket}; +use crate::qarnot::config::QarnotConfig; +use crate::qarnot::error::QarnotError; +use crate::qarnot::task::{TaskPayload, TaskStatus, TaskSubmitResponse}; +use aws_sdk_s3::Client as S3Client; +use reqwest::blocking::{Client as HttpClient, Response}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT}; +use std::time::Duration; + +const CAVE_USER_AGENT: &str = concat!("cave/", env!("CARGO_PKG_VERSION")); +const HTTP_TIMEOUT_SECS: u64 = 60; + +/// High-level Qarnot client used by every cave subcommand. +pub struct QarnotClient { + http: HttpClient, + api_url: String, + #[allow(dead_code)] + api_token: String, + s3_client: S3Client, + #[allow(dead_code)] + config: QarnotConfig, +} + +impl QarnotClient { + /// Build a [`QarnotClient`] from a resolved configuration. + /// + /// If `config.storage_url` is `None`, the storage URL is fetched once + /// from `GET /settings`. + pub fn new(mut config: QarnotConfig) -> Result { + let mut headers = HeaderMap::new(); + let auth_value = HeaderValue::from_str(&config.api_token) + .map_err(|e| QarnotError::ConfigError(format!("Invalid API token: {}", e)))?; + headers.insert(AUTHORIZATION, auth_value); + headers.insert(USER_AGENT, HeaderValue::from_static(CAVE_USER_AGENT)); + + let http = HttpClient::builder() + .default_headers(headers) + .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS)) + .build() + .map_err(|e| QarnotError::HttpError(format!("Cannot build HTTP client: {}", e)))?; + + // Resolve storage URL via /settings if not provided. + if config.storage_url.is_none() { + let url = format!("{}/settings", config.api_url.trim_end_matches('/')); + let resp = http.get(&url).send()?; + let status = resp.status(); + if !status.is_success() { + return Err(QarnotError::ApiError { + code: status.as_u16(), + message: resp.text().unwrap_or_default(), + }); + } + let value: serde_json::Value = resp.json()?; + let storage = value + .get("storage") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + QarnotError::ConfigError( + "Qarnot /settings did not return a storage URL".to_string(), + ) + })?; + config.storage_url = Some(storage); + } + + let storage_url = config.storage_url.clone().unwrap(); + let s3_client = build_s3_client(&storage_url, &config.email, &config.api_token); + + Ok(Self { + http, + api_url: config.api_url.trim_end_matches('/').to_string(), + api_token: config.api_token.clone(), + s3_client, + config, + }) + } + + /// Resolved configuration used to build this client. + #[allow(dead_code)] + pub fn config(&self) -> &QarnotConfig { + &self.config + } + + /// Get a [`Bucket`] handle wrapping the embedded S3 client. + pub fn bucket(&self, name: impl Into) -> Result { + Bucket::new(name.into(), self.s3_client.clone()) + } + + /// Build a full URL by appending `path` to the API base URL. + fn url(&self, path: &str) -> String { + format!("{}/{}", self.api_url, path.trim_start_matches('/')) + } + + /// Translate a non-success [`Response`] into a [`QarnotError`] with the + /// most informative message we can extract. + fn check_status(resp: Response, context: &str) -> Result { + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + let code = status.as_u16(); + let body = resp.text().unwrap_or_default(); + let message = if body.is_empty() { + context.to_string() + } else { + format!("{}: {}", context, body) + }; + match code { + 401 => Err(QarnotError::Unauthorized(message)), + 402 => Err(QarnotError::NotEnoughCredits(message)), + 404 => Err(QarnotError::TaskNotFound(message)), + _ => Err(QarnotError::ApiError { code, message }), + } + } + + /// Submit a task. Returns the UUID assigned by Qarnot. + pub fn submit_task(&self, payload: &TaskPayload) -> Result { + let url = self.url("tasks"); + + // Dump the serialized payload at debug level for troubleshooting. + // Enable with `CAVE_DEBUG=true`. + if log::log_enabled!(log::Level::Debug) { + match serde_json::to_string_pretty(payload) { + Ok(body) => log::debug!("POST {} payload:\n{}", url, body), + Err(e) => log::debug!("Failed to pretty-print submit payload: {}", e), + } + } + + let resp = self.http.post(&url).json(payload).send()?; + let resp = Self::check_status(resp, "submit_task")?; + let parsed: TaskSubmitResponse = resp.json()?; + Ok(parsed.uuid) + } + + /// Get the current status for a given task UUID. + pub fn get_task_status(&self, uuid: &str) -> Result { + let url = self.url(&format!("tasks/{}", uuid)); + let resp = self.http.get(&url).send()?; + let resp = Self::check_status(resp, "get_task_status")?; + let parsed: TaskStatus = resp.json()?; + Ok(parsed) + } + + /// Read accumulated stdout for a task. + pub fn get_stdout(&self, uuid: &str) -> Result { + let url = self.url(&format!("tasks/{}/stdout", uuid)); + let resp = self.http.get(&url).send()?; + let resp = Self::check_status(resp, "get_stdout")?; + Ok(resp.text()?) + } + + /// Read accumulated stderr for a task. + pub fn get_stderr(&self, uuid: &str) -> Result { + let url = self.url(&format!("tasks/{}/stderr", uuid)); + let resp = self.http.get(&url).send()?; + let resp = Self::check_status(resp, "get_stderr")?; + Ok(resp.text()?) + } + + /// Abort a running task. + pub fn abort_task(&self, uuid: &str) -> Result<(), QarnotError> { + let url = self.url(&format!("tasks/{}/abort", uuid)); + let resp = self.http.post(&url).send()?; + Self::check_status(resp, "abort_task")?; + Ok(()) + } + + /// Delete a task. `purge` is forwarded as the `purgeBucket` query + /// parameter, deleting the associated bucket as well. + pub fn delete_task(&self, uuid: &str, purge: bool) -> Result<(), QarnotError> { + let url = self.url(&format!( + "tasks/{}?purgeData={}&purgeResources={}", + uuid, purge, purge + )); + let resp = self.http.delete(&url).send()?; + Self::check_status(resp, "delete_task")?; + Ok(()) + } + + /// Reference to the underlying `aws_sdk_s3` client (mostly for advanced + /// uses outside of [`Bucket`]). + #[allow(dead_code)] + pub fn s3_client(&self) -> &S3Client { + &self.s3_client + } + + /// Token used by this client (mostly for diagnostics). + #[allow(dead_code)] + pub fn api_token(&self) -> &str { + &self.api_token + } +} diff --git a/src/qarnot/config.rs b/src/qarnot/config.rs new file mode 100644 index 0000000..a00fd9a --- /dev/null +++ b/src/qarnot/config.rs @@ -0,0 +1,276 @@ +//! Qarnot configuration handling. +//! +//! The configuration is split between a mandatory **global** file +//! (`~/.qarnotconfig`) that contains the credentials and the user defaults, +//! and an **optional local** file (`./.qarnotconfig`) located in the current +//! working directory that can override any of the global fields. +//! +//! This allows users to set a sensible default once and tweak per-project +//! parameters (Docker tag, hardware, slot count) without retyping their +//! credentials. + +use crate::qarnot::error::QarnotError; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Default Qarnot API URL. +pub const DEFAULT_API_URL: &str = "https://api.qarnot.com"; +/// Default Qarnot profile name for code_aster jobs. +pub const DEFAULT_PROFILE: &str = "code-aster"; +/// Default Docker tag used for the code_aster image on Qarnot. +pub const DEFAULT_DOCKER_TAG: &str = "dev"; +/// Default number of slots passed to the cluster setup. +pub const DEFAULT_SLOTS: &str = "8"; +/// Default hardware constraint identifier. +pub const DEFAULT_HARDWARE: &str = "28c-128g-intel-dual-xeon2680v4-ssd"; +/// Local config filename (in CWD). +pub const LOCAL_CONFIG_FILENAME: &str = ".qarnotconfig"; +/// Global config filename (in $HOME). +pub const GLOBAL_CONFIG_FILENAME: &str = ".qarnotconfig"; + +/// Final, resolved configuration used when interacting with the Qarnot API. +/// +/// All fields are mandatory at this point: the global file provides defaults +/// and the local file can override them. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QarnotConfig { + /// Qarnot API token (used for both REST API and S3 storage authentication). + pub api_token: String, + /// Account email (used as S3 access key id). + pub email: String, + /// Qarnot REST API URL. + pub api_url: String, + /// Optional pre-configured S3-compatible storage URL. If `None`, it will be + /// fetched from the `/settings` endpoint at client creation time. + #[serde(default)] + pub storage_url: Option, + /// Profile to run on Qarnot (e.g. `code-aster`). + pub profile: String, + /// Docker tag for the code_aster image (constant `DOCKER_TAG`). + pub docker_tag: String, + /// Number of slots for the cluster setup (constant `SETUP_CLUSTER_NB_SLOTS`). + pub setup_cluster_nb_slots: String, + /// Number of instances to run (always 1 in v1). + pub instance_count: u32, + /// Specific hardware constraint string (e.g. `28c-128g-intel-dual-xeon2680v4-ssd`). + pub hardware: String, +} + +/// Partial configuration as it appears on disk. Every field is optional so a +/// local override file can leave most of them out. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PartialQarnotConfig { + pub api_token: Option, + pub email: Option, + pub api_url: Option, + pub storage_url: Option, + pub profile: Option, + pub docker_tag: Option, + pub setup_cluster_nb_slots: Option, + pub instance_count: Option, + pub hardware: Option, +} + +impl PartialQarnotConfig { + /// Merge another partial config into this one. Values from `other` take + /// precedence over values already present in `self`. + pub fn merge(&mut self, other: PartialQarnotConfig) { + if other.api_token.is_some() { + self.api_token = other.api_token; + } + if other.email.is_some() { + self.email = other.email; + } + if other.api_url.is_some() { + self.api_url = other.api_url; + } + if other.storage_url.is_some() { + self.storage_url = other.storage_url; + } + if other.profile.is_some() { + self.profile = other.profile; + } + if other.docker_tag.is_some() { + self.docker_tag = other.docker_tag; + } + if other.setup_cluster_nb_slots.is_some() { + self.setup_cluster_nb_slots = other.setup_cluster_nb_slots; + } + if other.instance_count.is_some() { + self.instance_count = other.instance_count; + } + if other.hardware.is_some() { + self.hardware = other.hardware; + } + } + + /// Promote a partial config into a fully resolved one. Mandatory fields + /// (`api_token`, `email`) must be present, otherwise a [`QarnotError::ConfigError`] + /// is returned. Other fields fall back to their defaults. + pub fn into_config(self) -> Result { + let api_token = self.api_token.ok_or_else(|| { + QarnotError::ConfigError( + "Missing 'api_token' in qarnot config. Run `cave qarnot config --token --email `.".to_string(), + ) + })?; + let email = self.email.ok_or_else(|| { + QarnotError::ConfigError( + "Missing 'email' in qarnot config. Run `cave qarnot config --token --email `.".to_string(), + ) + })?; + + if api_token.trim().is_empty() { + return Err(QarnotError::ConfigError("Empty 'api_token'".to_string())); + } + if email.trim().is_empty() { + return Err(QarnotError::ConfigError("Empty 'email'".to_string())); + } + + Ok(QarnotConfig { + api_token, + email, + api_url: self.api_url.unwrap_or_else(|| DEFAULT_API_URL.to_string()), + storage_url: self.storage_url, + profile: self.profile.unwrap_or_else(|| DEFAULT_PROFILE.to_string()), + docker_tag: self + .docker_tag + .unwrap_or_else(|| DEFAULT_DOCKER_TAG.to_string()), + setup_cluster_nb_slots: self + .setup_cluster_nb_slots + .unwrap_or_else(|| DEFAULT_SLOTS.to_string()), + instance_count: self.instance_count.unwrap_or(1), + hardware: self + .hardware + .unwrap_or_else(|| DEFAULT_HARDWARE.to_string()), + }) + } +} + +/// Path to the user-wide qarnot configuration file (`~/.qarnotconfig`). +/// +/// # Errors +/// Returns a [`QarnotError::ConfigError`] if the home directory cannot be +/// resolved on this platform. +pub fn global_config_path() -> Result { + dirs::home_dir() + .map(|home| home.join(GLOBAL_CONFIG_FILENAME)) + .ok_or_else(|| QarnotError::ConfigError("Home directory not found".to_string())) +} + +/// Path to the local override config (`./.qarnotconfig`). +pub fn local_config_path() -> PathBuf { + PathBuf::from(LOCAL_CONFIG_FILENAME) +} + +/// Read a partial config from disk. Returns an empty partial when the file +/// does not exist (used for the optional local override). +fn read_partial(path: &Path) -> Result { + if !path.exists() { + return Ok(PartialQarnotConfig::default()); + } + let content = std::fs::read_to_string(path) + .map_err(|e| QarnotError::ConfigError(format!("Cannot read {}: {}", path.display(), e)))?; + serde_json::from_str::(&content) + .map_err(|e| QarnotError::ConfigError(format!("Invalid JSON in {}: {}", path.display(), e))) +} + +/// Read and merge the global and (optional) local Qarnot configuration files. +/// +/// The global file is mandatory and must contain at least `api_token` and +/// `email`. The local file (`./.qarnotconfig`) is optional; any field it +/// defines overrides the value from the global file. +/// +/// # Errors +/// - [`QarnotError::ConfigError`] if the global file is missing, malformed, +/// or doesn't contain the required credentials. +pub fn read_qarnot_config() -> Result { + let global_path = global_config_path()?; + if !global_path.exists() { + return Err(QarnotError::ConfigError(format!( + "Qarnot config not found at {}. Run `cave qarnot config --token --email ` first.", + global_path.display() + ))); + } + + let mut merged = read_partial(&global_path)?; + let local = read_partial(&local_config_path())?; + merged.merge(local); + + merged.into_config() +} + +/// Write a [`PartialQarnotConfig`] to disk as pretty-printed JSON. +pub fn write_partial_config(path: &Path, config: &PartialQarnotConfig) -> Result<(), QarnotError> { + let content = serde_json::to_string_pretty(config) + .map_err(|e| QarnotError::ConfigError(format!("Serialization failed: {}", e)))?; + std::fs::write(path, content) + .map_err(|e| QarnotError::ConfigError(format!("Cannot write {}: {}", path.display(), e)))?; + Ok(()) +} + +/// Write a fully resolved [`QarnotConfig`] to disk (used when running +/// `cave qarnot config` to produce a global file). +#[allow(dead_code)] +pub fn write_qarnot_config(path: &Path, config: &QarnotConfig) -> Result<(), QarnotError> { + let content = serde_json::to_string_pretty(config) + .map_err(|e| QarnotError::ConfigError(format!("Serialization failed: {}", e)))?; + std::fs::write(path, content) + .map_err(|e| QarnotError::ConfigError(format!("Cannot write {}: {}", path.display(), e)))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_overrides_global_with_local() { + let mut global = PartialQarnotConfig { + api_token: Some("tok".to_string()), + email: Some("a@b".to_string()), + docker_tag: Some("dev".to_string()), + ..Default::default() + }; + let local = PartialQarnotConfig { + docker_tag: Some("17.1.0".to_string()), + hardware: Some("custom-hw".to_string()), + ..Default::default() + }; + global.merge(local); + assert_eq!(global.docker_tag.as_deref(), Some("17.1.0")); + assert_eq!(global.hardware.as_deref(), Some("custom-hw")); + assert_eq!(global.api_token.as_deref(), Some("tok")); + } + + #[test] + fn into_config_fills_defaults() { + let partial = PartialQarnotConfig { + api_token: Some("tok".to_string()), + email: Some("a@b".to_string()), + ..Default::default() + }; + let cfg = partial.into_config().expect("should resolve"); + assert_eq!(cfg.api_url, DEFAULT_API_URL); + assert_eq!(cfg.profile, DEFAULT_PROFILE); + assert_eq!(cfg.docker_tag, DEFAULT_DOCKER_TAG); + assert_eq!(cfg.instance_count, 1); + } + + #[test] + fn into_config_requires_token() { + let partial = PartialQarnotConfig { + email: Some("a@b".to_string()), + ..Default::default() + }; + assert!(partial.into_config().is_err()); + } + + #[test] + fn into_config_requires_email() { + let partial = PartialQarnotConfig { + api_token: Some("tok".to_string()), + ..Default::default() + }; + assert!(partial.into_config().is_err()); + } +} diff --git a/src/qarnot/error.rs b/src/qarnot/error.rs new file mode 100644 index 0000000..a6d85b4 --- /dev/null +++ b/src/qarnot/error.rs @@ -0,0 +1,82 @@ +//! Errors for the Qarnot integration. +//! +//! Provides a dedicated [`QarnotError`] enum for everything related to the +//! Qarnot API and S3-compatible storage operations. The errors are convertible +//! to the global [`crate::manage::CaveError`] used by the CLI dispatcher. + +use std::fmt; + +/// Errors that can occur when interacting with the Qarnot API or its +/// S3-compatible storage backend. +#[derive(Debug)] +pub enum QarnotError { + /// Low-level HTTP error from `reqwest`. + HttpError(String), + /// Qarnot API returned a non-success status code. + ApiError { code: u16, message: String }, + /// Authentication failed (invalid token). + Unauthorized(String), + /// Insufficient credits on the Qarnot account. + NotEnoughCredits(String), + /// Task UUID was not found. + TaskNotFound(String), + /// Bucket-level error (creation, listing). + BucketError(String), + /// S3 transfer error (upload/download). + S3Error(String), + /// Configuration is missing or invalid. + ConfigError(String), + /// JSON serialization/deserialization error. + SerializationError(String), + /// I/O error while reading a local file. + IoError(String), +} + +impl fmt::Display for QarnotError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + QarnotError::HttpError(msg) => write!(f, "HTTP error: {}", msg), + QarnotError::ApiError { code, message } => { + write!(f, "Qarnot API error ({}): {}", code, message) + } + QarnotError::Unauthorized(msg) => { + write!(f, "Qarnot authentication failed: {}", msg) + } + QarnotError::NotEnoughCredits(msg) => { + write!(f, "Not enough credits on Qarnot account: {}", msg) + } + QarnotError::TaskNotFound(uuid) => { + write!(f, "Qarnot task not found: {}", uuid) + } + QarnotError::BucketError(msg) => write!(f, "Qarnot bucket error: {}", msg), + QarnotError::S3Error(msg) => write!(f, "Qarnot S3 storage error: {}", msg), + QarnotError::ConfigError(msg) => { + write!(f, "Qarnot configuration error: {}", msg) + } + QarnotError::SerializationError(msg) => { + write!(f, "Qarnot JSON error: {}", msg) + } + QarnotError::IoError(msg) => write!(f, "I/O error: {}", msg), + } + } +} + +impl std::error::Error for QarnotError {} + +impl From for QarnotError { + fn from(err: reqwest::Error) -> Self { + QarnotError::HttpError(err.to_string()) + } +} + +impl From for QarnotError { + fn from(err: serde_json::Error) -> Self { + QarnotError::SerializationError(err.to_string()) + } +} + +impl From for QarnotError { + fn from(err: std::io::Error) -> Self { + QarnotError::IoError(err.to_string()) + } +} diff --git a/src/qarnot/export_parser.rs b/src/qarnot/export_parser.rs new file mode 100644 index 0000000..e2eb399 --- /dev/null +++ b/src/qarnot/export_parser.rs @@ -0,0 +1,162 @@ +//! Parser for code_aster `.export` files. +//! +//! The `.export` file is a flat text format read by `as_run`/`run_aster`. +//! Each line starts with a keyword: +//! +//! - `P ` : a parameter (mode, memory, ncpus, ...). +//! - `F ` : a file. `D` means data (input), +//! `R` means result (output). +//! - `A ` : argument forwarded to code_aster. +//! +//! For Qarnot submission, we only care about the input files (`D`), which +//! must be uploaded to the input bucket. +//! +//! Example: +//! ```text +//! P actions make_etude +//! P mode interactif +//! P mpi_nbcpu 8 +//! F comm calcul.comm.py D 1 +//! F med blade_mid.med D 2 +//! F mess output.mess R 6 +//! ``` + +use crate::manage::CaveError; +use std::path::{Path, PathBuf}; + +/// Extract the list of *input* (D) files referenced by an `.export` file. +/// +/// Filenames are resolved relative to the directory containing the `.export` +/// file. The function fails if any referenced data file is missing on disk, +/// since uploading a partial dataset would lead to a runtime error on Qarnot. +/// +/// # Arguments +/// * `export_path` - path to a code_aster `.export` file. +/// +/// # Returns +/// A vector of absolute (or relative-to-CWD) paths pointing to every `D` +/// file declared in the export. +/// +/// # Errors +/// - [`CaveError::FileNotFound`] when the `.export` itself or any referenced +/// data file is missing. +/// - [`CaveError::IoError`] on read failure. +/// - [`CaveError::CodeAsterError`] when a malformed `F` line is encountered. +pub fn parse_export_data_files(export_path: &Path) -> Result, CaveError> { + if !export_path.exists() { + return Err(CaveError::FileNotFound(format!( + "Export file not found: {}", + export_path.display() + ))); + } + + let content = std::fs::read_to_string(export_path)?; + let export_dir = export_path.parent().unwrap_or_else(|| Path::new(".")); + let mut data_files = Vec::new(); + + for (lineno, raw) in content.lines().enumerate() { + let line = raw.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if !line.starts_with("F ") { + continue; + } + + // F + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 4 { + return Err(CaveError::CodeAsterError(format!( + "Malformed F line {} in {}: '{}'", + lineno + 1, + export_path.display(), + raw + ))); + } + + let direction = parts[3]; + if direction != "D" { + continue; + } + + let filename = parts[2]; + let file_path = export_dir.join(filename); + if !file_path.exists() { + return Err(CaveError::FileNotFound(format!( + "Data file '{}' referenced in {} not found at {}", + filename, + export_path.display(), + file_path.display() + ))); + } + + data_files.push(file_path); + } + + Ok(data_files) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf { + let path = dir.join(name); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(content.as_bytes()).unwrap(); + path + } + + #[test] + fn parse_extracts_only_d_files() { + let dir = tempdir().unwrap(); + write_file(dir.path(), "calcul.comm.py", "# comm"); + write_file(dir.path(), "mesh.med", "binary"); + let export = write_file( + dir.path(), + "calcul.export", + "P actions make_etude\n\ + P mode interactif\n\ + F comm calcul.comm.py D 1\n\ + F med mesh.med D 2\n\ + F mess output.mess R 6\n", + ); + + let files = parse_export_data_files(&export).expect("parse ok"); + assert_eq!(files.len(), 2); + assert!(files.iter().any(|p| p.ends_with("calcul.comm.py"))); + assert!(files.iter().any(|p| p.ends_with("mesh.med"))); + } + + #[test] + fn parse_fails_when_data_file_missing() { + let dir = tempdir().unwrap(); + let export = write_file(dir.path(), "calcul.export", "F comm missing.comm.py D 1\n"); + assert!(parse_export_data_files(&export).is_err()); + } + + #[test] + fn parse_skips_comments_and_empty_lines() { + let dir = tempdir().unwrap(); + write_file(dir.path(), "calcul.comm.py", ""); + let export = write_file( + dir.path(), + "calcul.export", + "# header comment\n\ + \n\ + P actions make_etude\n\ + F comm calcul.comm.py D 1\n", + ); + let files = parse_export_data_files(&export).unwrap(); + assert_eq!(files.len(), 1); + } + + #[test] + fn parse_fails_on_missing_export() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("nope.export"); + assert!(parse_export_data_files(&missing).is_err()); + } +} diff --git a/src/qarnot/mod.rs b/src/qarnot/mod.rs new file mode 100644 index 0000000..c3813fb --- /dev/null +++ b/src/qarnot/mod.rs @@ -0,0 +1,26 @@ +//! Qarnot integration for cave. +//! +//! Provides everything cave needs to submit, monitor and retrieve code_aster +//! jobs running on Qarnot's cloud: +//! +//! - [`config`] : reading/merging the user configuration files. +//! - [`error`] : Qarnot-specific error type. +//! - [`task`] : data model for the REST payloads. +//! - [`bucket`] : S3-compatible storage helpers. +//! - [`client`] : the actual HTTP client. +//! - [`export_parser`] : minimal `.export` parser used to discover input files. +//! - [`registry`] : local registry of submitted jobs. + +pub mod bucket; +pub mod client; +pub mod config; +pub mod error; +pub mod export_parser; +pub mod registry; +pub mod task; + +pub use client::QarnotClient; +#[allow(unused_imports)] +pub use config::{read_qarnot_config, QarnotConfig}; +#[allow(unused_imports)] +pub use error::QarnotError; diff --git a/src/qarnot/registry.rs b/src/qarnot/registry.rs new file mode 100644 index 0000000..a90ee3b --- /dev/null +++ b/src/qarnot/registry.rs @@ -0,0 +1,102 @@ +//! Local registry of submitted Qarnot jobs. +//! +//! Each successful submission records a [`JobInfo`] entry in +//! `~/.cave_qarnot_jobs.json` so users can list past submissions +//! without re-querying the API. The registry is best-effort: a missing or +//! corrupted file is handled gracefully. + +use crate::qarnot::error::QarnotError; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Filename of the registry inside the user home directory. +pub const REGISTRY_FILENAME: &str = ".cave_qarnot_jobs.json"; + +/// One entry per submission. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobInfo { + pub uuid: String, + pub name: String, + pub submitted_at: DateTime, + pub export_file: String, + pub bucket_name: String, + pub status: String, +} + +/// Top-level container persisted as JSON. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct JobRegistry { + #[serde(default)] + pub jobs: Vec, +} + +impl JobRegistry { + /// Build the registry path (`~/.cave_qarnot_jobs.json`). + pub fn path() -> Result { + dirs::home_dir() + .map(|h| h.join(REGISTRY_FILENAME)) + .ok_or_else(|| QarnotError::ConfigError("Home directory not found".to_string())) + } + + /// Load the registry from disk, returning an empty one when the file + /// doesn't exist or is unreadable. + pub fn load() -> Result { + let path = Self::path()?; + if !path.exists() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(&path).map_err(|e| { + QarnotError::ConfigError(format!("Cannot read registry {}: {}", path.display(), e)) + })?; + // Be tolerant: a corrupted registry shouldn't abort the operation. + Ok(serde_json::from_str(&content).unwrap_or_default()) + } + + /// Persist the registry on disk. + pub fn save(&self) -> Result<(), QarnotError> { + let path = Self::path()?; + let content = serde_json::to_string_pretty(self)?; + std::fs::write(&path, content).map_err(|e| { + QarnotError::ConfigError(format!("Cannot write registry {}: {}", path.display(), e)) + })?; + Ok(()) + } + + /// Append a new [`JobInfo`] and immediately save. + pub fn add(&mut self, job: JobInfo) -> Result<(), QarnotError> { + self.jobs.push(job); + self.save() + } + + /// Update the cached `status` of a previously registered job. + /// A no-op when the UUID is unknown. + pub fn update_status(&mut self, uuid: &str, status: String) -> Result<(), QarnotError> { + let mut updated = false; + for job in self.jobs.iter_mut() { + if job.uuid == uuid { + job.status = status.clone(); + updated = true; + } + } + if updated { + self.save()?; + } + Ok(()) + } + + /// Remove a job from the registry. + pub fn remove(&mut self, uuid: &str) -> Result<(), QarnotError> { + let before = self.jobs.len(); + self.jobs.retain(|j| j.uuid != uuid); + if self.jobs.len() != before { + self.save()?; + } + Ok(()) + } + + /// Look up a job by UUID. + pub fn get(&self, uuid: &str) -> Option<&JobInfo> { + self.jobs.iter().find(|j| j.uuid == uuid) + } +} diff --git a/src/qarnot/task.rs b/src/qarnot/task.rs new file mode 100644 index 0000000..8585b3f --- /dev/null +++ b/src/qarnot/task.rs @@ -0,0 +1,389 @@ +//! Qarnot Task data model. +//! +//! Mirrors the JSON payload expected by the Qarnot REST API +//! (`POST /tasks`) and the response of `GET /tasks/{uuid}`. +//! +//! The exact shape was derived by capturing the JSON body sent by the +//! official Python SDK when submitting a single-instance code_aster task. + +use crate::qarnot::config::QarnotConfig; +use crate::qarnot::error::QarnotError; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +/// Hardware constraint passed to Qarnot to pin the job on a specific machine +/// type. Currently only `SpecificHardwareConstraint` is supported. +/// +/// The wire format is `{"discriminator": "...", "specificationKey": "..."}` +/// (see `qarnot/hardware_constraint.py:SpecificHardware`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HardwareConstraint { + pub discriminator: String, + #[serde(rename = "specificationKey")] + pub specification_key: String, +} + +impl HardwareConstraint { + /// Build a `SpecificHardwareConstraint` targeting the given machine id. + pub fn specific(value: impl Into) -> Self { + Self { + discriminator: "SpecificHardwareConstraint".to_string(), + specification_key: value.into(), + } + } +} + +/// Element of the `advancedResourceBuckets` array. Cave never needs filtering +/// nor resource transformation, but the keys must be present (the SDK sends +/// empty objects `{}`, not `null`). +#[derive(Debug, Clone, Serialize)] +pub struct AdvancedResourceBucket { + #[serde(rename = "bucketName")] + pub bucket_name: String, + pub filtering: serde_json::Value, + #[serde(rename = "resourcesTransformation")] + pub resources_transformation: serde_json::Value, + #[serde(rename = "cacheTTLSec")] + pub cache_ttl_sec: Option, +} + +impl AdvancedResourceBucket { + fn new(bucket_name: impl Into) -> Self { + Self { + bucket_name: bucket_name.into(), + filtering: serde_json::json!({}), + resources_transformation: serde_json::json!({}), + cache_ttl_sec: None, + } + } +} + +/// Object passed as `dependencies`. +#[derive(Debug, Clone, Serialize, Default)] +pub struct Dependencies { + #[serde(rename = "dependsOn")] + pub depends_on: Vec, +} + +/// Object passed as `retrySettings`. +#[derive(Debug, Clone, Serialize, Default)] +pub struct RetrySettings { + #[serde(rename = "maxTotalRetries")] + pub max_total_retries: Option, + #[serde(rename = "maxPerInstanceRetries")] + pub max_per_instance_retries: Option, +} + +/// Object passed as `privileges`. +#[derive(Debug, Clone, Serialize)] +pub struct Privileges { + #[serde(rename = "exportApiAndStorageCredentialsInEnvironment")] + pub export_credentials: bool, +} + +impl Default for Privileges { + fn default() -> Self { + Self { + export_credentials: false, + } + } +} + +/// Payload sent to `POST /tasks`. +/// +/// Faithful mirror of the JSON produced by `qarnot/task.py:_to_json()` in the +/// Python SDK. Fields are serialized in the same order as the SDK output for +/// easy diffing. +#[derive(Debug, Clone, Serialize)] +pub struct TaskPayload { + pub name: String, + pub profile: String, + #[serde(rename = "poolUuid")] + pub pool_uuid: Option, + #[serde(rename = "jobUuid")] + pub job_uuid: Option, + pub constants: Vec, + pub constraints: Vec, + #[serde(rename = "forcedConstants")] + pub forced_constants: Vec, + pub dependencies: Dependencies, + #[serde(rename = "waitForPoolResourcesSynchronization")] + pub wait_for_pool_resources_synchronization: Option, + #[serde(rename = "uploadResultsOnCancellation")] + pub upload_results_on_cancellation: Option, + pub labels: HashMap, + #[serde(rename = "advancedResourceBuckets")] + pub advanced_resource_buckets: Vec, + #[serde(rename = "resultBucket")] + pub result_bucket: String, + #[serde(rename = "instanceCount")] + pub instance_count: u32, + pub tags: Vec, + #[serde(rename = "autoDeleteOnCompletion")] + pub auto_delete_on_completion: bool, + #[serde(rename = "completionTimeToLive")] + pub completion_time_to_live: String, + #[serde(rename = "hardwareConstraints")] + pub hardware_constraints: Vec, + #[serde(rename = "defaultResourcesCacheTTLSec")] + pub default_resources_cache_ttl_sec: Option, + pub privileges: Privileges, + #[serde(rename = "retrySettings")] + pub retry_settings: RetrySettings, + #[serde(rename = "forcedNetworkRules")] + pub forced_network_rules: Vec, +} + +/// Qarnot uses an array of `{key, value}` objects for constants, not a flat +/// map. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyValue { + pub key: String, + pub value: String, +} + +impl KeyValue { + pub fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + } + } +} + +impl TaskPayload { + /// Build a [`TaskPayload`] from an `.export` file path, a job name and a + /// resolved [`QarnotConfig`]. + /// + /// The export filename (without its directory) is passed verbatim as the + /// `CA_EXPORT_FILE` constant since files are uploaded at the bucket root. + pub fn from_export_and_config( + export_path: &Path, + job_name: &str, + bucket_name: &str, + config: &QarnotConfig, + ) -> Result { + let export_filename = export_path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| { + QarnotError::ConfigError(format!("Invalid export path: {}", export_path.display())) + })?; + + let constants = vec![ + KeyValue::new("DOCKER_TAG", config.docker_tag.clone()), + KeyValue::new( + "SETUP_CLUSTER_NB_SLOTS", + config.setup_cluster_nb_slots.clone(), + ), + KeyValue::new("CA_EXPORT_FILE", export_filename), + ]; + + Ok(TaskPayload { + name: job_name.to_string(), + profile: config.profile.clone(), + pool_uuid: None, + job_uuid: None, + constants, + constraints: Vec::new(), + forced_constants: Vec::new(), + dependencies: Dependencies::default(), + wait_for_pool_resources_synchronization: None, + upload_results_on_cancellation: None, + labels: HashMap::new(), + advanced_resource_buckets: vec![AdvancedResourceBucket::new(bucket_name)], + result_bucket: bucket_name.to_string(), + instance_count: config.instance_count, + tags: Vec::new(), + auto_delete_on_completion: false, + completion_time_to_live: "00:00:00".to_string(), + hardware_constraints: vec![HardwareConstraint::specific(config.hardware.clone())], + default_resources_cache_ttl_sec: None, + privileges: Privileges::default(), + retry_settings: RetrySettings::default(), + forced_network_rules: Vec::new(), + }) + } +} + +/// Response of `POST /tasks` (only the fields cave needs). +#[derive(Debug, Clone, Deserialize)] +pub struct TaskSubmitResponse { + pub uuid: String, +} + +/// Response of `GET /tasks/{uuid}` (subset of fields). +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskStatus { + pub uuid: String, + pub name: Option, + pub state: String, + #[serde(default)] + pub creation_date: Option, + #[serde(default)] + pub end_date: Option, + #[serde(default)] + pub running_core_count: Option, + #[serde(default)] + pub running_instance_count: Option, +} + +impl TaskStatus { + /// `true` when the task has reached a terminal state (success or failure). + pub fn is_terminal(&self) -> bool { + matches!( + self.state.as_str(), + "Success" | "Failure" | "Cancelled" | "PartiallyCancelled" + ) + } +} + +/// Convenience helper to convert a flat `key: value` map into the Qarnot +/// `[{key, value}]` format. Useful for tests. +#[allow(dead_code)] +pub fn map_to_constants(map: HashMap) -> Vec { + map.into_iter().map(|(k, v)| KeyValue::new(k, v)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::qarnot::config::PartialQarnotConfig; + + fn sample_config() -> QarnotConfig { + let mut p = PartialQarnotConfig::default(); + p.api_token = Some("tok".to_string()); + p.email = Some("a@b".to_string()); + p.docker_tag = Some("17.1.0".to_string()); + p.setup_cluster_nb_slots = Some("16".to_string()); + p.hardware = Some("hw-id".to_string()); + p.into_config().unwrap() + } + + #[test] + fn payload_uses_export_basename() { + let cfg = sample_config(); + let path = Path::new("/tmp/calcul/calcul.export"); + let payload = + TaskPayload::from_export_and_config(path, "job-1", "job-1-bucket", &cfg).unwrap(); + + let ca_export = payload + .constants + .iter() + .find(|c| c.key == "CA_EXPORT_FILE") + .unwrap(); + assert_eq!(ca_export.value, "calcul.export"); + + assert_eq!(payload.advanced_resource_buckets.len(), 1); + assert_eq!( + payload.advanced_resource_buckets[0].bucket_name, + "job-1-bucket" + ); + assert_eq!(payload.result_bucket, "job-1-bucket"); + assert_eq!(payload.profile, "code-aster"); + assert_eq!(payload.instance_count, 1); + } + + #[test] + fn payload_serializes_with_sdk_fields() { + let cfg = sample_config(); + let path = Path::new("calcul.export"); + let payload = TaskPayload::from_export_and_config(path, "job-1", "bucket-1", &cfg).unwrap(); + let json = serde_json::to_string(&payload).unwrap(); + + // Sanity checks against the SDK payload captured live. + assert!(json.contains("\"instanceCount\"")); + assert!(json.contains("\"advancedResourceBuckets\"")); + assert!(json.contains("\"resultBucket\"")); + assert!(json.contains("\"hardwareConstraints\"")); + assert!(json.contains("\"dependencies\"")); + assert!(json.contains("\"dependsOn\"")); + assert!(json.contains("\"discriminator\"")); + assert!(json.contains("\"specificationKey\"")); + assert!(json.contains("\"completionTimeToLive\"")); + assert!(json.contains("\"privileges\"")); + assert!(json.contains("\"retrySettings\"")); + assert!(json.contains("DOCKER_TAG")); + // Ensure we don't send the wrong fields anymore. + assert!(!json.contains("\"resourceBuckets\"")); + assert!(!json.contains("\"type\":\"SpecificHardware\"")); + } + + #[test] + fn hardware_constraint_uses_specification_key() { + let hc = HardwareConstraint::specific("hw-key"); + let json = serde_json::to_string(&hc).unwrap(); + assert!(json.contains("\"discriminator\":\"SpecificHardwareConstraint\"")); + assert!(json.contains("\"specificationKey\":\"hw-key\"")); + } + + #[test] + fn task_status_terminal_states() { + let s = TaskStatus { + uuid: "u".to_string(), + name: None, + state: "Success".to_string(), + creation_date: None, + end_date: None, + running_core_count: None, + running_instance_count: None, + }; + assert!(s.is_terminal()); + + let running = TaskStatus { + state: "FullyExecuting".to_string(), + ..s + }; + assert!(!running.is_terminal()); + } + + /// Compare cave's payload key set against the JSON body actually sent by + /// the official Python SDK (captured live with `sniff_qarnot_payload.py`). + /// + /// This guards us against regressions where we accidentally drop or + /// rename a top-level key the API requires. + #[test] + fn payload_top_level_keys_match_python_sdk() { + let cfg = sample_config(); + let path = Path::new("calcul_data/calcul.export"); + let payload = + TaskPayload::from_export_and_config(path, "job-1", "job-1-bucket", &cfg).unwrap(); + let value = serde_json::to_value(&payload).unwrap(); + let obj = value.as_object().expect("payload is a JSON object"); + + // Exact set of top-level keys produced by qarnot/task.py:_to_json(). + let expected_keys = [ + "name", + "profile", + "poolUuid", + "jobUuid", + "constants", + "constraints", + "forcedConstants", + "dependencies", + "waitForPoolResourcesSynchronization", + "uploadResultsOnCancellation", + "labels", + "advancedResourceBuckets", + "resultBucket", + "instanceCount", + "tags", + "autoDeleteOnCompletion", + "completionTimeToLive", + "hardwareConstraints", + "defaultResourcesCacheTTLSec", + "privileges", + "retrySettings", + "forcedNetworkRules", + ]; + for key in &expected_keys { + assert!( + obj.contains_key(*key), + "missing top-level key '{}' in payload", + key + ); + } + } +} diff --git a/src/qarnot_commands.rs b/src/qarnot_commands.rs new file mode 100644 index 0000000..4152ad4 --- /dev/null +++ b/src/qarnot_commands.rs @@ -0,0 +1,456 @@ +//! High-level orchestration for the `cave submit` and `cave qarnot ...` +//! subcommands. +//! +//! Each public function in this module is dispatched directly from +//! [`crate::main`] and wraps the lower-level [`crate::qarnot`] primitives: +//! parse the `.export`, talk to the Qarnot REST API, deal with the S3 bucket, +//! update the local job registry, etc. + +use crate::manage::CaveError; +use crate::qarnot::config::{ + self, global_config_path, local_config_path, PartialQarnotConfig, QarnotConfig, +}; +use crate::qarnot::export_parser::parse_export_data_files; +use crate::qarnot::registry::{JobInfo, JobRegistry}; +use crate::qarnot::task::TaskPayload; +use crate::qarnot::QarnotClient; +use chrono::Utc; +use colored::*; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +/// Default polling interval when `--wait` is set. +const POLL_INTERVAL_SECS: u64 = 15; + +/// Generate a unique job name from the export file basename and a timestamp. +fn default_job_name(export_path: &Path) -> String { + let stem = export_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("job"); + format!("cave-{}-{}", stem, Utc::now().format("%Y%m%d-%H%M%S")) +} + +/// Sanitize a string so it can safely be used as an S3 bucket name. +/// +/// Qarnot follows the AWS S3 naming rules (lowercase, alphanumeric, hyphens). +fn bucket_name_from_job(job_name: &str) -> String { + let mut name: String = job_name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + if !name.ends_with("-bucket") { + name.push_str("-bucket"); + } + name +} + +/// Implementation of `cave submit`. +/// +/// 1. Load the merged Qarnot config (global + local). +/// 2. Parse the `.export` to discover input files. +/// 3. Create the bucket and upload everything at its root. +/// 4. POST the task to the Qarnot API. +/// 5. Record the job in the local registry. +/// 6. Optionally wait for completion and download the results. +pub fn submit_qarnot( + export_file: String, + name: Option, + hardware: Option, + wait: bool, +) -> Result<(), CaveError> { + let mut qarnot_config: QarnotConfig = config::read_qarnot_config()?; + if let Some(hw) = hardware { + qarnot_config.hardware = hw; + } + + let export_path = PathBuf::from(&export_file); + if !export_path.exists() { + return Err(CaveError::FileNotFound(format!( + "Export file not found: {}", + export_path.display() + ))); + } + + println!("{} {}", "Parsing".cyan(), export_path.display()); + let data_files = parse_export_data_files(&export_path)?; + println!(" found {} input file(s)", data_files.len()); + + let job_name = name.unwrap_or_else(|| default_job_name(&export_path)); + let bucket_name = bucket_name_from_job(&job_name); + + let client = QarnotClient::new(qarnot_config.clone())?; + + println!("{} bucket: {}", "Creating".cyan(), bucket_name.bold()); + let bucket = client.bucket(&bucket_name)?; + bucket.create()?; + + println!("{} files to bucket...", "Uploading".cyan()); + let mut all_files: Vec = vec![export_path.clone()]; + all_files.extend(data_files); + bucket.upload_files(&all_files)?; + println!(" {} {} file(s) uploaded", "✓".green(), all_files.len()); + + let payload = + TaskPayload::from_export_and_config(&export_path, &job_name, &bucket_name, &qarnot_config)?; + + println!("{} task to Qarnot...", "Submitting".cyan()); + let uuid = client.submit_task(&payload)?; + + println!(); + println!("{} task submitted!", "✓".green().bold()); + println!(" UUID: {}", uuid.bold()); + println!(" Name: {}", job_name); + println!( + " Docker: {} (slots: {})", + qarnot_config.docker_tag, qarnot_config.setup_cluster_nb_slots + ); + println!(" Hardware: {}", qarnot_config.hardware); + + let mut registry = JobRegistry::load().unwrap_or_default(); + let _ = registry.add(JobInfo { + uuid: uuid.clone(), + name: job_name.clone(), + submitted_at: Utc::now(), + export_file: export_path.to_string_lossy().to_string(), + bucket_name: bucket_name.clone(), + status: "Submitted".to_string(), + }); + + if wait { + wait_and_download(&client, &uuid, &job_name, None)?; + } else { + println!(); + println!("{}", "Next steps:".bold()); + println!(" cave qarnot status {}", uuid); + println!(" cave qarnot download {}", uuid); + } + Ok(()) +} + +/// Poll the task status until it reaches a terminal state, then download the +/// results bucket. +fn wait_and_download( + client: &QarnotClient, + uuid: &str, + job_name: &str, + output_dir: Option<&Path>, +) -> Result<(), CaveError> { + println!(); + println!( + "{} for completion (poll every {}s)...", + "Waiting".cyan(), + POLL_INTERVAL_SECS + ); + + let mut last_state = String::new(); + loop { + let status = client.get_task_status(uuid)?; + if status.state != last_state { + println!(" state: {}", status.state.bold()); + last_state = status.state.clone(); + } + if status.is_terminal() { + println!( + "{} task finished with state '{}'", + "✓".green(), + status.state + ); + // Update registry status (best-effort). + if let Ok(mut registry) = JobRegistry::load() { + let _ = registry.update_status(uuid, status.state.clone()); + } + break; + } + thread::sleep(Duration::from_secs(POLL_INTERVAL_SECS)); + } + + let default_out = format!("{}-results", job_name); + let out = output_dir.unwrap_or_else(|| Path::new(&default_out)); + download_results(client, uuid, out) +} + +/// Implementation of `cave qarnot config`. +#[allow(clippy::too_many_arguments)] +pub fn qarnot_config_set( + token: Option, + email: Option, + api_url: Option, + profile: Option, + docker_tag: Option, + slots: Option, + hardware: Option, + local: bool, +) -> Result<(), CaveError> { + let target_path = if local { + local_config_path() + } else { + global_config_path()? + }; + + // Load existing values so we only overwrite what the user provided. + let mut existing: PartialQarnotConfig = if target_path.exists() { + let content = std::fs::read_to_string(&target_path)?; + serde_json::from_str(&content).unwrap_or_default() + } else { + PartialQarnotConfig::default() + }; + + if let Some(v) = token { + existing.api_token = Some(v); + } + if let Some(v) = email { + existing.email = Some(v); + } + if let Some(v) = api_url { + existing.api_url = Some(v); + } + if let Some(v) = profile { + existing.profile = Some(v); + } + if let Some(v) = docker_tag { + existing.docker_tag = Some(v); + } + if let Some(v) = slots { + existing.setup_cluster_nb_slots = Some(v); + } + if let Some(v) = hardware { + existing.hardware = Some(v); + } + + config::write_partial_config(&target_path, &existing)?; + println!( + "{} qarnot config saved to {}", + "✓".green(), + target_path.display() + ); + if !local { + println!(" (use --local to write a project-specific override)"); + } + Ok(()) +} + +/// Implementation of `cave qarnot show`. +pub fn qarnot_config_show() -> Result<(), CaveError> { + let cfg = config::read_qarnot_config()?; + println!("{}", "Resolved Qarnot configuration:".bold()); + println!(" api_url: {}", cfg.api_url); + println!( + " storage_url: {}", + cfg.storage_url + .clone() + .unwrap_or_else(|| "(auto)".to_string()) + ); + println!(" email: {}", cfg.email); + let masked = mask_token(&cfg.api_token); + println!(" api_token: {}", masked); + println!(" profile: {}", cfg.profile); + println!(" docker_tag: {}", cfg.docker_tag); + println!(" setup_cluster_nb_slots: {}", cfg.setup_cluster_nb_slots); + println!(" instance_count: {}", cfg.instance_count); + println!(" hardware: {}", cfg.hardware); + Ok(()) +} + +fn mask_token(token: &str) -> String { + if token.len() <= 8 { + return "***".to_string(); + } + format!("{}...{}", &token[..4], &token[token.len() - 4..]) +} + +/// Implementation of `cave qarnot list`. +pub fn qarnot_list_jobs() -> Result<(), CaveError> { + let registry = JobRegistry::load()?; + if registry.jobs.is_empty() { + println!("No Qarnot jobs recorded."); + return Ok(()); + } + println!( + "{:<38} {:<35} {:<22} {}", + "UUID".bold(), + "NAME".bold(), + "SUBMITTED".bold(), + "STATUS".bold() + ); + for job in ®istry.jobs { + println!( + "{:<38} {:<35} {:<22} {}", + job.uuid, + truncate(&job.name, 35), + job.submitted_at.format("%Y-%m-%d %H:%M:%S"), + job.status, + ); + } + Ok(()) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}…", &s[..max - 1]) + } +} + +/// Implementation of `cave qarnot status`. +pub fn qarnot_status(uuid: &str) -> Result<(), CaveError> { + let cfg = config::read_qarnot_config()?; + let client = QarnotClient::new(cfg)?; + let status = client.get_task_status(uuid)?; + + println!("{}", "Task status:".bold()); + println!(" UUID: {}", status.uuid); + if let Some(name) = status.name { + println!(" Name: {}", name); + } + println!(" State: {}", status.state.bold()); + if let Some(d) = status.creation_date { + println!(" Creation date: {}", d); + } + if let Some(d) = status.end_date { + println!(" End date: {}", d); + } + if let Some(c) = status.running_core_count { + println!(" Running cores: {}", c); + } + if let Some(c) = status.running_instance_count { + println!(" Running instances: {}", c); + } + + // Best-effort: keep the registry up-to-date. + if let Ok(mut registry) = JobRegistry::load() { + let _ = registry.update_status(uuid, status.state); + } + Ok(()) +} + +/// Implementation of `cave qarnot download`. +pub fn qarnot_download(uuid: &str, output: Option) -> Result<(), CaveError> { + let cfg = config::read_qarnot_config()?; + let client = QarnotClient::new(cfg)?; + let default_dir = format!("{}-results", uuid); + let out_dir = output.unwrap_or(default_dir); + download_results(&client, uuid, Path::new(&out_dir)) +} + +/// Shared helper: figure out the bucket name for a UUID and download every +/// object into `output_dir`. +fn download_results(client: &QarnotClient, uuid: &str, output_dir: &Path) -> Result<(), CaveError> { + // Try to find bucket name from the local registry first. + let registry = JobRegistry::load().unwrap_or_default(); + let bucket_name = match registry.get(uuid) { + Some(job) => job.bucket_name.clone(), + None => { + // No local record: tell the user we can't infer the bucket. + // Falling back to the API would require parsing the task summary + // (resourceBuckets), which is not yet wired through TaskStatus. + let status = client.get_task_status(uuid)?; + return Err(CaveError::QarnotError(format!( + "Task {} is not in the local registry (state on Qarnot: {}). \ + Downloads currently only work for jobs submitted from this machine.", + uuid, status.state + ))); + } + }; + + println!( + "{} bucket '{}' into {}", + "Downloading".cyan(), + bucket_name.bold(), + output_dir.display() + ); + let bucket = client.bucket(&bucket_name)?; + let n = bucket.download_all(output_dir)?; + println!("{} downloaded {} object(s)", "✓".green(), n); + Ok(()) +} + +/// Implementation of `cave qarnot stdout`. +pub fn qarnot_stdout(uuid: &str) -> Result<(), CaveError> { + let cfg = config::read_qarnot_config()?; + let client = QarnotClient::new(cfg)?; + let out = client.get_stdout(uuid)?; + print!("{}", out); + Ok(()) +} + +/// Implementation of `cave qarnot stderr`. +pub fn qarnot_stderr(uuid: &str) -> Result<(), CaveError> { + let cfg = config::read_qarnot_config()?; + let client = QarnotClient::new(cfg)?; + let err = client.get_stderr(uuid)?; + print!("{}", err); + Ok(()) +} + +/// Implementation of `cave qarnot abort`. +pub fn qarnot_abort(uuid: &str) -> Result<(), CaveError> { + let cfg = config::read_qarnot_config()?; + let client = QarnotClient::new(cfg)?; + client.abort_task(uuid)?; + println!("{} task {} aborted.", "✓".green(), uuid); + if let Ok(mut registry) = JobRegistry::load() { + let _ = registry.update_status(uuid, "Aborted".to_string()); + } + Ok(()) +} + +/// Implementation of `cave qarnot delete`. +pub fn qarnot_delete(uuid: &str, purge: bool) -> Result<(), CaveError> { + let cfg = config::read_qarnot_config()?; + let client = QarnotClient::new(cfg)?; + client.delete_task(uuid, purge)?; + println!( + "{} task {} deleted{}.", + "✓".green(), + uuid, + if purge { " (with bucket)" } else { "" } + ); + if let Ok(mut registry) = JobRegistry::load() { + let _ = registry.remove(uuid); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bucket_name_is_lowercase_and_safe() { + let name = bucket_name_from_job("Cave-Job_42"); + assert!(name.starts_with("cave-job-42")); + assert!(name.ends_with("-bucket")); + assert!(name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + } + + #[test] + fn default_job_name_starts_with_cave_prefix() { + let n = default_job_name(Path::new("/tmp/calcul.export")); + assert!(n.starts_with("cave-calcul-")); + } + + #[test] + fn mask_token_short() { + assert_eq!(mask_token("abc"), "***"); + } + + #[test] + fn mask_token_long() { + let t = "abcdefghijkl"; + let m = mask_token(t); + assert!(m.starts_with("abcd")); + assert!(m.ends_with("ijkl")); + assert!(m.contains("...")); + } +}