commit 3f60b72c3b3102b8a62b7c1cb95f4fd191c29192 Author: Andras Schmelczer Date: Mon Mar 23 07:44:26 2026 +0000 Initial diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..38fad77 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,41 @@ +# Rust +target/ + +# Git +.git/ +.gitignore + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Test files +test/ +tests/ + +# Documentation +README.md +docs/ + +# CI/CD +.github/ +.gitlab-ci.yml + +# Runtime files +backups/ +compose-files/ +config.yaml + +# Docker +docker-compose.yml +Dockerfile +.dockerignore \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..d9fb338 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,126 @@ +name: Build and Publish Docker Image + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: 1.88 + override: true + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Run tests + run: GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}"cargo test --verbose + + - name: Run clippy + run: cargo clippy -- -D warnings + + - name: Check formatting + run: cargo fmt -- --check + + build-and-push: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + version: ${{ steps.meta.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Test Docker image + if: github.event_name != 'pull_request' + run: | + docker run --rm \ + -e RUST_LOG=info \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} \ + docker-compose-updater check + + security-scan: + needs: build-and-push + runs-on: ubuntu-latest + if: github.event_name != 'pull_request' + permissions: + contents: read + packages: read + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.build-and-push.outputs.version }} + format: "sarif" + output: "trivy-results.sarif" + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: "trivy-results.sarif" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9eb1eeb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +### Build and Test +```bash +# Build the project +cargo build --release + +# Run all tests +cargo test + +# Run integration tests specifically +cargo test --test integration + +# Run with coverage +cargo test --coverage +``` + +### Running the Application +```bash +# Run a one-time update +./target/release/docker-compose-updater --config ./config.yaml update + +# Start the scheduler service +./target/release/docker-compose-updater --config ./config.yaml start + +# Run from source in development +cargo run -- --config ./config.yaml update +cargo run -- --config ./config.yaml start +``` + +### Development Testing +```bash +# Test against example configuration +cargo run -- --config ./config.example.yaml update + +# Run in dry-run mode to see what would be updated +cargo run -- --config ./demo-config.yaml update # (demo-config.yaml has dry_run: true) +``` + +## Architecture Overview + +This is a Rust-based Docker Compose updater service that automatically updates image versions in Docker Compose files while preserving formatting and comments. + +### Core Components + +- **`main.rs`**: Entry point with CLI argument parsing using clap. Supports `start` (scheduler) and `update` (one-time) commands. +- **`config.rs`**: Configuration management with support for multiple registries, update strategies, and image ignore patterns. Loads from `config.yaml` or `/app/config/config.yaml`. +- **`compose.rs`**: Docker Compose file parsing and updating. Uses regex-based YAML parsing to preserve formatting and comments while updating image versions. +- **`registry.rs`**: Container registry interaction for fetching available image versions. Supports Docker Hub, GitHub Container Registry, GitLab, and custom registries. +- **`scheduler.rs`**: Cron-based scheduling system for automatic updates. Runs health checks and reports status. +- **`health.rs`**: Health monitoring server that exposes HTTP endpoints for service health checks. + +### Key Design Patterns + +- **Configuration-driven**: All behavior controlled through `config.yaml` including paths, schedules, registries, and update strategies. +- **Preserves formatting**: Uses line-by-line regex parsing rather than full YAML parsing to maintain comments and formatting. +- **Flexible update strategies**: Supports different version update approaches (latest patch of previous minor, latest patch, latest minor, latest). +- **Registry abstraction**: Generic registry client that works with multiple container registries. +- **Health monitoring**: Built-in health server for monitoring service status. + +### Update Strategies + +The system supports different update strategies defined in `config.rs`: +- `LatestPatchOfPreviousMinor` (default) +- `Latest` + +### Configuration + +- Configuration is loaded from `config.yaml` or `/app/config/config.yaml` +- Default configuration includes Docker Hub and GitHub Container Registry +- Environment variables like `GITHUB_TOKEN` are used for registry authentication +- Images can be ignored using patterns in the `ignore_images` configuration + +### Testing + +- Unit tests are embedded in each module using `#[cfg(test)]` +- Integration tests are in the `tests/` directory +- Uses `tempfile` for testing file operations +- Tests cover compose file parsing, image reference parsing, and scheduler creation \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9a23f46 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2059 @@ +# This file is automatically @generated by Cargo. +# 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +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 = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "docker-compose-updater" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "clap", + "cron", + "regex", + "reqwest", + "semver", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "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.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[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", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "reqwest" +version = "0.12.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls" +version = "0.23.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strsim" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.46.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "pin-project-lite", + "slab", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..772148f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "docker-compose-updater" +version = "0.1.0" +edition = "2021" +authors = ["Andras Schmelczer "] +description = "Automatically update Docker Compose image versions" +license = "MIT" + +[dependencies] +tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "time", "fs", "net", "io-util"] } +reqwest = { version = "0.12", features = ["json"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_yaml = "0.9" +semver = "1.0" +anyhow = "1.0" +regex = "1.10" +cron = "0.12" +clap = { version = "4.0", features = ["derive"] } +async-trait = "0.1" +tracing = "0.1" +tracing-subscriber = "0.3" +chrono = "0.4" + +[dev-dependencies] +tempfile = "3.0" + +[lib] +name = "docker_compose_updater" +path = "src/lib.rs" + +[[bin]] +name = "docker-compose-updater" +path = "src/main.rs" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9738f3e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM rust:1.85-alpine AS builder + +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static + +WORKDIR /app + +COPY Cargo.toml Cargo.lock ./ +COPY src ./src + +# Build the application +RUN cargo build --release + +# Runtime stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + curl + +# Create non-root user +RUN addgroup -g 1000 updater && \ + adduser -u 1000 -G updater -s /bin/sh -D updater + +# Create necessary directories +RUN mkdir -p /app/config && \ + chown -R updater:updater /app + +# Copy binary from builder stage +COPY --from=builder /app/target/release/docker-compose-updater /usr/local/bin/docker-compose-updater + +# Switch to non-root user +USER updater + +# Set working directory +WORKDIR /app + +# Expose health check port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +CMD ["docker-compose-updater", "--config", "/app/config/config.yaml", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab17d2b --- /dev/null +++ b/README.md @@ -0,0 +1,377 @@ +# Docker Compose Updater + +A robust, production-ready Rust service that automatically updates Docker Compose image versions whilst preserving comments, formatting, and maintaining operational stability. The service operates on a configurable schedule and uses intelligent update strategies to ensure safe, conservative updates. + +## Key Features + +- 🔄 **Intelligent Updates**: Automatically updates Docker Compose files using configurable update strategies +- 📝 **Format Preservation**: Maintains all comments, whitespace, and YAML formatting perfectly +- 🎯 **Conservative Strategies**: Defaults to stable, tested versions rather than bleeding edge +- 🏥 **Health Monitoring**: Built-in health endpoints for monitoring and observability +- 🔐 **Multi-Registry Support**: Works with Docker Hub, GitHub Container Registry, GitLab, and custom registries +- 🔍 **Flexible Filtering**: Configurable patterns to ignore specific images or registries +- 🛡️ **Robust Error Handling**: Comprehensive error management with no silent failures +- 📋 **Prefix/Suffix Support**: Handles complex versioning schemes like `v1.2.3-alpine`, `release-1.0.0-slim` +- 🚀 **Container-Ready**: Designed for containerised environments with proper security +- 📊 **Comprehensive Testing**: Extensively tested with 44+ test cases covering edge cases + +## Quick Start + +### Using Docker Compose (Recommended) + +1. **Create a configuration file** (`config.yaml`): +```yaml +# Paths to scan for Docker Compose files +compose_paths: + - "./compose-files" + - "./docker-compose.yml" + - "./services" + +# Cron expression for update schedule +schedule: "0 0 2 * * *" # Daily at 2 AM UTC + +# Update strategy (conservative by default) +update_strategy: "LatestPatchOfPreviousMinor" + +# Runtime behaviour +dry_run: false # Set to true for testing + +# Images to ignore during updates (substring matching) +ignore_images: + - "localhost" + - "127.0.0.1" + - "internal.company.com" + +# Registry configurations +registries: + "docker.io": + url: "https://registry-1.docker.io" + + "ghcr.io": + url: "https://ghcr.io" + auth_token: "${GITHUB_TOKEN}" +``` + +2. **Deploy with Docker Compose**: +```yaml +version: '3.8' +services: + docker-compose-updater: + image: ghcr.io/your-username/docker-compose-updater:latest + container_name: docker-compose-updater + restart: unless-stopped + environment: + - GITHUB_TOKEN=${GITHUB_TOKEN} + - RUST_LOG=info + volumes: + # Mount Docker socket (read-only for security) + - /var/run/docker.sock:/var/run/docker.sock:ro + # Mount compose files directory + - ./compose-files:/app/compose-files:rw + # Mount configuration + - ./config.yaml:/app/config/config.yaml:ro + ports: + - "8080:8080" # Health monitoring port + user: "1000:1000" # Run as non-root user +``` + +3. **Start the service**: +```bash +docker-compose up -d +``` + +### Command Line Usage + +```bash +# Install or build the binary +cargo build --release + +# Run a one-time update +./target/release/docker-compose-updater --config ./config.yaml update + +# Start the scheduled service with health monitoring +./target/release/docker-compose-updater --config ./config.yaml start +``` + +## Update Strategies + +The service supports three intelligent update strategies, defaulting to the most conservative: + + +### `LatestPatchOfPreviousMinor` +- **Predictable**: Always updates to the latest patch of the previous minor version +- **Consistent Behaviour**: Never updates to the current latest minor +- **Example**: `1.5.3` → `1.4.8` + +### `Latest` +- **Aggressive**: Updates to the absolute latest available version +- **Cutting Edge**: Useful for development environments +- **Example**: `1.5.3` → `2.1.0` + +## Advanced Version Handling + +The service handles complex versioning schemes intelligently: + +### Supported Version Formats +- **Standard Semantic Versions**: `1.2.3`, `2.0.1` +- **Prefixed Versions**: `v1.2.3`, `release-1.0.0`, `build123-2.1.0` +- **Suffixed Versions**: `1.2.3-alpine`, `2.0.1-slim`, `1.5.0-ubuntu20.04` +- **Combined**: `v1.2.3-alpine-slim`, `release-2.0.1-final` + +### Version Matching Logic +The service ensures that prefix and suffix combinations remain consistent: +- `v1.2.3-alpine` will only be updated to other `v*.*.*-alpine` versions +- `release-1.0.0` will only be updated to other `release-*.*.*` versions +- This prevents incompatible image variants from being selected + +## Comprehensive Registry Support + +### Supported Registries +- **Docker Hub**: `docker.io` (default registry) +- **GitHub Container Registry**: `ghcr.io` +- **GitLab Container Registry**: `registry.gitlab.com` +- **Google Container Registry**: `gcr.io` +- **Amazon ECR**: `*.dkr.ecr.*.amazonaws.com` +- **Azure Container Registry**: `*.azurecr.io` +- **Custom/Private Registries**: Any registry following Docker Registry API v2 + +### Registry Configuration +```yaml +registries: + "docker.io": + url: "https://registry-1.docker.io" + # Docker Hub typically doesn't require auth for public images + + "ghcr.io": + url: "https://ghcr.io" + auth_token: "${GITHUB_TOKEN}" + + "registry.gitlab.com": + url: "https://registry.gitlab.com" + auth_token: "${GITLAB_TOKEN}" + + "your-registry.company.com": + url: "https://your-registry.company.com" + auth_token: "${COMPANY_REGISTRY_TOKEN}" +``` + +## Supported Image Formats + +The updater handles various image naming conventions: + +```yaml +# Standard Docker Hub images +nginx:1.21.0 → nginx:1.20.6 +ubuntu:20.04 → ubuntu:18.04 + +# Namespaced images +bitnami/nginx:1.21.0 → bitnami/nginx:1.20.6 +library/postgres:13.7 → library/postgres:12.11 + +# Custom registries +ghcr.io/user/app:v1.0.0 → ghcr.io/user/app:v0.9.5 +registry.example.com/team/service:2.1.3 → registry.example.com/team/service:2.0.9 + +# Complex versioning +nginx:1.21.0-alpine → nginx:1.20.6-alpine +postgres:13.7-bullseye → postgres:12.11-bullseye +redis:v7.0.0-alpine3.16 → redis:v6.2.7-alpine3.16 + +# Local development (typically ignored) +localhost:5000/myapp:latest → [ignored by default] +127.0.0.1:8080/service:dev → [ignored by default] +``` + +## Health Monitoring & Observability + +The service provides comprehensive monitoring capabilities: + +### Health Endpoints +- **`GET /`**: Basic health check + - Returns `{"status":"healthy"}` when operational + - Returns `{"status":"unhealthy"}` during failures + - Includes basic service information + +### Logging +```bash +# Configure logging level via environment variable +RUST_LOG=debug # For detailed debugging +RUST_LOG=info # For operational information (default) +RUST_LOG=warn # For warnings and errors only +RUST_LOG=error # For errors only +``` + +### Service Monitoring +The service reports: +- Successful update cycles with file counts +- Failed updates with detailed error information +- Registry connectivity status +- Configuration validation results + +## Security Features + +- **Non-Root Execution**: Designed to run as non-root user in containers +- **Minimal Attack Surface**: Alpine Linux base image with minimal packages +- **Read-Only Docker Socket**: Only requires read access to Docker socket +- **Secret Management**: All tokens provided via environment variables +- **Input Validation**: Comprehensive validation of all configuration inputs +- **Error Sanitisation**: No sensitive information leaked in error messages + +## Development + +### Building from Source + +```bash +# Clone the repository +git clone https://github.com/your-username/docker-compose-updater.git +cd docker-compose-updater + +# Build in release mode +cargo build --release + +# Run comprehensive test suite +cargo test + +# Run with custom configuration +./target/release/docker-compose-updater --config ./config.example.yaml start +``` + +### Testing + +The project includes extensive testing: + +```bash +# Run all tests (44+ test cases) +cargo test + +# Run specific test suites +cargo test --test integration_tests # Integration tests +cargo test --test test_error_handling # Error handling tests +cargo test --test test_config_validation # Configuration tests +cargo test --test test_compose_operations # Compose file operations + +# Run with output for debugging +cargo test -- --nocapture + +# Generate coverage report (requires cargo-llvm-cov) +cargo llvm-cov --html +``` + +### Test Coverage +- **Error Handling**: Invalid configurations, malformed files, network failures +- **Config Validation**: All configuration formats, edge cases, defaults +- **Compose Operations**: Complex file formats, image parsing, version handling +- **Registry Integration**: Multiple registry types, authentication, failures +- **Version Logic**: All update strategies, complex versioning schemes + +## Configuration Reference + +### Complete Configuration Example +```yaml +# File/directory paths to scan for Docker Compose files +compose_paths: + - "/app/compose-files" # Directory to scan recursively + - "./docker-compose.yml" # Specific file + - "./services/" # Another directory + +# Cron expression for update schedule (UTC timezone) +schedule: "0 0 2 * * *" # Daily at 2 AM + +# Update strategy selection +update_strategy: "LatestPatchOfPreviousMinor" + +# Prevent actual file modifications (for testing) +dry_run: false + +# Images to ignore during updates (substring matching) +ignore_images: + - "localhost" # Local development images + - "127.0.0.1" # Local registry + - "internal.company.com" # Private internal registry + - "snapshot" # Snapshot versions + +# Registry configurations +registries: + "docker.io": + url: "https://registry-1.docker.io" + # auth_token not typically needed for public images + + "ghcr.io": + url: "https://ghcr.io" + auth_token: "${GITHUB_TOKEN}" + + "registry.gitlab.com": + url: "https://registry.gitlab.com" + auth_token: "${GITLAB_TOKEN}" +``` + +### Environment Variables +- **`GITHUB_TOKEN`**: Personal access token for GitHub Container Registry +- **`GITLAB_TOKEN`**: Access token for GitLab Container Registry +- **`RUST_LOG`**: Logging level (`debug`, `info`, `warn`, `error`) +- **Registry-specific tokens**: For private registries + +## Operational Considerations + +### Backup Strategy +- Always backup your Docker Compose files before running updates +- Use version control (Git) to track changes +- Test with `dry_run: true` before enabling actual updates + +### Monitoring +- Monitor the health endpoint for service availability +- Set up alerts for failed update cycles +- Review logs regularly for registry connectivity issues + +### Resource Requirements +- **Memory**: ~10-50MB during operation +- **CPU**: Minimal during dormant periods, brief spikes during updates +- **Network**: Requires outbound HTTPS access to configured registries +- **Storage**: Minimal, only for configuration and temporary files + +## Troubleshooting + +### Common Issues + +**Service fails to start** +- Check configuration file syntax with `yamllint` +- Verify file paths exist and are accessible +- Ensure cron expression is valid + +**Registry authentication failures** +- Verify environment variables are set correctly +- Check token permissions for the target registry +- Test registry connectivity manually + +**Updates not applied** +- Check if `dry_run` is enabled +- Verify images are not in the ignore list +- Ensure semantic versioning is used in image tags + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Write tests for your changes +4. Ensure all tests pass (`cargo test`) +5. Commit your changes (`git commit -m 'Add amazing feature'`) +6. Push to the branch (`git push origin feature/amazing-feature`) +7. Open a Pull Request + +## Licence + +This project is licensed under the MIT Licence - see the [LICENSE](LICENSE) file for details. + +## Changelog + +### v0.1.0 +- Initial release with comprehensive functionality +- Multi-registry support (Docker Hub, GHCR, GitLab, custom) +- Intelligent update strategies with conservative defaults +- Comment and formatting preservation +- Comprehensive error handling (no silent failures) +- Health monitoring and observability +- Extensive test coverage (44+ test cases) +- Prefix/suffix version handling +- Robust configuration validation +- Production-ready security features \ No newline at end of file diff --git a/compose/backup.yml b/compose/backup.yml new file mode 100644 index 0000000..c0fb23a --- /dev/null +++ b/compose/backup.yml @@ -0,0 +1,4 @@ +name: active +services: + backup: + image: ghcr.io/schmelczer/backup-container:v0.0.3 diff --git a/compose/calibre.yml b/compose/calibre.yml new file mode 100644 index 0000000..c5cd950 --- /dev/null +++ b/compose/calibre.yml @@ -0,0 +1,11 @@ +name: active +services: + audiobookshelf: + image: advplyr/audiobookshelf:2.26.0 + + calibre-web: + image: "" # required for wud + build: + +networks: + local-network: diff --git a/compose/cloud.yml b/compose/cloud.yml new file mode 100644 index 0000000..fd9af4d --- /dev/null +++ b/compose/cloud.yml @@ -0,0 +1,10 @@ +name: active +services: + filebrowser: + image: filebrowser/filebrowser:v2.40.1 + + syncthing: + image: syncthing/syncthing:1.30.0 + +networks: + local-network: diff --git a/compose/dns.yml b/compose/dns.yml new file mode 100644 index 0000000..622b80a --- /dev/null +++ b/compose/dns.yml @@ -0,0 +1,4 @@ +name: active +services: + dns-server: + image: technitium/dns-server:13.6.0 diff --git a/compose/frontdoor.yml b/compose/frontdoor.yml new file mode 100644 index 0000000..2d0d97c --- /dev/null +++ b/compose/frontdoor.yml @@ -0,0 +1,39 @@ +name: active +services: + authelia: + image: authelia/authelia:4.39.5 + + certbot: + image: certbot/dns-digitalocean:v4.1.1 + + dyndns: + build: + context: /volumes/dyndns + container_name: frontdoor_dyndns + environment: + TZ: $TIME_ZONE + DOMAIN: schmelczer.dev + NAME: home;cloud;dns;immich;declared;pdf;homeassistant;store;audiobook;books;movies;stats;wud;auth;paperless;minecraft;torrent;obsidian;ghostfolio;vpn;mealie + DIGITALOCEAN_TOKEN: $DIGITALOCEAN_TOKEN + networks: + - local-network + restart: unless-stopped + init: true + tty: true + + fail2ban: + image: ghcr.io/crazy-max/fail2ban:1.1.0 + container_name: frontdoor_fail2ban + volumes: + - /volumes/fail2ban:/data + - /volumes/nginx/logs:/var/log:ro + - /volumes/vaultwarden/data/vaultwarden.log:/vaultwarden/vaultwarden.log:ro + environment: + TZ: $TIME_ZONE + F2B_LOG_TARGET: STDOUT + + tinyauth: + image: ghcr.io/steveiliop56/tinyauth:v3.6.2 + + nginx: + image: nginx:1.29.0 diff --git a/compose/github-runner.yml b/compose/github-runner.yml new file mode 100644 index 0000000..a2b65f3 --- /dev/null +++ b/compose/github-runner.yml @@ -0,0 +1,20 @@ +name: active +services: + github-runner: + build: + context: /volumes/github-runner + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - REPO_URL=https://github.com/schmelczer/vault-link + - ACCESS_TOKEN=$GITHUB_ACTIONS_RUNNER_TOKEN + restart: unless-stopped + deploy: + resources: + limits: + cpus: '6' + memory: '8G' + mode: replicated + replicas: 3 + init: true + tty: true diff --git a/compose/homeassistant.yml b/compose/homeassistant.yml new file mode 100644 index 0000000..1f90b76 --- /dev/null +++ b/compose/homeassistant.yml @@ -0,0 +1,4 @@ +name: active +services: + homeassistant: + image: homeassistant/home-assistant:2025.7.2 diff --git a/compose/immich.yml b/compose/immich.yml new file mode 100644 index 0000000..4acb328 --- /dev/null +++ b/compose/immich.yml @@ -0,0 +1,17 @@ +name: active +services: + database: + image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 + + immich-folder-album-creator: + image: salvoxia/immich-folder-album-creator:0.19.0 + + immich-machine-learning: + image: ghcr.io/immich-app/immich-machine-learning:v1.135.3 + + nas_sync: + build: + context: /volumes/nas-sync + + redis: + image: docker.io/redis:6.2-alpine@sha256:eaba718fecd1196d88533de7ba49bf903ad33664a92debb24660a922ecd9cac8 diff --git a/compose/media.yml b/compose/media.yml new file mode 100644 index 0000000..6e83933 --- /dev/null +++ b/compose/media.yml @@ -0,0 +1,22 @@ +name: active +services: + gluetun: + image: qmcgaw/gluetun:v3.40.0 + + jellyfin: + image: jellyfin/jellyfin:10.10.7 + + jellyseerr: + image: fallenbagel/jellyseerr:2.7.1 + + prowlarr: + image: lscr.io/linuxserver/prowlarr:1.37.0 + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:5.1.2-libtorrentv1 + + radarr: + image: lscr.io/linuxserver/radarr:5.26.2 + + sonarr: + image: lscr.io/linuxserver/sonarr:4.0.15 diff --git a/compose/minecraft.yml b/compose/minecraft.yml new file mode 100644 index 0000000..160a7c1 --- /dev/null +++ b/compose/minecraft.yml @@ -0,0 +1,4 @@ +name: active +services: + minecraft_andris: + image: itzg/minecraft-server:2025.6.2-java21-graalvm diff --git a/compose/obsidian.yml b/compose/obsidian.yml new file mode 100644 index 0000000..a013147 --- /dev/null +++ b/compose/obsidian.yml @@ -0,0 +1,4 @@ +name: active +services: + obsidian: + image: ghcr.io/schmelczer/vault-link:0.5.1 diff --git a/compose/paperless.yml b/compose/paperless.yml new file mode 100644 index 0000000..07ae2ef --- /dev/null +++ b/compose/paperless.yml @@ -0,0 +1,19 @@ +name: active +services: + paperless-ai: + image: clusterzx/paperless-ai:3.0.7 + + paperless-broker: + image: redis:8.0.3-alpine + + paperless-gotenberg: + image: gotenberg/gotenberg:8.21.1 + + paperless-ollama: + image: ollama/ollama:0.9.6 + + paperless-tika: + image: apache/tika:3.2.1.0-full + + paperless-webserver: + image: ghcr.io/paperless-ngx/paperless-ngx:2.17.1 diff --git a/compose/pdf.yml b/compose/pdf.yml new file mode 100644 index 0000000..c0c30dc --- /dev/null +++ b/compose/pdf.yml @@ -0,0 +1,7 @@ +name: active +services: + stirling-pdf: + image: stirlingtools/stirling-pdf:1.0.2-fat + +networks: + local-network: diff --git a/compose/pinyin-anki.yml b/compose/pinyin-anki.yml new file mode 100644 index 0000000..94a32da --- /dev/null +++ b/compose/pinyin-anki.yml @@ -0,0 +1,7 @@ +name: active +services: + pinyin-anki: + image: ghcr.io/schmelczer/pinyin:latest + +networks: + local-network: diff --git a/compose/plausible.yml b/compose/plausible.yml new file mode 100644 index 0000000..c5f22bb --- /dev/null +++ b/compose/plausible.yml @@ -0,0 +1,16 @@ +# copied from https://github.com/plausible/hosting/tree/master +name: active +services: + plausible: + image: plausible/analytics:v2.0.0 + + plausible_db: + image: postgres:14-alpine + + plausible_events_db: + image: clickhouse/clickhouse-server:23.3.7.5-alpine + +networks: + local-network: + plausible-network: + internal: true diff --git a/compose/projects.yml b/compose/projects.yml new file mode 100644 index 0000000..8f33399 --- /dev/null +++ b/compose/projects.yml @@ -0,0 +1,54 @@ +name: active +services: + declared: + build: + context: /volumes/declared-server + container_name: declared + environment: + NODE_ENV: production + TZ: $TIME_ZONE + networks: + - local-network + restart: unless-stopped + user: "1000" + init: true + tty: true + + life-towers: + build: + context: /volumes/life-towers + container_name: life_towers + depends_on: + - towers-db + environment: + TZ: $TIME_ZONE + networks: + - towers-network + - local-network + restart: unless-stopped + user: "1000" + init: true + tty: true + + towers-db: + image: postgres:15.3-alpine3.18 + container_name: towers_db + volumes: + - /volumes/life-towers/db:/var/lib/postgresql/data + - /volumes/life-towers/src/schema.sql:/docker-entrypoint-initdb.d/init.sql + environment: + POSTGRES_USER: storebackend + POSTGRES_PASSWORD: UKLpn6y4j4AjmBuB + POSTGRES_DB: store + TZ: $TIME_ZONE + networks: + - towers-network + restart: unless-stopped + user: "1000" + init: true + tty: true + +networks: + local-network: + towers-network: + internal: true diff --git a/compose/smtp.yml b/compose/smtp.yml new file mode 100644 index 0000000..69d34c7 --- /dev/null +++ b/compose/smtp.yml @@ -0,0 +1,4 @@ +name: active +services: + smtp: + image: bytemark/smtp:latest diff --git a/compose/ssh.yml b/compose/ssh.yml new file mode 100644 index 0000000..801276a --- /dev/null +++ b/compose/ssh.yml @@ -0,0 +1,33 @@ +name: active +services: + ssh: + build: + context: /volumes/stack + container_name: ssh + volumes: + - /volumes:/volumes + - /:/host + - /volumes/stack:/root/stack + - /volumes/stack/.zsh_history:/root/.zsh_history + - /volumes/stack/.zshrc:/root/.zshrc + - /var/run/docker.sock:/var/run/docker.sock + environment: + TZ: $TIME_ZONE + network_mode: host + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + cap_add: + - SYS_ADMIN + - SYS_RAWIO + deploy: + resources: + limits: + cpus: "12" + memory: 4G + devices: + - "/dev/nvme0n1" + tty: true + +networks: + local-network: diff --git a/compose/stack.yml b/compose/stack.yml new file mode 100644 index 0000000..0bdcce0 --- /dev/null +++ b/compose/stack.yml @@ -0,0 +1,18 @@ +# The necesseary services for keeping the stack running. + +# The goal is to keep the number of this to a minimum as to decrease overhead +# and avoid unneeded complexity. + +name: active +services: + dozzle: + image: amir20/dozzle:v8.13.6 + + uptime-kuma: + image: louislam/uptime-kuma:1.23.16 + + homepage: + image: ghcr.io/gethomepage/homepage:feature-deps-180425 + +networks: + local-network: diff --git a/compose/vaultwarden.yml b/compose/vaultwarden.yml new file mode 100644 index 0000000..1eeebc4 --- /dev/null +++ b/compose/vaultwarden.yml @@ -0,0 +1,4 @@ +name: active +services: + vaultwarden: + image: vaultwarden/server:1.34.1 diff --git a/compose/wireguard.yml b/compose/wireguard.yml new file mode 100644 index 0000000..d160671 --- /dev/null +++ b/compose/wireguard.yml @@ -0,0 +1,4 @@ +name: active +services: + wg-easy: + image: ghcr.io/wg-easy/wg-easy:15.1.0 diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..04f67b6 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,46 @@ +# Docker Compose Updater Configuration +# This configuration determines how the updater behaves + +# Paths to scan for Docker Compose files +compose_paths: + - "/app/compose-files" + - "./docker-compose.yml" + - "./compose.yml" + +# Cron expression for when to run updates +# Default: Daily at 2 AM UTC +schedule: "0 0 2 * * *" + +# Registry configurations +# All registries use the standard Docker Registry v2 API +registries: + "docker.io": + url: "https://registry-1.docker.io" + # Docker Hub uses the standard registry API endpoint + + "ghcr.io": + url: "https://ghcr.io" + auth_token: "${GITHUB_TOKEN}" + # GitHub token must have 'read:packages' scope and access to the target repositories + + "registry.gitlab.com": + url: "https://registry.gitlab.com" + auth_token: "${GITLAB_TOKEN}" + + # Add any custom registry following the same pattern: + # "my-registry.company.com": + # url: "https://my-registry.company.com" + # auth_token: "${CUSTOM_REGISTRY_TOKEN}" + +# Update strategy +# Options: LatestPatchOfPreviousMinor (default), LatestPatchOfPreviousMinor, Latest +update_strategy: "LatestPatchOfPreviousMinor" + +# Images to ignore (substring matching) +ignore_images: + - "localhost" + - "127.0.0.1" + - "local/" + +# Dry run mode - if true, no files will be modified +dry_run: false diff --git a/demo-config.yaml b/demo-config.yaml new file mode 100644 index 0000000..6524c47 --- /dev/null +++ b/demo-config.yaml @@ -0,0 +1,22 @@ +compose_paths: + - "/app/compose-files" + - "./test-docker-compose.yml" + +schedule: "0 0 2 * * *" + +registries: + "docker.io": + url: "https://registry-1.docker.io" + + "ghcr.io": + url: "https://ghcr.io" + auth_token: "${GITHUB_TOKEN}" + +update_strategy: "LatestPatchOfPreviousMinor" + +ignore_images: + - "localhost" + - "127.0.0.1" + - "local/" + +dry_run: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f807e6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.8' + +services: + docker-compose-updater: + image: ghcr.io/your-username/docker-compose-updater:latest + container_name: docker-compose-updater + restart: unless-stopped + + environment: + - GITHUB_TOKEN=${GITHUB_TOKEN} + - GITLAB_TOKEN=${GITLAB_TOKEN} + - RUST_LOG=info + - TZ=UTC + + volumes: + # Mount Docker socket for container management + - /var/run/docker.sock:/var/run/docker.sock:ro + + # Mount directory containing your compose files + - ./compose-files:/app/compose-files:rw + + # Mount configuration + - ./config.yaml:/app/config/config.yaml:ro + + # Optional: Mount additional compose files + - ./docker-compose.yml:/app/docker-compose.yml:rw + + ports: + - "8080:8080" + + networks: + - updater-network + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + +networks: + updater-network: + driver: bridge \ No newline at end of file diff --git a/src/compose.rs b/src/compose.rs new file mode 100644 index 0000000..9d4cc86 --- /dev/null +++ b/src/compose.rs @@ -0,0 +1,2 @@ +pub mod parser; +pub mod updater; diff --git a/src/compose/parser.rs b/src/compose/parser.rs new file mode 100644 index 0000000..6163058 --- /dev/null +++ b/src/compose/parser.rs @@ -0,0 +1,186 @@ +use crate::registry::ImageRef; +use anyhow::Result; +use regex::Regex; +use std::fs; +use tracing::{info, warn}; + +#[derive(Debug, Clone)] +pub struct ServiceImage { + pub service_name: String, + pub image_ref: ImageRef, + pub original_line: String, + pub line_number: usize, +} + +pub struct ComposeFile { + pub content: String, + pub services: Vec, +} + +pub struct ComposeParser; + +impl ComposeParser { + pub fn new() -> Self { + Self + } + + pub fn parse_file(&self, file_path: &str) -> Result { + let content = fs::read_to_string(file_path)?; + let services = self.extract_services(&content)?; + + Ok(ComposeFile { content, services }) + } + + fn extract_services(&self, content: &str) -> Result> { + let mut services = Vec::new(); + let lines: Vec<&str> = content.lines().collect(); + let mut in_services = false; + let mut current_service: Option = None; + let image_regex = Regex::new(r#"^\s*image:\s*(?:["']([^"']+)["']|([^\s#]+))\s*(#.*)?$"#)?; + + for (line_number, line) in lines.iter().enumerate() { + if line.trim_start().starts_with("services:") { + in_services = true; + continue; + } + + if in_services + && line.chars().next().is_some_and(|c| c.is_alphabetic()) + && !line.starts_with(" ") + { + in_services = false; + current_service = None; + continue; + } + + if in_services { + if let Some(service_name) = self.extract_service_name(line) { + current_service = Some(service_name); + continue; + } + + if let Some(ref service_name) = current_service { + if let Some(image_ref) = self.extract_image_from_line(line, &image_regex)? { + info!( + "Found service '{}' with image '{}'", + service_name, image_ref + ); + services.push(ServiceImage { + service_name: service_name.clone(), + image_ref, + original_line: line.to_string(), + line_number, + }); + } + } + } + } + + Ok(services) + } + + fn extract_service_name(&self, line: &str) -> Option { + let trimmed = line.trim_start(); + let indent_level = line.len() - trimmed.len(); + + if indent_level > 0 && indent_level <= 8 && trimmed.ends_with(':') && !trimmed.contains(' ') + { + let potential_service = trimmed.trim_end_matches(':'); + if !potential_service.is_empty() + && potential_service + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') + { + return Some(potential_service.to_string()); + } + } + + None + } + + fn extract_image_from_line(&self, line: &str, image_regex: &Regex) -> Result> { + let trimmed = line.trim_start(); + let indent_level = line.len() - trimmed.len(); + + if indent_level > 2 && trimmed.starts_with("image:") { + if let Some(captures) = image_regex.captures(line) { + let image_str = captures + .get(1) + .or_else(|| captures.get(2)) + .unwrap() + .as_str(); + + if image_str.find('@').is_some() { + info!("Found digest in image string, skipping: {}", image_str); + return Ok(None); + } + + if image_str.trim().is_empty() + || image_str.trim() == "\"\"" + || image_str.trim() == "''" + { + info!("Empty image string found in line: {}, skipping", line); + return Ok(None); + } + + match ImageRef::parse(image_str) { + Ok(image_ref) => Ok(Some(image_ref)), + Err(e) => { + warn!("Failed to parse image '{}': {}", image_str, e); + Ok(None) + } + } + } else { + Ok(None) + } + } else { + Ok(None) + } + } +} + +impl Default for ComposeParser { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn test_parse_compose_file() { + let compose_content = r#" +version: '3.8' +services: + web: + image: nginx:1.21.0 # Web server + ports: + - "80:80" + + db: + image: postgres:13.7 + environment: + POSTGRES_DB: myapp + + redis: + image: redis:6.2-alpine +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(compose_content.as_bytes()).unwrap(); + + let parser = ComposeParser::new(); + let result = parser + .parse_file(temp_file.path().to_str().unwrap()) + .unwrap(); + + assert_eq!(result.services.len(), 3); + assert_eq!(result.services[0].service_name, "web"); + assert_eq!(result.services[0].image_ref.name, "nginx"); + assert_eq!(result.services[0].image_ref.tag, "1.21.0"); + } +} diff --git a/src/compose/updater.rs b/src/compose/updater.rs new file mode 100644 index 0000000..6b098a3 --- /dev/null +++ b/src/compose/updater.rs @@ -0,0 +1,293 @@ +use super::parser::{ComposeFile, ComposeParser, ServiceImage}; +use crate::config::Config; +use crate::registry::Client as RegistryClient; +use crate::strategy::create_selector; +use crate::version::parse_version_tag; +use anyhow::{anyhow, Result}; +use regex::Regex; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, info, warn}; + +pub struct ComposeUpdater { + config: Config, + registry_client: RegistryClient, + parser: ComposeParser, +} + +impl ComposeUpdater { + pub fn new(config: Config) -> Self { + let registry_client = RegistryClient::new(config.clone()); + let parser = ComposeParser::new(); + + Self { + config, + registry_client, + parser, + } + } + + pub async fn update_all_compose_files(&self) -> Result> { + let mut updated_files = Vec::new(); + + for compose_path in &self.config.compose_paths { + let compose_files = self.find_compose_files(compose_path)?; + + for file_path in compose_files { + let updated = self.update_compose_file(&file_path).await?; + if updated { + updated_files.push(file_path); + } + } + } + + Ok(updated_files) + } + + pub fn parse_compose_file(&self, file_path: &str) -> Result { + self.parser.parse_file(file_path) + } + + async fn update_compose_file(&self, file_path: &str) -> Result { + info!("Processing compose file: {}", file_path); + + let compose_file = self.parse_compose_file(file_path)?; + let mut updated = false; + let mut new_content = compose_file.content.clone(); + + for service in &compose_file.services { + if self.config.is_image_ignored(&service.image_ref.to_string()) { + info!("Skipping ignored image: {}", service.image_ref.to_string()); + continue; + } + + match self.update_service_image(service).await { + Ok(Some(new_image)) => { + new_content = + self.replace_image_in_content(&new_content, service, &new_image)?; + updated = true; + info!( + "Updated {}: {} -> {}", + service.service_name, + service.image_ref.to_string(), + new_image + ); + } + Ok(None) => { + debug!( + "No update needed for {}: {}", + service.service_name, + service.image_ref.to_string() + ); + } + Err(e) => { + return Err( + e.context(format!("Failed to update service {}", service.service_name)) + ); + } + } + } + + if updated { + self.write_updated_content(file_path, new_content)?; + } + + Ok(updated) + } + + fn write_updated_content(&self, file_path: &str, content: String) -> Result<()> { + if !self.config.dry_run { + fs::write(file_path, content)?; + info!("Updated compose file: {}", file_path); + } else { + info!("Dry run: Would update compose file: {}", file_path); + } + Ok(()) + } + + async fn update_service_image(&self, service: &ServiceImage) -> Result> { + info!( + "Checking for updates for service: {} (current image: {})", + service.service_name, + service.image_ref.to_string() + ); + let (current_version, current_prefix, current_suffix, _) = + parse_version_tag(&service.image_ref.tag); + + let available_versions = self + .registry_client + .get_available_versions(&service.image_ref) + .await?; + + if available_versions.is_empty() { + warn!("No versions available for selection"); + return Ok(None); + } + + let selector = create_selector(&self.config.update_strategy); + if let Some(target_version_info) = + selector.select_target_version(&available_versions, current_prefix, current_suffix) + { + // Only update if the target version is different AND higher than the current version + // This prevents downgrades + if Some(target_version_info.version.clone()) != current_version { + if let Some(ref current_ver) = current_version { + if target_version_info.version < *current_ver { + info!( + "Skipping downgrade from {} to {}", + current_ver, target_version_info.version + ); + return Ok(None); + } + } + let mut new_image_ref = service.image_ref.clone(); + new_image_ref.tag = target_version_info.to_string(); + return Ok(Some(new_image_ref.to_string())); + } + } + + Ok(None) + } + + pub fn replace_image_in_content( + &self, + content: &str, + service: &ServiceImage, + new_image: &str, + ) -> Result { + let image_regex = + Regex::new(r#"^(\s*image:\s*)(?:["']([^"']+)["']|([^\s#]+))(\s*(?:#.*)?)$"#)?; + + if let Some(captures) = image_regex.captures(&service.original_line) { + let prefix = captures.get(1).unwrap().as_str(); + let _old_image = captures + .get(2) + .or_else(|| captures.get(3)) + .unwrap() + .as_str(); + let suffix = captures.get(4).unwrap().as_str(); + + let image_part = if captures.get(2).is_some() { + format!("\"{new_image}\"") + } else { + new_image.to_string() + }; + + let new_line = format!("{prefix}{image_part}{suffix}"); + + let lines: Vec<&str> = content.lines().collect(); + if service.line_number < lines.len() + && lines[service.line_number] == service.original_line + { + let mut result_lines = lines; + result_lines[service.line_number] = &new_line; + let mut result = result_lines.join("\n"); + + if content.ends_with('\n') { + result.push('\n'); + } + + Ok(result) + } else { + Err(anyhow!( + "Line number mismatch or content changed for service: {}", + service.service_name + )) + } + } else { + Err(anyhow!( + "Could not parse image line: {}", + service.original_line + )) + } + } + + fn find_compose_files(&self, path: &Path) -> Result> { + let mut visited = HashSet::new(); + self.find_compose_files_recursive(path, &mut visited) + } + + fn find_compose_files_recursive( + &self, + path: &Path, + visited: &mut HashSet, + ) -> Result> { + let canonical_path = match path.canonicalize() { + Ok(p) => p, + Err(e) => { + warn!("Failed to canonicalize path {}: {}", path.display(), e); + return Ok(Vec::new()); + } + }; + + if !visited.insert(canonical_path.clone()) { + return Ok(Vec::new()); + } + + let mut compose_files = Vec::new(); + + if path.is_file() { + if self.is_compose_file(path)? { + compose_files.push(path.to_string_lossy().to_string()); + } + } else if path.is_dir() { + for entry in fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + + if entry_path.is_file() && self.is_compose_file(&entry_path)? { + compose_files.push(entry_path.to_string_lossy().to_string()); + } else if entry_path.is_dir() { + compose_files.extend(self.find_compose_files_recursive(&entry_path, visited)?); + } + } + } + + Ok(compose_files) + } + + fn is_compose_file(&self, path: &Path) -> Result { + let filename = path + .file_name() + .ok_or_else(|| anyhow!("Invalid file path: {:?}", path))? + .to_string_lossy(); + Ok(filename.ends_with(".yml") || filename.ends_with(".yaml")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, UpdateStrategy}; + use std::io::Write; + use tempfile::NamedTempFile; + + #[tokio::test] + async fn test_prevents_downgrade() { + let mut config = Config::default(); + config.update_strategy = UpdateStrategy::Latest; + config.dry_run = true; + + let updater = ComposeUpdater::new(config); + + // Create a temporary compose file with a higher version + let mut temp_file = NamedTempFile::new().unwrap(); + writeln!(temp_file, "services:\n web:\n image: nginx:1.25.0").unwrap(); + + let file_path = temp_file.path().to_str().unwrap(); + let compose_file = updater.parse_compose_file(file_path).unwrap(); + + assert_eq!(compose_file.services.len(), 1); + let service = &compose_file.services[0]; + + // Mock a scenario where the strategy selects a lower version + // This would happen if available versions only include older versions + let (current_version, _, _, _) = parse_version_tag(&service.image_ref.tag); + assert!(current_version.is_some()); + + // The actual test would need mocked registry responses, but we can verify + // the logic by checking that current version is properly extracted + assert_eq!(current_version.unwrap().to_string(), "1.25.0"); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c8db59b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,105 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use tracing::info; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub compose_paths: Vec, + #[serde(default)] + pub schedule: String, + #[serde(default)] + pub registries: HashMap, + #[serde(default)] + pub update_strategy: UpdateStrategy, + #[serde(default)] + pub ignore_images: Vec, + #[serde(default)] + pub dry_run: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegistryConfig { + pub url: String, + pub auth_token: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub enum UpdateStrategy { + #[default] + LatestPatchOfPreviousMinor, + Latest, +} + +impl Default for Config { + fn default() -> Self { + let mut registries = HashMap::new(); + registries.insert( + "docker.io".to_string(), + RegistryConfig { + url: "https://registry-1.docker.io".to_string(), + auth_token: None, + }, + ); + registries.insert( + "ghcr.io".to_string(), + RegistryConfig { + url: "https://ghcr.io".to_string(), + auth_token: std::env::var("GITHUB_TOKEN").ok(), + }, + ); + + Self { + compose_paths: vec![PathBuf::from(".")], + schedule: "0 0 2 * * *".to_string(), // Daily at 2 AM + registries, + update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor, + ignore_images: vec![], + dry_run: false, + } + } +} + +impl Config { + pub fn load(config_path: PathBuf) -> Result { + info!("Loading configuration from {}", config_path.display()); + let content = std::fs::read_to_string(config_path)?; + let expanded_content = Self::expand_env_vars(&content); + let mut config: Self = serde_yaml::from_str(&expanded_content)?; + config.resolve_env_tokens(); + Ok(config) + } + + /// Expand environment variable placeholders like ${VAR} in the config content + pub fn expand_env_vars(content: &str) -> String { + let env_var_pattern = regex::Regex::new(r"\$\{([^}]+)\}").unwrap(); + + env_var_pattern + .replace_all(content, |caps: ®ex::Captures| { + let var_name = &caps[1]; + std::env::var(var_name).unwrap_or_else(|_| format!("${{{var_name}}}")) + }) + .to_string() + } + + /// Resolve environment variable tokens after deserialization + fn resolve_env_tokens(&mut self) { + for registry_config in self.registries.values_mut() { + if let Some(token) = ®istry_config.auth_token { + if token.starts_with("$") { + // Handle direct env var references like "$GITHUB_TOKEN" + let env_var_name = token.trim_start_matches('$'); + registry_config.auth_token = std::env::var(env_var_name).ok(); + } + } + } + } + + pub fn is_image_ignored(&self, image: &str) -> bool { + self.ignore_images + .iter() + .any(|ignored| image.contains(ignored)) + } +} diff --git a/src/health.rs b/src/health.rs new file mode 100644 index 0000000..1e66651 --- /dev/null +++ b/src/health.rs @@ -0,0 +1,114 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::time::timeout; +use tracing::{info, warn}; + +pub struct HealthServer { + last_update_success: Arc>, +} + +#[derive(Clone)] +pub struct HealthHandle { + last_update_success: Arc>, +} + +impl Default for HealthServer { + fn default() -> Self { + Self::new().0 + } +} + +impl HealthServer { + pub fn new() -> (Self, HealthHandle) { + let last_update_success = Arc::new(Mutex::new(true)); + let server = Self { + last_update_success: last_update_success.clone(), + }; + let handle = HealthHandle { + last_update_success, + }; + (server, handle) + } + + pub async fn start(self) -> anyhow::Result<()> { + let listener = TcpListener::bind("0.0.0.0:8080").await?; + info!("Health server listening on port 8080"); + + loop { + let (mut socket, _) = listener.accept().await?; + let health_status = self.last_update_success.clone(); + + tokio::spawn(async move { + // Set timeout for the entire request handling (5 seconds) + let handle_request = async { + let mut buffer = [0; 1024]; + + // Read with timeout to prevent hanging connections + match timeout(Duration::from_secs(5), socket.read(&mut buffer)).await { + Ok(Ok(_)) => { + // Successfully read request (we don't need to parse it for health check) + } + Ok(Err(_)) | Err(_) => { + warn!("Health check request read timeout or error"); + return; + } + } + + // Safely access health status without panicking + let is_healthy = match health_status.lock() { + Ok(status) => *status, + Err(_) => { + warn!("Health status mutex poisoned, defaulting to unhealthy"); + false + } + }; + + let (status_line, json_body) = if is_healthy { + ("HTTP/1.1 200 OK", "{\"status\":\"healthy\"}") + } else { + ( + "HTTP/1.1 503 Service Unavailable", + "{\"status\":\"unhealthy\"}", + ) + }; + + let response = format!( + "{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + status_line, + json_body.len(), + json_body + ); + + let _ = timeout( + Duration::from_secs(5), + socket.write_all(response.as_bytes()), + ) + .await; + }; + + // Overall timeout for the entire connection handling + let _ = timeout(Duration::from_secs(10), handle_request).await; + }); + } + } +} + +impl HealthHandle { + pub fn set_health_status(&self, is_healthy: bool) { + if let Ok(mut status) = self.last_update_success.lock() { + *status = is_healthy; + } + } + + pub fn report_update_success(&self) { + info!("Update succeeded - marking health as healthy"); + self.set_health_status(true); + } + + pub fn report_update_failure(&self) { + info!("Update failed - marking health as unhealthy"); + self.set_health_status(false); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e116e92 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod compose; +pub mod config; +pub mod health; +pub mod registry; +pub mod scheduler; +pub mod strategy; +pub mod version; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8619e8d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,77 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use tracing::{info, Level}; + +mod compose; +mod config; +mod health; +mod registry; +mod scheduler; +mod strategy; +mod version; + +use config::Config; +use health::HealthServer; +use scheduler::Scheduler; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// Path to the configuration file + #[arg(short, long)] + config: PathBuf, + + /// Verbose output (-v, -vv, -vvv for increasing verbosity) + #[arg(short, long, action = clap::ArgAction::Count, global = true)] + verbose: u8, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Start the updater service + Start, + /// Run a one-time update + Update, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + let log_level = match cli.verbose { + 0 => Level::INFO, + 1 => Level::DEBUG, + _ => Level::TRACE, + }; + + tracing_subscriber::fmt() + .with_max_level(log_level) + .with_timer(tracing_subscriber::fmt::time::SystemTime) + .with_target(false) + .init(); + let config = Config::load(cli.config)?; + + info!( + "Starting Docker Compose Updater v{}", + env!("CARGO_PKG_VERSION") + ); + + match cli.command { + Commands::Start => { + let (health_server, health_handle) = HealthServer::new(); + let scheduler = Scheduler::new(config.clone(), Some(health_handle))?; + tokio::try_join!(health_server.start(), scheduler.start())?; + } + Commands::Update => { + let scheduler = Scheduler::new(config, None)?; + scheduler.run_once().await?; + } + } + + Ok(()) +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..dfa9f67 --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,605 @@ +use crate::config::{Config, RegistryConfig}; +use crate::version::VersionInfo; +use anyhow::{anyhow, Result}; +use chrono::{DateTime, Utc}; +use reqwest::{Client as HttpClient, Response, StatusCode}; +use serde::Deserialize; +use std::time::Duration; +use tokio::time::sleep; +use tracing::{debug, warn}; + +const PAGE_SIZE: usize = 500; +const MAX_RETRY_ATTEMPTS: u32 = 5; +const INITIAL_RETRY_DELAY_SECS: u64 = 1; + +#[derive(Debug, Clone)] +pub struct ImageRef { + pub registry: String, + pub namespace: Option, + pub name: String, + pub tag: String, +} + +impl ImageRef { + pub fn parse(image: &str) -> Result { + let (image_part, tag) = if let Some(last_delim) = image.rfind(':') { + (&image[..last_delim], &image[last_delim + 1..]) + } else { + (image, "latest") + }; + + let registry_parts: Vec<&str> = image_part.split('/').collect(); + + let (registry, namespace, name) = match registry_parts.len() { + 0 => return Err(anyhow!("Invalid image format: {}", image)), + 1 => ("docker.io".to_string(), None, registry_parts[0].to_string()), + 2 => { + if registry_parts[0].contains('.') || registry_parts[0].contains(':') { + ( + registry_parts[0].to_string(), + None, + registry_parts[1].to_string(), + ) + } else { + ( + "docker.io".to_string(), + Some(registry_parts[0].to_string()), + registry_parts[1].to_string(), + ) + } + } + _ => { + // 3 or more parts: first is registry, last is name, everything in between is namespace + let registry = registry_parts[0].to_string(); + let name = registry_parts[registry_parts.len() - 1].to_string(); + let namespace = Some(registry_parts[1..registry_parts.len() - 1].join("/")); + (registry, namespace, name) + } + }; + + Ok(ImageRef { + registry, + namespace, + name, + tag: tag.to_string(), + }) + } +} + +impl std::fmt::Display for ImageRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let base = match &self.namespace { + Some(ns) => format!("{}/{}/{}", self.registry, ns, self.name), + None => format!("{}/{}", self.registry, self.name), + }; + + if self.registry == "docker.io" { + match &self.namespace { + Some(ns) => write!(f, "{}/{}:{}", ns, self.name, self.tag), + None => write!(f, "{}:{}", self.name, self.tag), + } + } else { + write!(f, "{}:{}", base, self.tag) + } + } +} + +/// Docker Hub API response for tag list +#[derive(Deserialize)] +struct DockerHubTagsResponse { + results: Vec, + next: Option, +} + +#[derive(Deserialize)] +struct DockerHubTag { + name: String, +} + +/// Unified registry client with hybrid approach +/// Uses Docker Hub's public API for Docker Hub, standard v2 API for others +pub struct Client { + http_client: HttpClient, + config: Config, +} + +impl Client { + pub fn new(config: Config) -> Self { + Self { + http_client: HttpClient::new(), + config, + } + } + + /// Parse Retry-After header to determine wait duration + /// Supports both delay-seconds (integer) and HTTP-date formats + fn parse_retry_after(&self, retry_after: &str) -> Option { + // Try parsing as seconds first + if let Ok(seconds) = retry_after.parse::() { + return Some(Duration::from_secs(seconds)); + } + + // Try parsing as HTTP date (RFC 2822 or RFC 3339) + if let Ok(date) = DateTime::parse_from_rfc2822(retry_after) { + let now = Utc::now(); + let wait_time = date.signed_duration_since(now); + if wait_time.num_seconds() > 0 { + return Some(Duration::from_secs(wait_time.num_seconds() as u64)); + } + } + + None + } + + /// Perform HTTP request with retry logic for rate limiting (429) + async fn request_with_retry( + &self, + request_fn: F, + operation_name: &str, + ) -> Result + where + F: Fn() -> Fut, + Fut: std::future::Future>, + { + let mut attempt = 0; + let mut delay = Duration::from_secs(INITIAL_RETRY_DELAY_SECS); + + loop { + let response = request_fn().await?; + + if response.status() == StatusCode::TOO_MANY_REQUESTS { + attempt += 1; + + if attempt > MAX_RETRY_ATTEMPTS { + return Err(anyhow!( + "{} failed: Rate limited (429) after {} attempts", + operation_name, + MAX_RETRY_ATTEMPTS + )); + } + + // Check for Retry-After header + let wait_duration = if let Some(retry_after) = response + .headers() + .get("retry-after") + .and_then(|h| h.to_str().ok()) + { + self.parse_retry_after(retry_after).unwrap_or(delay) + } else { + delay + }; + + warn!( + "{} rate limited (429), attempt {}/{}, waiting {:?} before retry", + operation_name, attempt, MAX_RETRY_ATTEMPTS, wait_duration + ); + + sleep(wait_duration).await; + + // Exponential backoff for next attempt if no Retry-After header + delay = delay.saturating_mul(2); + } else { + return Ok(response); + } + } + } + + fn get_registry_config(&self, registry: &str) -> Result<&RegistryConfig> { + self.config + .registries + .get(registry) + .ok_or_else(|| anyhow!("Unknown registry: {}", registry)) + } + + fn build_repository_path(&self, image_ref: &ImageRef) -> String { + match &image_ref.namespace { + Some(namespace) => format!("{}/{}", namespace, image_ref.name), + None => { + if image_ref.registry == "docker.io" { + // For Docker Hub official images, use 'library' namespace in public API + format!("library/{}", image_ref.name) + } else { + // For other registries, no namespace means just the image name + image_ref.name.clone() + } + } + } + } + + fn parse_dockerhub_response( + &self, + response_text: &str, + ) -> Result<(Vec, Option)> { + let dockerhub_response: DockerHubTagsResponse = serde_json::from_str(response_text) + .map_err(|e| anyhow!("Failed to parse Docker Hub response: {}", e))?; + + let mut versions = Vec::new(); + + for tag in dockerhub_response.results { + if let Some(version_info) = VersionInfo::from_tag(&tag.name) { + versions.push(version_info); + } + } + + Ok((versions, dockerhub_response.next)) + } + + fn parse_v2_response(&self, response_text: &str) -> Result> { + debug!("Parsing registry v2 response: {}", response_text); + let tags_response: serde_json::Value = serde_json::from_str(response_text) + .map_err(|e| anyhow!("Failed to parse registry response: {}", e))?; + + let mut versions = Vec::new(); + + if let Some(tags) = tags_response.get("tags").and_then(|t| t.as_array()) { + for tag in tags { + if let Some(tag_str) = tag.as_str() { + if let Some(version_info) = VersionInfo::from_tag(tag_str) { + versions.push(version_info); + } + } + } + } + + Ok(versions) + } + + pub async fn get_available_versions(&self, image_ref: &ImageRef) -> Result> { + if image_ref.registry == "docker.io" { + self.get_dockerhub_versions(image_ref).await + } else { + self.get_registry_v2_versions(image_ref).await + } + } + + async fn get_dockerhub_versions(&self, image_ref: &ImageRef) -> Result> { + let repo_path = self.build_repository_path(image_ref); + let mut results = Vec::new(); + // Use Docker Hub's public REST API which doesn't require authentication for public repos + let mut url = format!( + "https://hub.docker.com/v2/repositories/{repo_path}/tags/?page_size={PAGE_SIZE}" + ); + + loop { + debug!("Docker Hub API URL: {}", url); + + let url_clone = url.clone(); + let response = self + .request_with_retry( + || async { self.http_client.get(&url_clone).send().await }, + "Docker Hub API request", + ) + .await?; + debug!("Docker Hub response status: {}", response.status()); + + if !response.status().is_success() { + return Err(anyhow!( + "Docker Hub request failed with status {}: {}", + response.status(), + response.text().await.unwrap_or_default() + )); + } + + let response_text = response.text().await?; + + let (new_tags, next_page) = self.parse_dockerhub_response(&response_text)?; + + results.extend(new_tags); + + if let Some(next) = next_page { + url = next; + } else { + break; + } + } + + Ok(results) + } + + async fn get_registry_v2_versions(&self, image_ref: &ImageRef) -> Result> { + let registry_config = self.get_registry_config(&image_ref.registry)?; + let repo_path = self.build_repository_path(image_ref); + + let mut results = Vec::new(); + let mut last_tag: Option = None; + let mut next_url: Option = None; + let mut bearer_token: Option = None; + + // Reference: https://github.com/opencontainers/distribution-spec/blob/main/spec.md#endpoints + loop { + // Use next URL from Link header if available, otherwise build URL with last parameter + let url = if let Some(next) = next_url.take() { + format!("{}{next}", registry_config.url) + } else { + format!( + "{}/v2/{repo_path}/tags/list?n={PAGE_SIZE}{}", + registry_config.url, + if let Some(ref last) = last_tag { + format!("&last={last}") + } else { + String::new() + } + ) + }; + + debug!("Registry API URL: {}", url); + + // Try unauthenticated request first to potentially get auth challenge + let url_clone = url.clone(); + let bearer_token_clone = bearer_token.clone(); + let response = self + .request_with_retry( + || async { + let mut request_builder = self.http_client.get(&url_clone); + if let Some(token) = &bearer_token_clone { + request_builder = request_builder.bearer_auth(token); + } + request_builder.send().await + }, + "Registry v2 API request", + ) + .await?; + + let (new_tags, link_next) = if response.status() == reqwest::StatusCode::UNAUTHORIZED { + if let Some(token) = ®istry_config.auth_token { + // Try Docker Registry v2 auth flow if we get auth challenge + if let Some(auth_header) = response.headers().get("www-authenticate") { + bearer_token = self + .try_registry_v2_auth(auth_header.to_str().unwrap(), token) + .await?; + continue; // Retry with new token + } else { + return Err(anyhow!( + "Unauthorized request but no WWW-Authenticate header found" + )); + } + } else { + return Err(anyhow!("Unauthorized request but no auth token configured")); + } + } else if response.status().is_success() { + // Check for Link header for pagination + let link_next = response + .headers() + .get("link") + .and_then(|h| h.to_str().ok()) + .and_then(|h| self.parse_link_header(h)); + + let response_text = response.text().await?; + let tags = self.parse_v2_response(&response_text)?; + (tags, link_next) + } else { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + return Err(anyhow!( + "Registry request failed with status {}: {}", + status, + error_text + )); + }; + + let maybe_last_tag = new_tags.last().map(|v| v.original.clone()); + results.extend(new_tags); + + // Use Link header next URL if available, otherwise fall back to last parameter pagination + if let Some(next) = link_next { + next_url = Some(next); + } else if let Some(last) = maybe_last_tag { + last_tag = Some(last); + } else { + break; + } + } + + Ok(results) + } + + async fn try_registry_v2_auth(&self, auth_str: &str, token: &str) -> Result> { + // Parse WWW-Authenticate header to extract auth parameters + let realm = self.extract_auth_param(auth_str, "realm")?; + let service = self.extract_auth_param(auth_str, "service")?; + let scope = self.extract_auth_param(auth_str, "scope")?; + + // Request token from auth endpoint + let auth_url = format!("{realm}?service={service}&scope={scope}"); + debug!("Getting registry token from: {}", auth_url); + + let auth_url_clone = auth_url.clone(); + let token_clone = token.to_string(); + let token_response = self + .request_with_retry( + || async { + self.http_client + .get(&auth_url_clone) + .basic_auth("token", Some(&token_clone)) + .send() + .await + }, + "Registry auth token request", + ) + .await?; + + if !token_response.status().is_success() { + return Err(anyhow!( + "Failed to get token: {} - {}", + token_response.status(), + token_response.text().await.unwrap_or_default() + )); + } + + let token_json: serde_json::Value = token_response.json().await?; + + if let Some(registry_token) = token_json.get("token").and_then(|t| t.as_str()) { + return Ok(Some(registry_token.to_string())); + } + + Err(anyhow!("Auth flow didn't work, caller can try fallback")) + } + + fn extract_auth_param(&self, auth_str: &str, param: &str) -> Result { + let pattern = format!(r#"{param}="([^"]+)""#); + let re = regex::Regex::new(&pattern)?; + + re.captures(auth_str) + .and_then(|caps| caps.get(1)) + .map(|m| m.as_str().to_string()) + .ok_or_else(|| anyhow!("Missing {} in auth challenge", param)) + } + + fn parse_link_header(&self, link_header: &str) -> Option { + // Parse RFC 5988 Link header to find "next" relation + // Format: ; rel="next", ; rel="last" + for link in link_header.split(',') { + let parts: Vec<&str> = link.trim().split(';').collect(); + if parts.len() >= 2 { + let url = parts[0].trim(); + if url.starts_with('<') && url.ends_with('>') { + let url = &url[1..url.len() - 1]; // Remove < and > + + for param in &parts[1..] { + let param = param.trim(); + if param == "rel=\"next\"" || param == "rel=next" { + return Some(url.to_string()); + } + } + } + } + } + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_image_ref_parsing() { + let test_cases = vec![ + ("nginx:1.21.0", "docker.io", None, "nginx", "1.21.0"), + ( + "ubuntu/nginx:1.21.0", + "docker.io", + Some("ubuntu"), + "nginx", + "1.21.0", + ), + ( + "ghcr.io/user/app:v1.0.0", + "ghcr.io", + Some("user"), + "app", + "v1.0.0", + ), + ( + "localhost:5000/myapp:latest", + "localhost:5000", + None, + "myapp", + "latest", + ), + ( + "ghcr.io/schmelczer/fizika/fizika-admin:latest", + "ghcr.io", + Some("schmelczer/fizika"), + "fizika-admin", + "latest", + ), + ( + "registry.example.com/org/team/project/image:v2.0.0", + "registry.example.com", + Some("org/team/project"), + "image", + "v2.0.0", + ), + ]; + + for (input, expected_registry, expected_namespace, expected_name, expected_tag) in + test_cases + { + let image_ref = ImageRef::parse(input).unwrap(); + assert_eq!( + image_ref.registry, expected_registry, + "Registry mismatch for {}", + input + ); + assert_eq!( + image_ref.namespace, + expected_namespace.map(String::from), + "Namespace mismatch for {}", + input + ); + assert_eq!(image_ref.name, expected_name, "Name mismatch for {}", input); + assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for {}", input); + } + } + + #[test] + fn test_repository_path_building() { + let config = Config::default(); + let client = Client::new(config); + + // Official Docker Hub image (no namespace) + let image_ref = ImageRef { + registry: "docker.io".to_string(), + namespace: None, + name: "nginx".to_string(), + tag: "latest".to_string(), + }; + assert_eq!(client.build_repository_path(&image_ref), "library/nginx"); + + // Docker Hub with namespace + let image_ref = ImageRef { + registry: "docker.io".to_string(), + namespace: Some("bitnami".to_string()), + name: "nginx".to_string(), + tag: "latest".to_string(), + }; + assert_eq!(client.build_repository_path(&image_ref), "bitnami/nginx"); + + // Custom registry + let image_ref = ImageRef { + registry: "ghcr.io".to_string(), + namespace: Some("user".to_string()), + name: "app".to_string(), + tag: "v1.0.0".to_string(), + }; + assert_eq!(client.build_repository_path(&image_ref), "user/app"); + } + + #[test] + fn test_parse_link_header() { + let config = Config::default(); + let client = Client::new(config); + + // Test standard Link header with next rel + let link_header = r#"; rel="next", ; rel="last""#; + let next_url = client.parse_link_header(link_header); + assert_eq!( + next_url, + Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string()) + ); + + // Test Link header without quotes around rel value + let link_header = + r#"; rel=next"#; + let next_url = client.parse_link_header(link_header); + assert_eq!( + next_url, + Some("https://registry.example.com/v2/repo/tags/list?n=100&last=tag99".to_string()) + ); + + // Test Link header with no next relation + let link_header = r#"; rel="prev", ; rel="last""#; + let next_url = client.parse_link_header(link_header); + assert_eq!(next_url, None); + + // Test empty Link header + let next_url = client.parse_link_header(""); + assert_eq!(next_url, None); + + // Test malformed Link header + let link_header = "not-a-valid-link-header"; + let next_url = client.parse_link_header(link_header); + assert_eq!(next_url, None); + } +} diff --git a/src/scheduler.rs b/src/scheduler.rs new file mode 100644 index 0000000..8da6e27 --- /dev/null +++ b/src/scheduler.rs @@ -0,0 +1,164 @@ +use crate::health::HealthHandle; +use crate::{compose::updater::ComposeUpdater, config::Config}; +use anyhow::{anyhow, Context, Result}; +use cron::Schedule; +use std::str::FromStr; +use std::time::Duration; +use tokio::time::{sleep, Instant}; +use tracing::error; +use tracing::info; + +pub struct Scheduler { + config: Config, + updater: ComposeUpdater, + schedule: Schedule, + health_handle: Option, +} + +impl Scheduler { + pub fn new(config: Config, health_handle: Option) -> Result { + let schedule = Schedule::from_str(&config.schedule) + .map_err(|e| anyhow!("Invalid cron expression '{}': {}", config.schedule, e))?; + + let updater = ComposeUpdater::new(config.clone()); + + Ok(Self { + config, + updater, + schedule, + health_handle, + }) + } + + pub async fn start(&self) -> Result<()> { + info!( + "Starting scheduler with cron expression: {}", + self.config.schedule + ); + + loop { + if let Err(err) = self.run_update().await.context("Failed to run update") { + error!("{:?}", err) + } + + if let Some(next_run) = self.schedule.upcoming(chrono::Utc).take(1).next() { + let now = chrono::Utc::now(); + let duration_until_next = next_run.signed_duration_since(now); + + if duration_until_next.num_seconds() > 0 { + info!( + "Next update scheduled for: {}", + next_run.format("%Y-%m-%d %H:%M:%S UTC") + ); + + let sleep_duration = + Duration::from_secs(duration_until_next.num_seconds() as u64); + sleep(sleep_duration).await; + } + } + } + } + + pub async fn run_once(&self) -> Result<()> { + info!("Running one-time update"); + self.run_update().await + } + + async fn run_update(&self) -> Result<()> { + let start_time = Instant::now(); + info!("Starting Docker Compose update cycle"); + + match self.updater.update_all_compose_files().await { + Ok(updated_files) => { + let duration = start_time.elapsed(); + if updated_files.is_empty() { + info!( + "Update cycle completed in {:?} - no files updated", + duration + ); + } else { + info!( + "Update cycle completed in {:?} - {} {} files:", + duration, + if self.config.dry_run { + "would update" + } else { + "updated" + }, + updated_files.len() + ); + for file in &updated_files { + info!(" - {}", file); + } + } + + // Report success to health monitor + if let Some(ref health) = self.health_handle { + health.report_update_success(); + } + + Ok(()) + } + Err(e) => { + let error = e.context("Failed to update Docker Compose files"); + + // Report failure to health monitor + if let Some(ref health) = self.health_handle { + health.report_update_failure(); + } + + Err(error) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, UpdateStrategy}; + use std::collections::HashMap; + use std::path::PathBuf; + + #[test] + fn test_scheduler_creation() { + let config = Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: "0 0 2 * * *".to_string(), + registries: HashMap::new(), + update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor, + ignore_images: vec![], + dry_run: true, + }; + + let scheduler = Scheduler::new(config, None).unwrap(); + assert!(scheduler + .schedule + .upcoming(chrono::Utc) + .take(1) + .next() + .is_some()); + } + + #[test] + fn test_cron_parsing() { + let config = Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: "0 30 1 * * *".to_string(), // 1:30 AM daily + registries: HashMap::new(), + update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor, + ignore_images: vec![], + dry_run: true, + }; + + let scheduler = Scheduler::new(config, None).unwrap(); + + // Just verify the scheduler can be created + assert!(scheduler + .schedule + .upcoming(chrono::Utc) + .take(1) + .next() + .is_some()); + } +} diff --git a/src/strategy.rs b/src/strategy.rs new file mode 100644 index 0000000..878c812 --- /dev/null +++ b/src/strategy.rs @@ -0,0 +1,238 @@ +use crate::config::UpdateStrategy; +use crate::version::VersionInfo; +use tracing::{info, warn}; + +pub fn create_selector(strategy: &UpdateStrategy) -> Box { + match strategy { + UpdateStrategy::Latest => Box::new(LatestVersionSelector), + UpdateStrategy::LatestPatchOfPreviousMinor => Box::new(SmartPreviousMinorSelector), + } +} + +pub trait VersionSelector { + fn select_target_version( + &self, + available: &[VersionInfo], + current_prefix: Option, + current_suffix: Option, + ) -> Option; +} + +pub struct LatestVersionSelector; + +impl VersionSelector for LatestVersionSelector { + fn select_target_version( + &self, + available: &[VersionInfo], + current_prefix: Option, + current_suffix: Option, + ) -> Option { + info!("Using update strategy: LatestVersionSelector"); + + let versions = + get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix); + + let latest = versions.first().cloned(); + + if let Some(ref selected) = latest { + info!("Selected {} as latest version", selected.version); + } else { + warn!("No versions available for selection"); + } + + latest + } +} + +pub struct SmartPreviousMinorSelector; + +impl VersionSelector for SmartPreviousMinorSelector { + fn select_target_version( + &self, + available: &[VersionInfo], + current_prefix: Option, + current_suffix: Option, + ) -> Option { + info!("Using update strategy: SmartPreviousMinorSelector"); + + let versions = + get_filtered_and_soreted_matching_versions(available, current_prefix, current_suffix); + + let latest = &versions.first()?.version; + info!("Latest version available: {}", latest); + + let (target_major, max_minor) = if latest.minor == 0 { + // If current minor is 0, look for the latest minor of the previous major + if latest.major == 0 { + info!("Cannot go to previous version of 0.0.x, skipping update"); + return None; + } + + info!("The latest version has a minor version of 0, looking for the latest minor of the previous major"); + (latest.major - 1, None) + } else { + info!("The latest version has a minor version of {}, looking for the latest patch of the previous minor", latest.minor); + (latest.major, Some(latest.minor - 1)) + }; + + let selected = versions + .iter() + .find(|v| { + v.version.major == target_major && max_minor.is_none_or(|m| v.version.minor <= m) + }) + .cloned(); + + if let Some(ref selected) = selected { + info!("Selected {} as latest version", selected.version); + } else { + warn!("No versions available for selection"); + } + + selected + } +} + +fn get_filtered_and_soreted_matching_versions( + available: &[VersionInfo], + current_prefix: Option, + current_suffix: Option, +) -> Vec { + let mut sorted_versions = available.to_vec(); + sorted_versions.sort_by(|a, b| b.version.cmp(&a.version)); + + info!( + "Available versions for selection: {}", + sorted_versions + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(", ") + ); + let filtered_versions: Vec = sorted_versions + .into_iter() + .filter(|v| v.prefix == current_prefix && v.suffix == current_suffix) + .collect(); + + info!( + "Filtered versions with matching prefix and suffix: {}", + filtered_versions + .iter() + .map(|v| v.to_string()) + .collect::>() + .join(", ") + ); + + filtered_versions +} + +#[cfg(test)] +mod tests { + use semver::Version; + + use super::*; + + #[test] + fn test_version_strategy_latest_patch_of_previous_minor() { + let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); + + let available = vec![ + VersionInfo::from_tag("2.0.0").unwrap(), + VersionInfo::from_tag("1.3.0").unwrap(), + VersionInfo::from_tag("1.2.5").unwrap(), + VersionInfo::from_tag("1.1.5").unwrap(), + VersionInfo::from_tag("1.1.4").unwrap(), + ]; + + let target = selector.select_target_version(&available, None, None); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("1.3.0").unwrap()) + ); + } + + #[test] + fn test_version_strategy_latest_patch() { + let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); + + let available = vec![ + VersionInfo::from_tag("1.3.0").unwrap(), + VersionInfo::from_tag("1.2.5").unwrap(), + VersionInfo::from_tag("1.1.5").unwrap(), + ]; + + let target = selector.select_target_version(&available, None, None); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("1.2.5").unwrap()) + ); + + let available_far_ahead = vec![ + VersionInfo::from_tag("2.0.0").unwrap(), + VersionInfo::from_tag("1.4.0").unwrap(), + VersionInfo::from_tag("1.2.5").unwrap(), + VersionInfo::from_tag("1.1.5").unwrap(), + ]; + + let target = selector.select_target_version(&available_far_ahead, None, None); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("1.4.0").unwrap()) + ); + } + + #[test] + fn test_version_strategy_latest() { + let selector = create_selector(&UpdateStrategy::Latest); + + let available = vec![ + VersionInfo::from_tag("2.0.0").unwrap(), + VersionInfo::from_tag("1.2.5").unwrap(), + VersionInfo::from_tag("1.1.5").unwrap(), + ]; + + let target = selector.select_target_version(&available, None, None); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("2.0.0").unwrap()) + ); + } + + #[test] + fn test_prefix_and_suffix_matching_in_strategy() { + let selector = create_selector(&UpdateStrategy::Latest); + + let current_prefix = Some("v".to_string()); + let current_suffix = Some("-alpine".to_string()); + + let available = vec![ + VersionInfo::from_tag("1.3.0-alpine").unwrap(), + VersionInfo::from_tag("v1.4.0").unwrap(), + VersionInfo::from_tag("v1.5.0-alpine").unwrap(), + VersionInfo::from_tag("2.0.0").unwrap(), + ]; + + let target = selector.select_target_version(&available, current_prefix, current_suffix); + assert_eq!( + target.map(|v| v.version), + Some(Version::parse("1.5.0").unwrap()) + ); + } + + #[test] + fn test_cross_major_version_handling() { + let selector = create_selector(&UpdateStrategy::LatestPatchOfPreviousMinor); + + let current_suffix = Some("-fat".to_string()); + + let available = vec![ + VersionInfo::from_tag("1.0.2-fat").unwrap(), + VersionInfo::from_tag("0.46.2-fat").unwrap(), + VersionInfo::from_tag("0.46.1-fat").unwrap(), + VersionInfo::from_tag("0.45.6-fat").unwrap(), + ]; + + let target = selector.select_target_version(&available, None, current_suffix); + assert!(target.is_some()); + assert_eq!(target.unwrap().version, Version::parse("0.46.2").unwrap()); + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..b00fa9d --- /dev/null +++ b/src/version.rs @@ -0,0 +1,187 @@ +use regex::Regex; +use semver::Version; +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub struct VersionInfo { + pub version: Version, + pub prefix: Option, + pub suffix: Option, + pub original: String, +} + +impl VersionInfo { + /// Parses a container image tag into a VersionInfo struct + /// + /// # Examples + /// + /// ``` + /// use docker_compose_updater::version::VersionInfo; + /// + /// let version_info = VersionInfo::from_tag("v1.29.0-alpine-slim").unwrap(); + /// assert_eq!(version_info.version.to_string(), "1.29.0"); + /// assert_eq!(version_info.prefix, Some("v".to_string())); + /// assert_eq!(version_info.suffix, Some("-alpine-slim".to_string())); + /// ``` + pub fn from_tag(tag: &str) -> Option { + let re = Regex::new(r"^(?P.*?)(?P(?:\d+\.)+\d+)(?P.*?)$").unwrap(); + + if let Some(captures) = re.captures(tag) { + let prefix_part = captures.name("prefix").map_or("", |m| m.as_str()); + let version_part = captures.name("version").map_or("", |m| m.as_str()); + let suffix_part = captures.name("suffix").map_or("", |m| m.as_str()); + + let version_part = version_part + .split('.') + .chain(std::iter::repeat("0")) + .take(3) + .collect::>() + .join("."); + + if let Ok(version) = Version::parse(&version_part) { + let prefix = if prefix_part.is_empty() { + None + } else { + Some(prefix_part.to_string()) + }; + + let suffix = if suffix_part.is_empty() { + None + } else { + Some(suffix_part.to_string()) + }; + + return Some(Self { + version, + prefix, + suffix, + original: tag.to_string(), + }); + } + } + + None + } +} + +impl PartialEq for VersionInfo { + fn eq(&self, other: &Version) -> bool { + self.version == *other + } +} + +impl fmt::Display for VersionInfo { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.original) + } +} + +/// Extracts semantic version, prefix and suffix from a container image tag +/// +/// # Examples +/// +/// ``` +/// use docker_compose_updater::version::parse_version_tag; +/// +/// let (version, prefix, suffix, original) = parse_version_tag("v1.29.0-alpine-slim"); +/// assert_eq!(version.unwrap().to_string(), "1.29.0"); +/// assert_eq!(prefix, Some("v".to_string())); +/// assert_eq!(suffix, Some("-alpine-slim".to_string())); +/// assert_eq!(original, "v1.29.0-alpine-slim".to_string()); +/// ``` +pub fn parse_version_tag(tag: &str) -> (Option, Option, Option, String) { + if let Some(version_info) = VersionInfo::from_tag(tag) { + ( + Some(version_info.version), + version_info.prefix, + version_info.suffix, + version_info.original, + ) + } else { + (None, None, None, tag.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_version_tag() { + let test_cases = vec![ + ("1.29.0", "1.29.0", None, None), + ("v1.29.0", "1.29.0", Some("v"), None), + ("v1.29.0.10.2", "1.29.0", Some("v"), None), + ("v1.29", "1.29.0", Some("v"), None), + ("release-1.2.3", "1.2.3", Some("release-"), None), + ("app_v1.5.0", "1.5.0", Some("app_v"), None), + ("1.29.0-alpine", "1.29.0", None, Some("-alpine")), + ("15.3-alpine3.18", "15.3.0", None, Some("-alpine3.18")), + ( + "v1.29.0-alpine-slim", + "1.29.0", + Some("v"), + Some("-alpine-slim"), + ), + ( + "release-1.2.3-ubuntu", + "1.2.3", + Some("release-"), + Some("-ubuntu"), + ), + ("v4.1.1", "4.1.1", Some("v"), None), + ( + "v2.1.3-bookworm-perl", + "2.1.3", + Some("v"), + Some("-bookworm-perl"), + ), + ( + "build123-2.0.1-final", + "2.0.1", + Some("build123-"), + Some("-final"), + ), + ]; + + for (input, expected_version, expected_prefix, expected_suffix) in test_cases { + let (version_opt, prefix, suffix, _) = parse_version_tag(input); + + match version_opt { + Some(version) => { + assert_eq!( + version.to_string(), + expected_version, + "Version mismatch for {input}" + ); + assert_eq!( + prefix, + expected_prefix.map(String::from), + "Prefix mismatch for {input}" + ); + assert_eq!( + suffix, + expected_suffix.map(String::from), + "Suffix mismatch for {input}" + ); + } + None => { + panic!("Expected version for {input}"); + } + } + } + } + + #[test] + fn test_non_semver_tags() { + let non_semver_tags = vec!["latest", "stable", "main", "alpine"]; + + for tag in non_semver_tags { + let (version_opt, _prefix, _suffix, _) = parse_version_tag(tag); + assert!( + version_opt.is_none(), + "Expected no version for non-semver tag: {tag}" + ); + } + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..1324680 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,259 @@ +use docker_compose_updater::compose::updater::ComposeUpdater; +use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy}; +use docker_compose_updater::registry::{Client, ImageRef}; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tempfile::NamedTempFile; + +#[tokio::test] +async fn test_end_to_end_compose_file_parsing_and_updating() { + let compose_content = r#" +version: '3.8' +services: + web: + image: nginx:1.21.0 # Web server + ports: + - "80:80" + + db: + image: postgres:13.7 + environment: + POSTGRES_DB: myapp + + redis: + image: redis:6.2.1-alpine + ports: + - "6379:6379" +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(compose_content.as_bytes()).unwrap(); + + let config = create_test_config(); + let updater = ComposeUpdater::new(config); + + let result = updater + .parse_compose_file(temp_file.path().to_str().unwrap()) + .unwrap(); + + assert_eq!(result.services.len(), 3); + + assert_eq!(result.services[0].service_name, "web"); + assert_eq!(result.services[0].image_ref.name, "nginx"); + assert_eq!(result.services[0].image_ref.tag, "1.21.0"); + assert_eq!(result.services[0].image_ref.registry, "docker.io"); + + assert_eq!(result.services[1].service_name, "db"); + assert_eq!(result.services[1].image_ref.name, "postgres"); + assert_eq!(result.services[1].image_ref.tag, "13.7"); + + assert_eq!(result.services[2].service_name, "redis"); + assert_eq!(result.services[2].image_ref.name, "redis"); + assert_eq!(result.services[2].image_ref.tag, "6.2.1-alpine"); +} + +#[tokio::test] +async fn test_comment_and_formatting_preservation_during_updates() { + let compose_content = r#" +version: '3.8' +services: + web: + image: nginx:1.21.0 # This is a comment + ports: + - "80:80" + + # This is another comment + db: + image: "postgres:13.7" # Database comment with quotes + environment: + POSTGRES_DB: myapp +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(compose_content.as_bytes()).unwrap(); + + let config = create_test_config(); + let updater = ComposeUpdater::new(config); + + let result = updater + .parse_compose_file(temp_file.path().to_str().unwrap()) + .unwrap(); + + assert!(result.services[0] + .original_line + .contains("# This is a comment")); + assert!(result.services[1] + .original_line + .contains("# Database comment")); + + let new_content = updater + .replace_image_in_content(&result.content, &result.services[0], "nginx:1.20.0") + .unwrap(); + + assert!(new_content.contains("# This is a comment")); + assert!(new_content.contains("nginx:1.20.0")); + assert!(!new_content.contains("nginx:1.21.0")); + + let new_content = updater + .replace_image_in_content(&new_content, &result.services[1], "postgres:14.0") + .unwrap(); + + assert!(new_content.contains("# Database comment")); + assert!(new_content.contains("\"postgres:14.0\"")); + assert!(!new_content.contains("postgres:13.7")); +} + +#[tokio::test] +async fn test_empty_compose_paths_handling() { + let config = Config { + compose_paths: vec![], + ..create_test_config() + }; + let updater = ComposeUpdater::new(config); + + let result = updater.update_all_compose_files().await; + assert!(result.is_ok()); + assert!(result.unwrap().is_empty()); +} + +#[test] +fn test_config_defaults_and_image_filtering() { + let config = Config::default(); + + assert_eq!(config.schedule, "0 0 2 * * *"); + assert_eq!( + config.update_strategy, + UpdateStrategy::LatestPatchOfPreviousMinor + ); + assert!(!config.dry_run); + assert!(config.registries.contains_key("docker.io")); + assert!(config.registries.contains_key("ghcr.io")); + + let config_with_ignores = Config { + ignore_images: vec!["localhost".to_string(), "127.0.0.1".to_string()], + ..Config::default() + }; + + assert!(config_with_ignores.is_image_ignored("localhost:5000/myapp:latest")); + assert!(config_with_ignores.is_image_ignored("127.0.0.1:5000/myapp:latest")); + assert!(!config_with_ignores.is_image_ignored("nginx:1.21.0")); +} + +fn create_test_config() -> Config { + let mut registries = HashMap::new(); + registries.insert( + "docker.io".to_string(), + RegistryConfig { + url: "https://registry-1.docker.io".to_string(), + auth_token: None, + }, + ); + + Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: "0 0 2 * * *".to_string(), + registries, + update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor, + ignore_images: vec!["localhost".to_string()], + dry_run: true, + } +} + +#[test] +fn test_env_var_substitution() { + // Test environment variable substitution in config + std::env::set_var("TEST_TOKEN", "test_value_123"); + + let test_yaml = r#" +registries: + "test.registry": + url: "https://test.registry" + auth_token: "${TEST_TOKEN}" +"#; + + let expanded = Config::expand_env_vars(test_yaml); + assert!(expanded.contains("test_value_123")); + assert!(!expanded.contains("${TEST_TOKEN}")); + + // Clean up + std::env::remove_var("TEST_TOKEN"); +} + +#[test] +fn test_config_loading_with_env_vars() { + // Set up test environment variable + std::env::set_var("GITHUB_TOKEN", "ghp_test_token_for_testing"); + + // Test that config loading works with our test config + if std::path::Path::new("config.test.yaml").exists() { + let config = Config::load(Path::new("config.test.yaml").to_path_buf()).unwrap(); + + if let Some(ghcr_config) = config.registries.get("ghcr.io") { + // Should have resolved the environment variable + assert_eq!( + ghcr_config.auth_token, + Some("ghp_test_token_for_testing".to_string()) + ); + } + } + + // Clean up + std::env::remove_var("GITHUB_TOKEN"); +} + +#[tokio::test] +#[ignore = "Only run in CI with GITHUB_TOKEN set"] +async fn test_ghcr_authentication_e2e() { + let github_token = std::env::var("GITHUB_TOKEN").unwrap(); + + // This test validates that Docker Registry v2 authentication works with GHCR + let mut registries = HashMap::new(); + registries.insert( + "docker.io".to_string(), + RegistryConfig { + url: "https://registry-1.docker.io".to_string(), + auth_token: None, + }, + ); + registries.insert( + "ghcr.io".to_string(), + RegistryConfig { + url: "https://ghcr.io".to_string(), + // Use the token we validated above + auth_token: Some(github_token), + }, + ); + + let config = Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: "0 0 2 * * *".to_string(), + registries, + update_strategy: UpdateStrategy::LatestPatchOfPreviousMinor, + ignore_images: vec!["localhost".to_string()], + dry_run: true, + }; + + let client = Client::new(config); + + // Test with the actual image that was failing + let image_ref = ImageRef::parse("ghcr.io/schmelczer/vault-link:0.5.1").unwrap(); + + let result = client.get_available_versions(&image_ref).await; + + match result { + Ok(versions) => { + assert!(!versions.is_empty(), "Should return at least one version"); + + // Verify that we can find the specific version 0.5.1 + let has_target_version = versions.iter().any(|v| v.version.to_string() == "0.5.1"); + assert!( + has_target_version, + "Should find target version 0.5.1 in available versions" + ); + } + Err(e) => { + panic!("GHCR authentication test failed: {e}"); + } + } +} diff --git a/tests/test_compose_operations.rs b/tests/test_compose_operations.rs new file mode 100644 index 0000000..efc6fbe --- /dev/null +++ b/tests/test_compose_operations.rs @@ -0,0 +1,509 @@ +use docker_compose_updater::compose::updater::ComposeUpdater; +use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy}; +use docker_compose_updater::registry::ImageRef; +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use tempfile::NamedTempFile; + +#[test] +fn test_image_ref_comprehensive_parsing() { + // Test comprehensive image reference parsing scenarios + let test_cases = vec![ + // (input, expected_registry, expected_namespace, expected_name, expected_tag) + + // Standard Docker Hub images + ("nginx", "docker.io", None, "nginx", "latest"), + ("nginx:1.21", "docker.io", None, "nginx", "1.21"), + ("nginx:latest", "docker.io", None, "nginx", "latest"), + // Docker Hub with namespace + ( + "library/nginx:1.21", + "docker.io", + Some("library"), + "nginx", + "1.21", + ), + ( + "bitnami/nginx:1.21", + "docker.io", + Some("bitnami"), + "nginx", + "1.21", + ), + // Custom registries + ("quay.io/nginx:1.21", "quay.io", None, "nginx", "1.21"), + ( + "gcr.io/project/nginx:1.21", + "gcr.io", + Some("project"), + "nginx", + "1.21", + ), + ( + "registry.k8s.io/pause:3.9", + "registry.k8s.io", + None, + "pause", + "3.9", + ), + // GitHub Container Registry + ( + "ghcr.io/user/app:v1.0.0", + "ghcr.io", + Some("user"), + "app", + "v1.0.0", + ), + ( + "ghcr.io/org/project:latest", + "ghcr.io", + Some("org"), + "project", + "latest", + ), + // Local registries with ports + ( + "localhost:5000/app:dev", + "localhost:5000", + None, + "app", + "dev", + ), + ( + "127.0.0.1:8080/ns/app:test", + "127.0.0.1:8080", + Some("ns"), + "app", + "test", + ), + // Complex tags with versions and suffixes + ( + "nginx:1.21.6-alpine", + "docker.io", + None, + "nginx", + "1.21.6-alpine", + ), + ( + "postgres:13.7-bullseye", + "docker.io", + None, + "postgres", + "13.7-bullseye", + ), + ( + "redis:7.0.0-alpine3.16", + "docker.io", + None, + "redis", + "7.0.0-alpine3.16", + ), + // Enterprise/custom registries + ( + "my-registry.company.com/team/app:v2.1.3", + "my-registry.company.com", + Some("team"), + "app", + "v2.1.3", + ), + ( + "harbor.example.org/project/nginx:stable", + "harbor.example.org", + Some("project"), + "nginx", + "stable", + ), + ]; + + for (input, expected_registry, expected_namespace, expected_name, expected_tag) in test_cases { + let result = ImageRef::parse(input); + assert!(result.is_ok(), "Failed to parse: {input}"); + + let image_ref = result.unwrap(); + assert_eq!( + image_ref.registry, expected_registry, + "Registry mismatch for: {input}" + ); + assert_eq!( + image_ref.namespace, + expected_namespace.map(String::from), + "Namespace mismatch for: {input}" + ); + assert_eq!(image_ref.name, expected_name, "Name mismatch for: {input}"); + assert_eq!(image_ref.tag, expected_tag, "Tag mismatch for: {input}"); + + // Test that the image ref can be converted back to string representation + let display_string = image_ref.to_string(); + assert!( + !display_string.is_empty(), + "Display string should not be empty for: {input}" + ); + + // For Docker Hub images, the display should omit the registry + if expected_registry == "docker.io" { + assert!( + !display_string.starts_with("docker.io"), + "Docker Hub images should not show registry in display: {input}" + ); + } + } +} + +#[tokio::test] +async fn test_compose_file_parsing_edge_cases() { + let config = create_test_config(); + let updater = ComposeUpdater::new(config); + + // Test various compose file formats and edge cases + let test_cases = vec![ + // Standard compose file + ( + "standard_compose", + r#" +version: '3.8' +services: + web: + image: nginx:1.21.0 + ports: + - "80:80" + db: + image: postgres:13.7 +"#, + 2, // Expected number of services + ), + // Compose file with comments and whitespace + ( + "with_comments", + r#" +# This is a comment +version: '3.8' + +services: + # Web service comment + web: + image: nginx:1.21.0 # Inline comment + ports: + - "80:80" + + # Database service + db: + image: postgres:13.7 + # More comments + environment: + POSTGRES_DB: test +"#, + 2, + ), + // Compose file with complex image references + ( + "complex_images", + r#" +version: '3.8' +services: + frontend: + image: ghcr.io/company/frontend:v2.1.0 + backend: + image: my-registry.com:5000/team/backend:latest + cache: + image: redis:7.0.0-alpine3.16 + database: + image: postgres:14.5-bullseye +"#, + 4, + ), + // Minimal compose file + ( + "minimal", + r#" +version: '3' +services: + app: + image: hello-world +"#, + 1, + ), + // Compose file with extends and other features + ( + "with_extends", + r#" +version: '3.8' +services: + base: + image: ubuntu:20.04 + environment: + - ENV=production + + app: + extends: + service: base + image: myapp:latest + ports: + - "3000:3000" +"#, + 2, + ), + ]; + + for (test_name, compose_content, expected_services) in test_cases { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(compose_content.as_bytes()).unwrap(); + + let result = updater.parse_compose_file(temp_file.path().to_str().unwrap()); + assert!(result.is_ok(), "Failed to parse compose file: {test_name}"); + + let compose_file = result.unwrap(); + assert_eq!( + compose_file.services.len(), + expected_services, + "Service count mismatch for: {test_name}" + ); + + // Verify that all services have valid image references + for service in &compose_file.services { + assert!( + !service.service_name.is_empty(), + "Service name should not be empty in: {test_name}" + ); + assert!( + !service.image_ref.name.is_empty(), + "Image name should not be empty in: {test_name}" + ); + assert!( + !service.image_ref.tag.is_empty(), + "Image tag should not be empty in: {test_name}" + ); + assert!( + !service.image_ref.registry.is_empty(), + "Image registry should not be empty in: {test_name}" + ); + } + } +} + +#[tokio::test] +async fn test_compose_content_replacement() { + let config = create_test_config(); + let updater = ComposeUpdater::new(config); + + let original_content = r#" +version: '3.8' +services: + web: + image: nginx:1.20.0 # Current version + ports: + - "80:80" + db: + image: postgres:13.6 +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(original_content.as_bytes()).unwrap(); + + let compose_file = updater + .parse_compose_file(temp_file.path().to_str().unwrap()) + .unwrap(); + + // Test replacing nginx image + let nginx_service = compose_file + .services + .iter() + .find(|s| s.service_name == "web") + .unwrap(); + + let result = + updater.replace_image_in_content(&compose_file.content, nginx_service, "nginx:1.21.0"); + assert!(result.is_ok(), "Should successfully replace image"); + + let new_content = result.unwrap(); + assert!( + new_content.contains("nginx:1.21.0"), + "Should contain new image version" + ); + assert!( + !new_content.contains("nginx:1.20.0"), + "Should not contain old image version" + ); + assert!( + new_content.contains("# Current version"), + "Should preserve comments" + ); + assert!( + new_content.contains("postgres:13.6"), + "Should preserve other services unchanged" + ); +} + +#[test] +fn test_compose_service_image_replacement_edge_cases() { + let config = create_test_config(); + let updater = ComposeUpdater::new(config); + + // Test cases with tricky image replacement scenarios + let test_cases = vec![ + // Multiple occurrences of similar image names + ( + r#" +version: '3.8' +services: + nginx-proxy: + image: nginx:1.20.0 + nginx-cache: + image: nginx:1.21.0 + web: + image: nginx:1.20.0 +"#, + "nginx-proxy", + "nginx:1.20.0", + "nginx:1.22.0", + ), + // Images with similar names but different registries + ( + r#" +version: '3.8' +services: + public-nginx: + image: nginx:1.20.0 + private-nginx: + image: my-registry.com/nginx:1.20.0 +"#, + "public-nginx", + "nginx:1.20.0", + "nginx:1.21.0", + ), + // Complex image with registry and namespace + ( + r#" +version: '3.8' +services: + app: + image: ghcr.io/company/app:v1.0.0 + environment: + - IMAGE_VERSION=v1.0.0 +"#, + "app", + "ghcr.io/company/app:v1.0.0", + "ghcr.io/company/app:v1.1.0", + ), + ]; + + for (compose_content, service_name, old_image, new_image) in test_cases { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(compose_content.as_bytes()).unwrap(); + + let compose_file = updater + .parse_compose_file(temp_file.path().to_str().unwrap()) + .unwrap(); + + let target_service = compose_file + .services + .iter() + .find(|s| s.service_name == service_name) + .unwrap(); + + let result = + updater.replace_image_in_content(&compose_file.content, target_service, new_image); + assert!( + result.is_ok(), + "Should replace image for service: {service_name}" + ); + + let new_content = result.unwrap(); + assert!( + new_content.contains(new_image), + "Should contain new image: {new_image}" + ); + + // Count occurrences to ensure only the right image was replaced + let old_count = compose_content.matches(old_image).count(); + let new_old_count = new_content.matches(old_image).count(); + assert_eq!( + new_old_count, + old_count - 1, + "Should replace exactly one occurrence for: {service_name}" + ); + } +} + +#[test] +fn test_image_ignore_patterns_comprehensive() { + // Test comprehensive ignore pattern scenarios + let test_cases = vec![ + // Basic pattern matching + (vec!["localhost"], "localhost:5000/app:latest", true), + (vec!["localhost"], "nginx:latest", false), + // Multiple patterns + ( + vec!["localhost", "127.0.0.1"], + "127.0.0.1:5000/app:latest", + true, + ), + ( + vec!["localhost", "127.0.0.1"], + "192.168.1.1:5000/app:latest", + false, + ), + // Registry-based patterns + (vec!["ghcr.io"], "ghcr.io/user/app:latest", true), + (vec!["ghcr.io"], "docker.io/user/app:latest", false), + // Prefix patterns + (vec!["test-"], "test-app:latest", true), + (vec!["test-"], "app-test:latest", false), + (vec!["test-"], "testing:latest", false), + // Complex registry patterns + ( + vec!["internal.company.com"], + "internal.company.com/team/app:v1.0", + true, + ), + ( + vec!["internal.company.com"], + "external.company.com/team/app:v1.0", + false, + ), + // Partial matches + (vec!["redis"], "redis:latest", true), + (vec!["redis"], "valkey:latest", false), + (vec!["redis"], "my-redis:latest", true), // This should match as substring + ]; + + for (ignore_patterns, image, should_be_ignored) in test_cases { + let config = Config { + ignore_images: ignore_patterns.iter().map(|s| s.to_string()).collect(), + ..create_test_config() + }; + + let result = config.is_image_ignored(image); + assert_eq!( + result, should_be_ignored, + "Pattern {ignore_patterns:?} with image '{image}' should be ignored: {should_be_ignored}" + ); + } +} + +fn create_test_config() -> Config { + let mut registries = HashMap::new(); + registries.insert( + "docker.io".to_string(), + RegistryConfig { + url: "https://registry-1.docker.io".to_string(), + auth_token: None, + }, + ); + registries.insert( + "ghcr.io".to_string(), + RegistryConfig { + url: "https://ghcr.io".to_string(), + auth_token: None, + }, + ); + + Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: "0 0 2 * * *".to_string(), + registries, + update_strategy: UpdateStrategy::Latest, + ignore_images: vec![], + dry_run: true, + } +} diff --git a/tests/test_config_validation.rs b/tests/test_config_validation.rs new file mode 100644 index 0000000..501896e --- /dev/null +++ b/tests/test_config_validation.rs @@ -0,0 +1,346 @@ +use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy}; +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use tempfile::NamedTempFile; + +#[test] +fn test_config_default_values() { + let config = Config::default(); + + // Verify all default values are reasonable + assert_eq!(config.schedule, "0 0 2 * * *"); + assert_eq!( + config.update_strategy, + UpdateStrategy::LatestPatchOfPreviousMinor + ); + assert!(!config.dry_run); + assert!(config.ignore_images.is_empty()); + // compose_paths should be a valid Vec (length check is always true for usize) + let _paths_count = config.compose_paths.len(); + + // Should have default registries configured + assert!(config.registries.contains_key("docker.io")); + assert!(config.registries.contains_key("ghcr.io")); + + let docker_registry = &config.registries["docker.io"]; + assert_eq!(docker_registry.url, "https://registry-1.docker.io"); + assert!(docker_registry.auth_token.is_none()); + + let ghcr_registry = &config.registries["ghcr.io"]; + assert_eq!(ghcr_registry.url, "https://ghcr.io"); + assert!(ghcr_registry.auth_token.is_none()); +} + +#[test] +fn test_config_from_different_file_formats() { + // Test loading from a properly formatted YAML file + let valid_yaml = r#" +compose_paths: + - "./docker-compose.yml" + - "./services/" +schedule: "0 0 3 * * *" +update_strategy: "Latest" +dry_run: true +ignore_images: + - "localhost" + - "127.0.0.1" +registries: + docker.io: + url: "https://registry-1.docker.io" + custom.registry.com: + url: "https://custom.registry.com" + auth_token: "secret-token" +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(valid_yaml.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + let result = Config::load(temp_file.path().to_path_buf()); + assert!(result.is_ok(), "Should load valid YAML config"); + + let config = result.unwrap(); + assert_eq!(config.compose_paths.len(), 2); + assert_eq!(config.schedule, "0 0 3 * * *"); + assert_eq!(config.update_strategy, UpdateStrategy::Latest); + assert!(config.dry_run); + assert_eq!(config.ignore_images.len(), 2); + assert!(config.registries.contains_key("custom.registry.com")); + + let custom_registry = &config.registries["custom.registry.com"]; + assert_eq!(custom_registry.url, "https://custom.registry.com"); + assert_eq!(custom_registry.auth_token, Some("secret-token".to_string())); +} + +#[test] +fn test_config_with_invalid_yaml_syntax() { + let invalid_yamls = [ + // Invalid YAML syntax + r#" +compose_paths: + - "./docker-compose.yml" +invalid_indent: +schedule: "0 0 2 * * *" +"#, + // Invalid field types + r#" +compose_paths: "not an array" +schedule: 123 +dry_run: "not a boolean" +"#, + // Completely invalid YAML + r#" +{ invalid yaml syntax [ +"#, + ]; + + for (i, invalid_yaml) in invalid_yamls.iter().enumerate() { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(invalid_yaml.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + let result = Config::load(temp_file.path().to_path_buf()); + + // Should either fail gracefully or fall back to defaults + if result.is_ok() { + let config = result.unwrap(); + // If it succeeds, should have reasonable defaults + assert!( + !config.schedule.is_empty(), + "Schedule should not be empty for test case {i}" + ); + } + // If it fails, that's also acceptable behavior for invalid YAML + } +} + +#[test] +fn test_config_update_strategy_parsing() { + let strategies = vec![ + ("Latest", UpdateStrategy::Latest), + ( + "LatestPatchOfPreviousMinor", + UpdateStrategy::LatestPatchOfPreviousMinor, + ), + ( + "LatestPatchOfPreviousMinor", + UpdateStrategy::LatestPatchOfPreviousMinor, + ), + ]; + + for (strategy_str, expected_strategy) in strategies { + let yaml_config = format!( + r#" +compose_paths: [] +schedule: "0 0 2 * * *" +update_strategy: "{strategy_str}" +"# + ); + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(yaml_config.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + let result = Config::load(temp_file.path().to_path_buf()); + assert!( + result.is_ok(), + "Should parse valid strategy: {strategy_str}" + ); + + let config = result.unwrap(); + assert_eq!(config.update_strategy, expected_strategy); + } +} + +#[test] +fn test_config_with_invalid_update_strategy() { + let yaml_config = r#" +compose_paths: [] +schedule: "0 0 2 * * *" +update_strategy: "InvalidStrategy" +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(yaml_config.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + let result = Config::load(temp_file.path().to_path_buf()); + + assert!(result.is_err(), "Should not parse invalid update strategy"); +} + +#[test] +fn test_image_ignore_patterns_functionality() { + // Test various ignore patterns + let test_cases = vec![ + // (ignore_pattern, image_to_test, should_be_ignored) + ("localhost", "localhost:5000/app:latest", true), + ("localhost", "nginx:latest", false), + ("127.0.0.1", "127.0.0.1:5000/app:latest", true), + ("127.0.0.1", "192.168.1.1:5000/app:latest", false), + ("ghcr.io", "ghcr.io/user/app:latest", true), + ("ghcr.io", "docker.io/nginx:latest", false), + ("test-", "test-app:latest", true), + ("test-", "app-test:latest", false), + ]; + + for (ignore_pattern, image_to_test, should_be_ignored) in test_cases { + let config = Config { + ignore_images: vec![ignore_pattern.to_string()], + ..Config::default() + }; + + let result = config.is_image_ignored(image_to_test); + assert_eq!( + result, should_be_ignored, + "Pattern '{ignore_pattern}' with image '{image_to_test}' should be ignored: {should_be_ignored}" + ); + } +} + +#[test] +fn test_multiple_ignore_patterns() { + let config = Config { + ignore_images: vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "test-".to_string(), + "ghcr.io".to_string(), + ], + ..Config::default() + }; + + let test_cases = vec![ + ("localhost:5000/app:latest", true), + ("127.0.0.1:5000/app:latest", true), + ("test-app:latest", true), + ("ghcr.io/user/app:latest", true), + ("docker.io/nginx:latest", false), + ("registry.example.com/app:latest", false), + ("app-test:latest", false), + ]; + + for (image, should_be_ignored) in test_cases { + let result = config.is_image_ignored(image); + assert_eq!( + result, should_be_ignored, + "Image '{image}' should be ignored: {should_be_ignored}" + ); + } +} + +#[test] +fn test_registry_configuration_validation() { + // Test registry configs with various configurations + let mut registries = HashMap::new(); + + // Registry with just URL + registries.insert( + "simple.registry.com".to_string(), + RegistryConfig { + url: "https://simple.registry.com".to_string(), + auth_token: None, + }, + ); + + // Registry with auth token + registries.insert( + "auth.registry.com".to_string(), + RegistryConfig { + url: "https://auth.registry.com".to_string(), + auth_token: Some("secret-token".to_string()), + }, + ); + + let config = Config { + registries, + ..Config::default() + }; + + // Verify registries are properly configured + assert!(config.registries.contains_key("simple.registry.com")); + assert!(config.registries.contains_key("auth.registry.com")); + + let simple_registry = &config.registries["simple.registry.com"]; + assert_eq!(simple_registry.url, "https://simple.registry.com"); + assert!(simple_registry.auth_token.is_none()); + + let auth_registry = &config.registries["auth.registry.com"]; + assert_eq!(auth_registry.url, "https://auth.registry.com"); + assert_eq!(auth_registry.auth_token, Some("secret-token".to_string())); +} + +#[test] +fn test_config_with_empty_fields() { + let yaml_config = r#" +compose_paths: [] +schedule: "" +ignore_images: [] +registries: {} +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(yaml_config.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + let result = Config::load(temp_file.path().to_path_buf()); + + if result.is_ok() { + let config = result.unwrap(); + assert!(config.compose_paths.is_empty()); + assert!(config.ignore_images.is_empty()); + // Empty schedule should either be rejected or have a default + assert!(!config.schedule.is_empty() || config.schedule.is_empty()); // Either is acceptable + } + // If loading fails for empty schedule, that's also acceptable +} + +#[test] +fn test_config_compose_paths_handling() { + // Test various compose path configurations + let test_cases = vec![ + // Single file path + vec!["./docker-compose.yml"], + // Multiple file paths + vec!["./docker-compose.yml", "./docker-compose.override.yml"], + // Directory paths + vec!["./services/", "./environments/"], + // Mixed paths + vec!["./docker-compose.yml", "./services/", "./override.yml"], + // Relative and absolute-looking paths + vec![ + "docker-compose.yml", + "/app/config/compose.yml", + "../compose.yml", + ], + ]; + + for paths in test_cases { + let yaml_config = format!( + r#" +compose_paths: +{} +schedule: "0 0 2 * * *" +"#, + paths + .iter() + .map(|p| format!(" - \"{p}\"")) + .collect::>() + .join("\n") + ); + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(yaml_config.as_bytes()).unwrap(); + temp_file.flush().unwrap(); + + let result = Config::load(temp_file.path().to_path_buf()); + assert!(result.is_ok(), "Should handle paths: {paths:?}"); + + let config = result.unwrap(); + assert_eq!(config.compose_paths.len(), paths.len()); + for (i, expected_path) in paths.iter().enumerate() { + assert_eq!(config.compose_paths[i], PathBuf::from(expected_path)); + } + } +} diff --git a/tests/test_error_handling.rs b/tests/test_error_handling.rs new file mode 100644 index 0000000..8f3dbca --- /dev/null +++ b/tests/test_error_handling.rs @@ -0,0 +1,279 @@ +use docker_compose_updater::compose::updater::ComposeUpdater; +use docker_compose_updater::config::{Config, RegistryConfig, UpdateStrategy}; +use docker_compose_updater::registry::{Client as RegistryClient, ImageRef}; +use docker_compose_updater::scheduler::Scheduler; +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use tempfile::{NamedTempFile, TempDir}; + +#[test] +fn test_scheduler_invalid_cron_expressions() { + // Test that scheduler now properly rejects invalid cron expressions instead of falling back + let invalid_cron_configs = [ + "invalid cron", + "0 0 25 * * *", // Invalid hour + "60 0 2 * * *", // Invalid minute + "", // Empty string + "0 0 2 * *", // Missing field + ]; + + for invalid_cron in invalid_cron_configs { + let config = Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: invalid_cron.to_string(), + registries: HashMap::new(), + update_strategy: UpdateStrategy::Latest, + ignore_images: vec![], + dry_run: true, + }; + + let result = Scheduler::new(config, None); + assert!( + result.is_err(), + "Expected error for invalid cron expression: '{invalid_cron}'" + ); + } +} + +#[test] +fn test_scheduler_valid_cron_expressions() { + // Test that scheduler accepts valid cron expressions + let valid_cron_configs = [ + "0 0 2 * * *", // Default - 2 AM daily + "0 30 1 * * *", // 1:30 AM daily + "0 0 */6 * * *", // Every 6 hours + "0 0 2 * * MON", // Mondays at 2 AM + "0 15 14 1 * *", // 1st of month at 2:15 PM + ]; + + for valid_cron in valid_cron_configs { + let config = Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: valid_cron.to_string(), + registries: HashMap::new(), + update_strategy: UpdateStrategy::Latest, + ignore_images: vec![], + dry_run: true, + }; + + let result = Scheduler::new(config, None); + assert!( + result.is_ok(), + "Expected success for valid cron expression: '{valid_cron}'" + ); + } +} + +#[test] +fn test_compose_invalid_file_paths() { + let config = create_test_config(); + let updater = ComposeUpdater::new(config); + + // Test with completely invalid path + let result = updater.parse_compose_file("/nonexistent/path/file.yml"); + assert!(result.is_err(), "Should fail for nonexistent file"); + + // Test with directory instead of file + let temp_dir = TempDir::new().unwrap(); + let result = updater.parse_compose_file(temp_dir.path().to_str().unwrap()); + assert!(result.is_err(), "Should fail when path is a directory"); +} + +#[test] +fn test_compose_malformed_yaml() { + let config = create_test_config(); + let updater = ComposeUpdater::new(config); + + let malformed_yamls = [ + // Invalid YAML syntax + r#" +version: '3.8' +services: + web: + image: nginx:1.21.0 + invalid_indent + ports: + - "80:80" +"#, + // Missing required fields + r#" +version: '3.8' +# Missing services section +"#, + // Completely empty file + "", + // Invalid version format + r#" +version: invalid +services: + web: + image: nginx:1.21.0 +"#, + ]; + + for (i, malformed_yaml) in malformed_yamls.iter().enumerate() { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(malformed_yaml.as_bytes()).unwrap(); + + let result = updater.parse_compose_file(temp_file.path().to_str().unwrap()); + + // We expect these to either fail or return empty services (graceful degradation) + if result.is_ok() { + let compose_file = result.unwrap(); + // If it parses successfully, it should at least not crash + assert!( + compose_file.services.is_empty() || !compose_file.services.is_empty(), + "Test case {i} should either fail or succeed gracefully" + ); + } + // If it fails, that's also acceptable behavior for malformed YAML + } +} + +#[test] +fn test_image_ref_parsing_edge_cases() { + // Test that image parsing doesn't crash on edge cases + let long_name = "a".repeat(300); + let edge_case_refs = [ + "", // Empty string + ":", // Just colon + "image:", // Missing tag + ":tag", // Missing image + "registry.com/", // Missing image name + &long_name, // Extremely long name + ]; + + for edge_case_ref in edge_case_refs { + let result = ImageRef::parse(edge_case_ref); + // The main goal is that parsing doesn't crash and returns a result + // Some might succeed (with defaults) or fail - both are acceptable + if result.is_ok() { + let _image_ref = result.unwrap(); + // If it succeeds, that's fine - the parser is robust + // We don't make strict assertions about the content since + // the parser may use defaults for edge cases + } + // If they fail, that's also perfectly acceptable behavior + } +} + +#[test] +fn test_registry_unknown_registry() { + let config = Config { + compose_paths: vec![], + schedule: "0 0 2 * * *".to_string(), + registries: HashMap::new(), // Empty registries + update_strategy: UpdateStrategy::Latest, + ignore_images: vec![], + dry_run: true, + }; + + let registry_client = RegistryClient::new(config); + + // Test with an unknown registry + let image_ref = ImageRef { + registry: "unknown.registry.com".to_string(), + namespace: Some("user".to_string()), + name: "app".to_string(), + tag: "1.0.0".to_string(), + }; + + // This should fail because the registry is not configured + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(registry_client.get_available_versions(&image_ref)); + assert!(result.is_err(), "Should fail for unknown registry"); + + let error_msg = result.unwrap_err().to_string(); + assert!( + error_msg.contains("Unknown registry"), + "Error should mention unknown registry, got: {error_msg}" + ); +} + +#[tokio::test] +async fn test_compose_update_with_empty_paths() { + let config = Config { + compose_paths: vec![], // Empty paths + ..create_test_config() + }; + let updater = ComposeUpdater::new(config); + + // Should handle empty paths gracefully + let result = updater.update_all_compose_files().await; + assert!(result.is_ok(), "Should handle empty paths gracefully"); + assert!( + result.unwrap().is_empty(), + "Should return empty list for empty paths" + ); +} + +#[test] +fn test_config_edge_cases() { + // Test config with minimal valid data + let minimal_config = Config { + compose_paths: vec![], // Empty paths + schedule: "0 0 2 * * *".to_string(), + registries: HashMap::new(), // No registries + update_strategy: UpdateStrategy::Latest, + ignore_images: vec![], + dry_run: true, + }; + + // Should be able to create scheduler with minimal config + let scheduler_result = Scheduler::new(minimal_config.clone(), None); + assert!( + scheduler_result.is_ok(), + "Should accept minimal valid config" + ); + + // Should be able to create compose updater with minimal config + let _updater = ComposeUpdater::new(minimal_config.clone()); + // This should not panic + assert!(!minimal_config.is_image_ignored("any-image:tag")); +} + +#[test] +fn test_version_selection_with_mismatched_prefixes_suffixes() { + use docker_compose_updater::strategy::create_selector; + use docker_compose_updater::version::VersionInfo; + + let config = create_test_config(); + let selector = create_selector(&config.update_strategy); + + let current_prefix = Some("v".to_string()); + let current_suffix = Some("-alpine".to_string()); + + let available = [ + VersionInfo::from_tag("1.3.0").unwrap(), + VersionInfo::from_tag("v1.3.0-ubuntu").unwrap(), + VersionInfo::from_tag("release-1.1.5-alpine").unwrap(), + ]; + + let target = selector.select_target_version(&available, current_prefix, current_suffix); + + assert!( + target.is_none(), + "Should not find target when prefix/suffix don't match" + ); +} + +fn create_test_config() -> Config { + let mut registries = HashMap::new(); + registries.insert( + "docker.io".to_string(), + RegistryConfig { + url: "https://registry-1.docker.io".to_string(), + auth_token: None, + }, + ); + + Config { + compose_paths: vec![PathBuf::from("./test")], + schedule: "0 0 2 * * *".to_string(), + registries, + update_strategy: UpdateStrategy::Latest, + ignore_images: vec![], + dry_run: true, + } +} diff --git a/tests/test_suffix_handling.rs b/tests/test_suffix_handling.rs new file mode 100644 index 0000000..86fd316 --- /dev/null +++ b/tests/test_suffix_handling.rs @@ -0,0 +1,44 @@ +use docker_compose_updater::compose::updater::ComposeUpdater; +use docker_compose_updater::config::{Config, UpdateStrategy}; +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use tempfile::NamedTempFile; + +#[tokio::test] +async fn test_compose_update_with_suffix_preservation() { + let compose_content = r#" +version: '3.8' +services: + web: + image: nginx:1.21.0-alpine + ports: + - "80:80" +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(compose_content.as_bytes()).unwrap(); + + let config = Config { + compose_paths: vec![PathBuf::from(temp_file.path().to_str().unwrap())], + schedule: "0 0 2 * * *".to_string(), + registries: HashMap::new(), + update_strategy: UpdateStrategy::Latest, + ignore_images: vec![], + dry_run: true, + }; + + let updater = ComposeUpdater::new(config); + let result = updater + .parse_compose_file(temp_file.path().to_str().unwrap()) + .unwrap(); + + assert_eq!(result.services[0].image_ref.tag, "1.21.0-alpine"); + + let new_content = updater + .replace_image_in_content(&result.content, &result.services[0], "nginx:1.22.0-alpine") + .unwrap(); + + assert!(new_content.contains("nginx:1.22.0-alpine")); + assert!(!new_content.contains("nginx:1.21.0-alpine")); +}