From 8a29b5050753dbfd3b6ac2aef9c9694b1eed57e2 Mon Sep 17 00:00:00 2001 From: cbaugus Date: Mon, 9 Feb 2026 12:44:14 -0600 Subject: [PATCH 1/7] Refactor monolithic main.rs into modular structure Split the 794-line main.rs into focused modules for better maintainability, testability, and code organization. Closes #15 Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 1748 ++++++++++++++++++++++++++++++++++++++++++++ src/client.rs | 267 +++++++ src/config.rs | 219 ++++++ src/lib.rs | 6 + src/load_models.rs | 181 +++++ src/main.rs | 793 +------------------- src/metrics.rs | 102 +++ src/utils.rs | 157 ++++ src/worker.rs | 101 +++ 9 files changed, 2820 insertions(+), 754 deletions(-) create mode 100644 Cargo.lock create mode 100644 src/client.rs create mode 100644 src/config.rs create mode 100644 src/lib.rs create mode 100644 src/load_models.rs create mode 100644 src/metrics.rs create mode 100644 src/utils.rs create mode 100644 src/worker.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9525ed6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1748 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +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", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "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 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +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.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +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.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[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 = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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 = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "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.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rust_loadtest" +version = "0.1.0" +dependencies = [ + "hyper 0.14.32", + "lazy_static", + "pem", + "prometheus", + "reqwest", + "rustls 0.22.4", + "rustls-pemfile", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.25.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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 = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +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 = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[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 = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..7359cbe --- /dev/null +++ b/src/client.rs @@ -0,0 +1,267 @@ +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use std::fs::File; +use std::io::Read; +use std::net::SocketAddr; +use std::str::FromStr; + +use crate::utils::parse_headers_with_escapes; + +/// Configuration for building the HTTP client. +pub struct ClientConfig { + pub skip_tls_verify: bool, + pub resolve_target_addr: Option, + pub client_cert_path: Option, + pub client_key_path: Option, + pub custom_headers: Option, +} + +/// Result of building the client, includes parsed headers for logging. +pub struct ClientBuildResult { + pub client: reqwest::Client, + pub parsed_headers: HeaderMap, +} + +/// Builds a reqwest HTTP client with the specified configuration. +pub fn build_client( + config: &ClientConfig, +) -> Result> { + let mut client_builder = reqwest::Client::builder(); + + // DNS Override Configuration + if let Some(ref resolve_str) = config.resolve_target_addr { + if !resolve_str.is_empty() { + client_builder = configure_dns_override(client_builder, resolve_str)?; + } else { + println!("RESOLVE_TARGET_ADDR is set but empty, no DNS override will be applied."); + } + } + + // mTLS Configuration + client_builder = configure_mtls( + client_builder, + config.client_cert_path.as_deref(), + config.client_key_path.as_deref(), + )?; + + // Custom Headers Configuration + let parsed_headers = configure_custom_headers(config.custom_headers.as_deref())?; + if !parsed_headers.is_empty() { + client_builder = client_builder.default_headers(parsed_headers.clone()); + println!("Successfully configured custom default headers."); + } + + // Build client with TLS settings + let client = if config.skip_tls_verify { + println!("WARNING: Skipping TLS certificate verification."); + client_builder + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build()? + } else { + client_builder.build()? + }; + + Ok(ClientBuildResult { + client, + parsed_headers, + }) +} + +fn configure_dns_override( + mut client_builder: reqwest::ClientBuilder, + resolve_str: &str, +) -> Result> { + println!( + "Attempting to apply DNS override from RESOLVE_TARGET_ADDR: {}", + resolve_str + ); + + let parts: Vec<&str> = resolve_str.split(':').collect(); + if parts.len() != 3 { + return Err(format!( + "RESOLVE_TARGET_ADDR environment variable ('{}') is not in the expected format 'hostname:ip:port'", + resolve_str + ).into()); + } + + let hostname_to_override = parts[0].trim(); + let ip_to_resolve_to = parts[1].trim(); + let port_to_connect_to_str = parts[2].trim(); + + if hostname_to_override.is_empty() { + return Err( + "RESOLVE_TARGET_ADDR: hostname part cannot be empty. Format: 'hostname:ip:port'".into(), + ); + } + if ip_to_resolve_to.is_empty() { + return Err( + "RESOLVE_TARGET_ADDR: IP address part cannot be empty. Format: 'hostname:ip:port'" + .into(), + ); + } + if port_to_connect_to_str.is_empty() { + return Err( + "RESOLVE_TARGET_ADDR: port part cannot be empty. Format: 'hostname:ip:port'".into(), + ); + } + + let port_to_connect_to: u16 = port_to_connect_to_str.parse().map_err(|e| { + format!( + "Failed to parse port '{}' in RESOLVE_TARGET_ADDR: {}. Must be a valid u16. Format: 'hostname:ip:port'", + port_to_connect_to_str, e + ) + })?; + + let socket_addr_str = format!("{}:{}", ip_to_resolve_to, port_to_connect_to); + let socket_addr: SocketAddr = socket_addr_str.parse().map_err(|e| { + format!( + "Failed to parse IP/Port '{}' into SocketAddr for RESOLVE_TARGET_ADDR: {}. Ensure IP and port are valid. Format: 'hostname:ip:port'", + socket_addr_str, e + ) + })?; + + client_builder = client_builder.resolve(hostname_to_override, socket_addr); + println!( + "Successfully configured DNS override: '{}' will resolve to {}", + hostname_to_override, socket_addr + ); + + Ok(client_builder) +} + +fn configure_mtls( + mut client_builder: reqwest::ClientBuilder, + cert_path: Option<&str>, + key_path: Option<&str>, +) -> Result> { + match (cert_path, key_path) { + (Some(cert_path), Some(key_path)) => { + println!("Attempting to load mTLS certificate from: {}", cert_path); + println!("Attempting to load mTLS private key from: {}", key_path); + + let mut cert_file = File::open(cert_path) + .map_err(|e| format!("Failed to open client certificate file '{}': {}", cert_path, e))?; + let mut cert_pem_buf = Vec::new(); + cert_file.read_to_end(&mut cert_pem_buf) + .map_err(|e| format!("Failed to read client certificate file '{}': {}", cert_path, e))?; + + let mut key_file = File::open(key_path) + .map_err(|e| format!("Failed to open client key file '{}': {}", key_path, e))?; + let mut key_pem_buf = Vec::new(); + key_file.read_to_end(&mut key_pem_buf) + .map_err(|e| format!("Failed to read client key file '{}': {}", key_path, e))?; + + // Validate certificate PEM + let mut cert_pem_cursor = std::io::Cursor::new(cert_pem_buf.as_slice()); + let certs_result: Vec<_> = rustls_pemfile::certs(&mut cert_pem_cursor).collect(); + if certs_result.is_empty() { + return Err(format!("No PEM certificates found in {}", cert_path).into()); + } + for cert in certs_result { + if let Err(e) = cert { + return Err(format!( + "Failed to parse PEM certificates from '{}': {}", + cert_path, e + ).into()); + } + } + + // Validate private key PEM (must be PKCS#8) + let mut key_pem_cursor = std::io::Cursor::new(key_pem_buf.as_slice()); + let keys_result: Vec<_> = rustls_pemfile::pkcs8_private_keys(&mut key_pem_cursor).collect(); + if keys_result.is_empty() { + return Err(format!( + "No PKCS#8 private keys found in '{}'. Ensure the file contains a valid PEM-encoded PKCS#8 private key.", + key_path + ).into()); + } + for key in keys_result { + if let Err(e) = key { + return Err(format!( + "Failed to parse private key from '{}' as PKCS#8: {}. Please ensure the key is PEM-encoded and in PKCS#8 format.", + key_path, e + ).into()); + } + } + + // Combine certificate PEM and key PEM into one buffer + let mut combined_pem_buf = Vec::new(); + combined_pem_buf.extend_from_slice(&cert_pem_buf); + if !cert_pem_buf.ends_with(b"\n") && !key_pem_buf.starts_with(b"\n") { + combined_pem_buf.push(b'\n'); + } + combined_pem_buf.extend_from_slice(&key_pem_buf); + + let identity = reqwest::Identity::from_pem(&combined_pem_buf) + .map_err(|e| format!( + "Failed to create reqwest::Identity from PEM (cert+key): {}. Ensure the key is PKCS#8 and the certificate is valid.", + e + ))?; + + client_builder = client_builder.identity(identity); + println!("Successfully configured mTLS with client certificate and key."); + } + (Some(_), None) => { + return Err("CLIENT_CERT_PATH is set, but CLIENT_KEY_PATH is missing for mTLS.".into()); + } + (None, Some(_)) => { + return Err("CLIENT_KEY_PATH is set, but CLIENT_CERT_PATH is missing for mTLS.".into()); + } + (None, None) => { + // No mTLS configured + } + } + + Ok(client_builder) +} + +fn configure_custom_headers( + custom_headers_str: Option<&str>, +) -> Result> { + let mut parsed_headers = HeaderMap::new(); + + let headers_str = match custom_headers_str { + Some(s) if !s.is_empty() => s, + _ => return Ok(parsed_headers), + }; + + println!("Attempting to parse CUSTOM_HEADERS: {}", headers_str); + + let header_pairs = parse_headers_with_escapes(headers_str); + + for header_pair_str in header_pairs { + let header_pair_str_trimmed = header_pair_str.trim(); + if header_pair_str_trimmed.is_empty() { + continue; + } + + let parts: Vec<&str> = header_pair_str_trimmed.splitn(2, ':').collect(); + if parts.len() != 2 { + return Err(format!( + "Invalid header format in CUSTOM_HEADERS: '{}'. Expected 'Name:Value'.", + header_pair_str_trimmed + ).into()); + } + + let name_str = parts[0].trim(); + let value_str = parts[1].trim(); + + if name_str.is_empty() { + return Err(format!( + "Invalid header format: Header name cannot be empty in '{}'.", + header_pair_str_trimmed + ).into()); + } + + let unescaped_value = value_str.replace("\\,", ","); + + let header_name = HeaderName::from_str(name_str) + .map_err(|e| format!("Invalid header name: {}. Name: '{}'", e, name_str))?; + let header_value = HeaderValue::from_str(&unescaped_value) + .map_err(|e| format!("Invalid header value for '{}': {}. Value: '{}'", name_str, e, unescaped_value))?; + + parsed_headers.insert(header_name, header_value); + } + + Ok(parsed_headers) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d4e9d90 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,219 @@ +use std::env; +use tokio::time::Duration; + +use crate::client::ClientConfig; +use crate::load_models::LoadModel; +use crate::utils::parse_duration_string; + +/// Main configuration for the load test. +#[derive(Debug, Clone)] +pub struct Config { + pub target_url: String, + pub request_type: String, + pub send_json: bool, + pub json_payload: Option, + pub num_concurrent_tasks: usize, + pub test_duration: Duration, + pub load_model: LoadModel, + pub skip_tls_verify: bool, + pub resolve_target_addr: Option, + pub client_cert_path: Option, + pub client_key_path: Option, + pub custom_headers: Option, +} + +impl Config { + /// Loads configuration from environment variables. + pub fn from_env() -> Result> { + let target_url = + env::var("TARGET_URL").expect("TARGET_URL environment variable must be set"); + + let request_type = env::var("REQUEST_TYPE").unwrap_or_else(|_| "POST".to_string()); + + let send_json = env::var("SEND_JSON") + .unwrap_or_else(|_| "false".to_string()) + .to_lowercase() + == "true"; + + let json_payload = if send_json { + Some( + env::var("JSON_PAYLOAD") + .expect("JSON_PAYLOAD environment variable must be set when SEND_JSON=true"), + ) + } else { + None + }; + + let num_concurrent_tasks: usize = env::var("NUM_CONCURRENT_TASKS") + .unwrap_or_else(|_| "10".to_string()) + .parse() + .expect("NUM_CONCURRENT_TASKS must be a valid number"); + + let test_duration_str = env::var("TEST_DURATION").unwrap_or_else(|_| "2h".to_string()); + let test_duration = parse_duration_string(&test_duration_str).map_err(|e| { + format!( + "Invalid TEST_DURATION format: '{}'. {}", + test_duration_str, e + ) + })?; + + let load_model = Self::parse_load_model(&test_duration_str)?; + + let skip_tls_verify = env::var("SKIP_TLS_VERIFY") + .unwrap_or_else(|_| "false".to_string()) + .to_lowercase() + == "true"; + + let resolve_target_addr = env::var("RESOLVE_TARGET_ADDR").ok(); + let client_cert_path = env::var("CLIENT_CERT_PATH").ok(); + let client_key_path = env::var("CLIENT_KEY_PATH").ok(); + let custom_headers = env::var("CUSTOM_HEADERS").ok(); + + Ok(Config { + target_url, + request_type, + send_json, + json_payload, + num_concurrent_tasks, + test_duration, + load_model, + skip_tls_verify, + resolve_target_addr, + client_cert_path, + client_key_path, + custom_headers, + }) + } + + fn parse_load_model( + test_duration_str: &str, + ) -> Result> { + let model_type = env::var("LOAD_MODEL_TYPE").unwrap_or_else(|_| "Concurrent".to_string()); + + match model_type.as_str() { + "Concurrent" => Ok(LoadModel::Concurrent), + "Rps" => { + let target_rps: f64 = env::var("TARGET_RPS") + .expect("TARGET_RPS must be set for Rps model") + .parse()?; + Ok(LoadModel::Rps { target_rps }) + } + "RampRps" => { + let min_rps: f64 = env::var("MIN_RPS") + .expect("MIN_RPS must be set for RampRps") + .parse()?; + let max_rps: f64 = env::var("MAX_RPS") + .expect("MAX_RPS must be set for RampRps") + .parse()?; + let ramp_duration_str = env::var("RAMP_DURATION") + .unwrap_or_else(|_| test_duration_str.to_string()); + let ramp_duration = parse_duration_string(&ramp_duration_str)?; + Ok(LoadModel::RampRps { + min_rps, + max_rps, + ramp_duration, + }) + } + "DailyTraffic" => { + let min_rps: f64 = env::var("DAILY_MIN_RPS") + .expect("DAILY_MIN_RPS must be set for DailyTraffic model") + .parse()?; + let mid_rps: f64 = env::var("DAILY_MID_RPS") + .expect("DAILY_MID_RPS must be set for DailyTraffic model") + .parse()?; + let max_rps: f64 = env::var("DAILY_MAX_RPS") + .expect("DAILY_MAX_RPS must be set for DailyTraffic model") + .parse()?; + let cycle_duration_str = env::var("DAILY_CYCLE_DURATION") + .expect("DAILY_CYCLE_DURATION must be set for DailyTraffic model"); + let cycle_duration = parse_duration_string(&cycle_duration_str)?; + + let morning_ramp_ratio: f64 = env::var("MORNING_RAMP_RATIO") + .unwrap_or_else(|_| "0.125".to_string()) + .parse()?; + let peak_sustain_ratio: f64 = env::var("PEAK_SUSTAIN_RATIO") + .unwrap_or_else(|_| "0.167".to_string()) + .parse()?; + let mid_decline_ratio: f64 = env::var("MID_DECLINE_RATIO") + .unwrap_or_else(|_| "0.125".to_string()) + .parse()?; + let mid_sustain_ratio: f64 = env::var("MID_SUSTAIN_RATIO") + .unwrap_or_else(|_| "0.167".to_string()) + .parse()?; + let evening_decline_ratio: f64 = env::var("EVENING_DECLINE_RATIO") + .unwrap_or_else(|_| "0.167".to_string()) + .parse()?; + + let total_ratios = morning_ramp_ratio + + peak_sustain_ratio + + mid_decline_ratio + + mid_sustain_ratio + + evening_decline_ratio; + if total_ratios > 1.0 { + eprintln!( + "Warning: Sum of DailyTraffic segment ratios exceeds 1.0 (Total: {}). Night sustain phase will be negative or very short.", + total_ratios + ); + } + + Ok(LoadModel::DailyTraffic { + min_rps, + mid_rps, + max_rps, + cycle_duration, + morning_ramp_ratio, + peak_sustain_ratio, + mid_decline_ratio, + mid_sustain_ratio, + evening_decline_ratio, + }) + } + _ => panic!("Unknown LOAD_MODEL_TYPE: {}", model_type), + } + } + + /// Creates a ClientConfig from this Config. + pub fn to_client_config(&self) -> ClientConfig { + ClientConfig { + skip_tls_verify: self.skip_tls_verify, + resolve_target_addr: self.resolve_target_addr.clone(), + client_cert_path: self.client_cert_path.clone(), + client_key_path: self.client_key_path.clone(), + custom_headers: self.custom_headers.clone(), + } + } + + /// Prints the configuration summary. + pub fn print_summary(&self, parsed_headers: &reqwest::header::HeaderMap) { + println!("Starting load test:"); + println!(" Target URL: {}", self.target_url); + println!(" Request type: {}", self.request_type); + println!(" Concurrent Tasks: {}", self.num_concurrent_tasks); + println!(" Overall Test Duration: {:?}", self.test_duration); + println!(" Load Model: {:?}", self.load_model); + println!(" Skip TLS Verify: {}", self.skip_tls_verify); + + if self.client_cert_path.is_some() && self.client_key_path.is_some() { + println!(" mTLS Enabled: Yes (using CLIENT_CERT_PATH and CLIENT_KEY_PATH)"); + } else { + println!(" mTLS Enabled: No (CLIENT_CERT_PATH or CLIENT_KEY_PATH not set, or only one was set)"); + } + + if let Some(ref headers_str) = self.custom_headers { + if !headers_str.is_empty() && !parsed_headers.is_empty() { + println!(" Custom Headers Enabled: Yes"); + for (name, value) in parsed_headers.iter() { + println!( + " {}: {}", + name, + value.to_str().unwrap_or("") + ); + } + } else { + println!(" Custom Headers Enabled: No (CUSTOM_HEADERS was set but resulted in no valid headers or was empty after parsing)"); + } + } else { + println!(" Custom Headers Enabled: No (CUSTOM_HEADERS not set)"); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fc988e6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod client; +pub mod config; +pub mod load_models; +pub mod metrics; +pub mod utils; +pub mod worker; diff --git a/src/load_models.rs b/src/load_models.rs new file mode 100644 index 0000000..9ac5a0f --- /dev/null +++ b/src/load_models.rs @@ -0,0 +1,181 @@ +use tokio::time::Duration; + +/// Represents different load generation models for the load test. +#[derive(Debug, Clone)] +pub enum LoadModel { + /// No specific RPS limit, just max concurrency. + /// Requests are sent as fast as possible within the concurrency limit. + Concurrent, + + /// Fixed RPS target. + /// Maintains a constant request rate throughout the test. + Rps { + target_rps: f64, + }, + + /// Linear ramp up/down pattern. + /// Divides the ramp_duration into thirds: + /// - First 1/3: Ramp from min_rps to max_rps + /// - Middle 1/3: Sustain at max_rps + /// - Last 1/3: Ramp down from max_rps to min_rps + RampRps { + min_rps: f64, + max_rps: f64, + ramp_duration: Duration, + }, + + /// Complex daily traffic pattern simulation. + /// Simulates realistic daily traffic with multiple phases: + /// 1. Morning ramp-up (min to max) + /// 2. Peak sustain (max) + /// 3. Mid-day decline (max to mid) + /// 4. Mid-day sustain (mid) + /// 5. Evening decline (mid to min) + /// 6. Night sustain (min) + DailyTraffic { + min_rps: f64, + mid_rps: f64, + max_rps: f64, + cycle_duration: Duration, + morning_ramp_ratio: f64, + peak_sustain_ratio: f64, + mid_decline_ratio: f64, + mid_sustain_ratio: f64, + evening_decline_ratio: f64, + }, +} + +impl LoadModel { + /// Calculates the current target RPS based on the model and elapsed time. + /// + /// # Arguments + /// * `elapsed_total_secs` - Total seconds elapsed since test start + /// * `_overall_test_duration_secs` - Total test duration in seconds (unused for some models) + /// + /// # Returns + /// The target requests per second for the current point in time. + pub fn calculate_current_rps( + &self, + elapsed_total_secs: f64, + _overall_test_duration_secs: f64, + ) -> f64 { + match self { + LoadModel::Concurrent => f64::MAX, + LoadModel::Rps { target_rps } => *target_rps, + LoadModel::RampRps { + min_rps, + max_rps, + ramp_duration, + } => Self::calculate_ramp_rps(*min_rps, *max_rps, ramp_duration, elapsed_total_secs), + LoadModel::DailyTraffic { + min_rps, + mid_rps, + max_rps, + cycle_duration, + morning_ramp_ratio, + peak_sustain_ratio, + mid_decline_ratio, + mid_sustain_ratio, + evening_decline_ratio, + } => Self::calculate_daily_traffic_rps( + *min_rps, + *mid_rps, + *max_rps, + cycle_duration, + *morning_ramp_ratio, + *peak_sustain_ratio, + *mid_decline_ratio, + *mid_sustain_ratio, + *evening_decline_ratio, + elapsed_total_secs, + ), + } + } + + fn calculate_ramp_rps( + min_rps: f64, + max_rps: f64, + ramp_duration: &Duration, + elapsed_total_secs: f64, + ) -> f64 { + let total_ramp_secs = ramp_duration.as_secs_f64(); + + if total_ramp_secs <= 0.0 { + return max_rps; + } + + let one_third_duration = total_ramp_secs / 3.0; + + if elapsed_total_secs <= one_third_duration { + // Ramp-up phase (first 1/3) + min_rps + (max_rps - min_rps) * (elapsed_total_secs / one_third_duration) + } else if elapsed_total_secs <= 2.0 * one_third_duration { + // Max load phase (middle 1/3) + max_rps + } else { + // Ramp-down phase (last 1/3) + let ramp_down_elapsed = elapsed_total_secs - 2.0 * one_third_duration; + let rps = max_rps - (max_rps - min_rps) * (ramp_down_elapsed / one_third_duration); + rps.max(min_rps) + } + } + + #[allow(clippy::too_many_arguments)] + fn calculate_daily_traffic_rps( + min_rps: f64, + mid_rps: f64, + max_rps: f64, + cycle_duration: &Duration, + morning_ramp_ratio: f64, + peak_sustain_ratio: f64, + mid_decline_ratio: f64, + mid_sustain_ratio: f64, + evening_decline_ratio: f64, + elapsed_total_secs: f64, + ) -> f64 { + let cycle_duration_secs = cycle_duration.as_secs_f64(); + + if cycle_duration_secs <= 0.0 { + return max_rps; + } + + let time_in_cycle = elapsed_total_secs % cycle_duration_secs; + + let morning_ramp_end = cycle_duration_secs * morning_ramp_ratio; + let peak_sustain_end = morning_ramp_end + (cycle_duration_secs * peak_sustain_ratio); + let mid_decline_end = peak_sustain_end + (cycle_duration_secs * mid_decline_ratio); + let mid_sustain_end = mid_decline_end + (cycle_duration_secs * mid_sustain_ratio); + let evening_decline_end = mid_sustain_end + (cycle_duration_secs * evening_decline_ratio); + + if time_in_cycle < morning_ramp_end { + // Phase 1: Morning Ramp-up (min_rps to max_rps) + Self::linear_interpolate(min_rps, max_rps, time_in_cycle, morning_ramp_end) + } else if time_in_cycle < peak_sustain_end { + // Phase 2: Peak Sustain (max_rps) + max_rps + } else if time_in_cycle < mid_decline_end { + // Phase 3: Mid-Day Decline (max_rps to mid_rps) + let decline_elapsed = time_in_cycle - peak_sustain_end; + let decline_duration = mid_decline_end - peak_sustain_end; + Self::linear_interpolate(max_rps, mid_rps, decline_elapsed, decline_duration) + } else if time_in_cycle < mid_sustain_end { + // Phase 4: Mid-Day Sustain (mid_rps) + mid_rps + } else if time_in_cycle < evening_decline_end { + // Phase 5: Evening Decline (mid_rps to min_rps) + let decline_elapsed = time_in_cycle - mid_sustain_end; + let decline_duration = evening_decline_end - mid_sustain_end; + Self::linear_interpolate(mid_rps, min_rps, decline_elapsed, decline_duration) + } else { + // Phase 6: Night Sustain (min_rps) + min_rps + } + } + + fn linear_interpolate(from: f64, to: f64, elapsed: f64, duration: f64) -> f64 { + if duration <= 0.0 { + return to; + } + from + (to - from) * (elapsed / duration) + } +} diff --git a/src/main.rs b/src/main.rs index 5b3d14f..cb88c21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,693 +1,73 @@ -extern crate lazy_static; - -use reqwest; -use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; // Added -use std::net::SocketAddr; // Added for DNS override -use tokio::{self, time::{self, Duration}}; use std::sync::{Arc, Mutex}; -use prometheus::{Encoder, Gauge, IntCounter, IntCounterVec, Opts, Registry, TextEncoder, Histogram}; -use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Request, Response, Server}; -use std::env; -use std::str::FromStr; // Needed for parsing numbers from strings -use std::fs::File; -use std::io::Read; -use rustls_pemfile; - -// Define Prometheus metrics -lazy_static::lazy_static! { - static ref METRIC_NAMESPACE: String = - env::var("METRIC_NAMESPACE").unwrap_or_else(|_| "rust_loadtest".to_string()); - - static ref REQUEST_TOTAL: IntCounter = - IntCounter::with_opts( - Opts::new("requests_total", "Total number of HTTP requests made") - .namespace(METRIC_NAMESPACE.as_str()) - ).unwrap(); - - static ref REQUEST_STATUS_CODES: IntCounterVec = - IntCounterVec::new( - Opts::new("requests_status_codes_total", "Number of HTTP requests by status code") - .namespace(METRIC_NAMESPACE.as_str()), - &["status_code"] - ).unwrap(); - - static ref CONCURRENT_REQUESTS: Gauge = - Gauge::with_opts( - Opts::new("concurrent_requests", "Number of HTTP requests currently in flight") - .namespace(METRIC_NAMESPACE.as_str()) - ).unwrap(); - - static ref REQUEST_DURATION_SECONDS: Histogram = - Histogram::with_opts( - prometheus::HistogramOpts::new( - "request_duration_seconds", - "HTTP request latencies in seconds." - ).namespace(METRIC_NAMESPACE.as_str()) - ).unwrap(); -} - -// --- NEW: Enum for different load models with their data --- -#[derive(Debug, Clone)] // Removed PartialEq, Eq because f64 doesn't implement them reliably -pub enum LoadModel { - Concurrent, // No specific RPS limit, just max concurrency - Rps { target_rps: f64 }, // Fixed RPS target - RampRps { // Linear ramp up/down - min_rps: f64, - max_rps: f64, - ramp_duration: Duration, // Total duration for the ramp profile (e.g., 2 hours for the 1/3, 1/8, remainder pattern) - }, - DailyTraffic { // Complex daily traffic pattern - min_rps: f64, // Base load (e.g., night-time traffic) - mid_rps: f64, // Mid-level load (e.g., afternoon traffic) - max_rps: f64, // Peak load (e.g., morning rush) - cycle_duration: Duration, // Duration of one full daily cycle (e.g., Duration::from_hours(24)) - // Ratios defining the segments within one cycle. Sum of ratios should be <= 1.0 - morning_ramp_ratio: f64, // From min_rps to max_rps - peak_sustain_ratio: f64, // Hold max_rps - mid_decline_ratio: f64, // From max_rps to mid_rps - mid_sustain_ratio: f64, // Hold mid_rps - evening_decline_ratio: f64, // From mid_rps to min_rps - // (Night sustain is implied by the end of the cycle) - }, -} - -// --- NEW: calculate_current_rps method for LoadModel --- -impl LoadModel { - // Helper function to calculate the current target RPS based on the model and elapsed time - // This function will be called repeatedly by each worker task. - pub fn calculate_current_rps(&self, elapsed_total_secs: f64, _overall_test_duration_secs: f64) -> f64 { - match self { - LoadModel::Concurrent => f64::MAX, // As fast as possible per task, limited by concurrency - LoadModel::Rps { target_rps } => *target_rps, - LoadModel::RampRps { min_rps, max_rps, ramp_duration } => { - let total_ramp_secs = ramp_duration.as_secs_f64(); - let current_target_rps: f64; // Declare without initial assignment - - if total_ramp_secs > 0.0 { - let one_third_duration = total_ramp_secs / 3.0; - - if elapsed_total_secs <= one_third_duration { - // Ramp-up phase (first 1/3) - current_target_rps = min_rps + (max_rps - min_rps) * (elapsed_total_secs / one_third_duration); - - // current_target_rps = 1000 + (400000 - 1000) * (300 / 300); - } else if elapsed_total_secs <= 2.0 * one_third_duration { - // Max load phase (middle 1/3) - current_target_rps = *max_rps; - } else { - // Ramp-down phase (last 1/3) - let ramp_down_elapsed = elapsed_total_secs - 2.0 * one_third_duration; - let mut rps = max_rps - (max_rps - min_rps) * (ramp_down_elapsed / one_third_duration); - // Ensure it doesn't go below min_rps - if rps < *min_rps { - rps = *min_rps; - } - current_target_rps = rps; - } - } else { - current_target_rps = *max_rps; // If duration is 0, just use max_rps - } - current_target_rps - }, - LoadModel::DailyTraffic { - min_rps, - mid_rps, - max_rps, - cycle_duration, - morning_ramp_ratio, - peak_sustain_ratio, - mid_decline_ratio, - mid_sustain_ratio, - evening_decline_ratio, - } => { - let cycle_duration_secs = cycle_duration.as_secs_f64(); - let time_in_cycle = elapsed_total_secs % cycle_duration_secs; - - let morning_ramp_end = cycle_duration_secs * morning_ramp_ratio; - let peak_sustain_end = morning_ramp_end + (cycle_duration_secs * peak_sustain_ratio); - let mid_decline_end = peak_sustain_end + (cycle_duration_secs * mid_decline_ratio); - let mid_sustain_end = mid_decline_end + (cycle_duration_secs * mid_sustain_ratio); - let evening_decline_end = mid_sustain_end + (cycle_duration_secs * evening_decline_ratio); - - let current_target_rps: f64; // Declare without initial assignment - - if cycle_duration_secs <= 0.0 { - current_target_rps = *max_rps; // Handle zero cycle duration, default to max - } else if time_in_cycle < morning_ramp_end { - // Phase 1: Morning Ramp-up (min_rps to max_rps) - let ramp_elapsed = time_in_cycle; - let ramp_duration_segment = morning_ramp_end; - if ramp_duration_segment > 0.0 { - current_target_rps = min_rps + (max_rps - min_rps) * (ramp_elapsed / ramp_duration_segment); - } else { - current_target_rps = *max_rps; // Instant ramp to max if duration is zero - } - } else if time_in_cycle < peak_sustain_end { - // Phase 2: Peak Sustain (max_rps) - current_target_rps = *max_rps; - } else if time_in_cycle < mid_decline_end { - // Phase 3: Mid-Day Decline (max_rps to mid_rps) - let decline_elapsed = time_in_cycle - peak_sustain_end; - let decline_duration_segment = mid_decline_end - peak_sustain_end; - if decline_duration_segment > 0.0 { - current_target_rps = max_rps - (max_rps - mid_rps) * (decline_elapsed / decline_duration_segment); - } else { - current_target_rps = *mid_rps; // Instant decline to mid if duration is zero - } - } else if time_in_cycle < mid_sustain_end { - // Phase 4: Mid-Day Sustain (mid_rps) - current_target_rps = *mid_rps; - } else if time_in_cycle < evening_decline_end { - // Phase 5: Evening Decline (mid_rps to min_rps) - let decline_elapsed = time_in_cycle - mid_sustain_end; - let decline_duration_segment = evening_decline_end - mid_sustain_end; - if decline_duration_segment > 0.0 { - current_target_rps = mid_rps - (mid_rps - min_rps) * (decline_elapsed / decline_duration_segment); - } else { - current_target_rps = *min_rps; // Instant decline to min if duration is zero - } - } else { - // Phase 6: Night Sustain (min_rps) - implicit until end of cycle - current_target_rps = *min_rps; - } - current_target_rps - }, - } - } -} -// --- END NEW: calculate_current_rps method --- - - -// --- Function to parse the duration string (copy-pasted from previous answer) --- -fn parse_duration_string(s: &str) -> Result { - let s = s.trim(); - - if s.is_empty() { - return Err("Duration string cannot be empty".to_string()); - } - - let unit_char = s.chars().last().unwrap(); - let value_str = &s[0..s.len() - 1]; - - let value = match u64::from_str(value_str) { - Ok(v) => v, - Err(_) => return Err(format!("Invalid numeric value in duration: '{}'", value_str)), - }; - - match unit_char { - 'm' => Ok(Duration::from_secs(value * 60)), - 'h' => Ok(Duration::from_secs(value * 60 * 60)), - 'd' => Ok(Duration::from_secs(value * 24 * 60 * 60)), - _ => Err(format!("Unknown duration unit: '{}'. Use 'm', 'h', or 'd'.", unit_char)), - } -} -// --- END Function to parse the duration string --- - -// --- Function to parse headers with escape support --- -fn parse_headers_with_escapes(headers_str: &str) -> Vec { - let mut headers = Vec::new(); - let mut current_header = String::new(); - let mut chars = headers_str.chars().peekable(); - - while let Some(ch) = chars.next() { - match ch { - '\\' => { - // Check if the next character is a comma - if chars.peek() == Some(&',') { - // This is an escaped comma, add it to the current header - current_header.push(','); - chars.next(); // Consume the comma - } else { - // Not escaping a comma, keep the backslash - current_header.push('\\'); - } - } - ',' => { - // This is a header separator - if !current_header.trim().is_empty() { - headers.push(current_header.clone()); - } - current_header.clear(); - } - _ => { - current_header.push(ch); - } - } - } - - // Don't forget the last header - if !current_header.trim().is_empty() { - headers.push(current_header); - } - - headers -} -// --- END Function to parse headers with escape support --- +use tokio::time::{self, Duration}; +use rust_loadtest::client::build_client; +use rust_loadtest::config::Config; +use rust_loadtest::metrics::{gather_metrics_string, register_metrics, start_metrics_server}; +use rust_loadtest::worker::{run_worker, WorkerConfig}; #[tokio::main] async fn main() -> Result<(), Box> { - // Register metrics with the default Prometheus registry - prometheus::default_registry().register(Box::new(REQUEST_TOTAL.clone()))?; - prometheus::default_registry().register(Box::new(REQUEST_STATUS_CODES.clone()))?; - prometheus::default_registry().register(Box::new(CONCURRENT_REQUESTS.clone()))?; - prometheus::default_registry().register(Box::new(REQUEST_DURATION_SECONDS.clone()))?; - - // --- NEW: Configure reqwest::Client for HTTPS and TLS verification --- - let skip_tls_verify_str = env::var("SKIP_TLS_VERIFY").unwrap_or_else(|_| "false".to_string()); - let skip_tls_verify = skip_tls_verify_str.to_lowercase() == "true"; - - let mut client_builder = reqwest::Client::builder(); - - // --- NEW: DNS Override Configuration --- - // Reads RESOLVE_TARGET_ADDR="hostname:ip_address:port" - // Example: "example.com:192.168.1.10:8080" - // This means any request to "example.com" (regardless of port in URL) - // will be directed to 192.168.1.10:8080. - if let Ok(resolve_str) = env::var("RESOLVE_TARGET_ADDR") { - if !resolve_str.is_empty() { - println!("Attempting to apply DNS override from RESOLVE_TARGET_ADDR: {}", resolve_str); - let parts: Vec<&str> = resolve_str.split(':').collect(); - if parts.len() == 3 { - let hostname_to_override = parts[0].trim(); - let ip_to_resolve_to = parts[1].trim(); - let port_to_connect_to_str = parts[2].trim(); - - if hostname_to_override.is_empty() { - return Err("RESOLVE_TARGET_ADDR: hostname part cannot be empty. Format: 'hostname:ip:port'".into()); - } - if ip_to_resolve_to.is_empty() { - return Err("RESOLVE_TARGET_ADDR: IP address part cannot be empty. Format: 'hostname:ip:port'".into()); - } - if port_to_connect_to_str.is_empty() { - return Err("RESOLVE_TARGET_ADDR: port part cannot be empty. Format: 'hostname:ip:port'".into()); - } - - match port_to_connect_to_str.parse::() { - Ok(port_to_connect_to) => { - let socket_addr_str = format!("{}:{}", ip_to_resolve_to, port_to_connect_to); - match socket_addr_str.parse::() { - Ok(socket_addr) => { - client_builder = client_builder.resolve(hostname_to_override, socket_addr); - println!("Successfully configured DNS override: '{}' will resolve to {}", hostname_to_override, socket_addr); - } - Err(e) => { - return Err(format!("Failed to parse IP/Port '{}' into SocketAddr for RESOLVE_TARGET_ADDR: {}. Ensure IP and port are valid. Format: 'hostname:ip:port'", socket_addr_str, e).into()); - } - } - } - Err(e) => { - return Err(format!("Failed to parse port '{}' in RESOLVE_TARGET_ADDR: {}. Must be a valid u16. Format: 'hostname:ip:port'", port_to_connect_to_str, e).into()); - } - } - } else { - // RESOLVE_TARGET_ADDR is set and not empty, but format is wrong. - return Err(format!("RESOLVE_TARGET_ADDR environment variable ('{}') is not in the expected format 'hostname:ip:port'", resolve_str).into()); - } - } else { - // RESOLVE_TARGET_ADDR is set but empty. - println!("RESOLVE_TARGET_ADDR is set but empty, no DNS override will be applied."); - } - // If RESOLVE_TARGET_ADDR is not set at all, env::var("RESOLVE_TARGET_ADDR") returns Err, - // and this whole 'if let' block is skipped, which is the correct behavior (no override). - } - // --- END NEW: DNS Override Configuration --- - - // --- mTLS Configuration --- - let client_cert_path_env = env::var("CLIENT_CERT_PATH").ok(); - let client_key_path_env = env::var("CLIENT_KEY_PATH").ok(); - - if let (Some(cert_path), Some(key_path)) = (client_cert_path_env.as_ref(), client_key_path_env.as_ref()) { - println!("Attempting to load mTLS certificate from: {}", cert_path); - println!("Attempting to load mTLS private key from: {}", key_path); - - let mut cert_file = File::open(cert_path) - .map_err(|e| format!("Failed to open client certificate file '{}': {}", cert_path, e))?; - let mut cert_pem_buf = Vec::new(); - cert_file.read_to_end(&mut cert_pem_buf) - .map_err(|e| format!("Failed to read client certificate file '{}': {}", cert_path, e))?; + // Register Prometheus metrics + register_metrics()?; - let mut key_file = File::open(key_path) - .map_err(|e| format!("Failed to open client key file '{}': {}", key_path, e))?; - let mut key_pem_buf = Vec::new(); - key_file.read_to_end(&mut key_pem_buf) - .map_err(|e| format!("Failed to read client key file '{}': {}", key_path, e))?; + // Load configuration from environment variables + let config = Config::from_env()?; - // Validate certificate PEM - let mut cert_pem_cursor = std::io::Cursor::new(cert_pem_buf.as_slice()); - let certs_result: Vec<_> = rustls_pemfile::certs(&mut cert_pem_cursor).collect(); - if certs_result.is_empty() { - return Err(format!("No PEM certificates found in {}", cert_path).into()); - } - for cert in certs_result { - if let Err(e) = cert { - return Err(format!("Failed to parse PEM certificates from '{}': {}", cert_path, e).into()); - } - } + // Build HTTP client with TLS and header configuration + let client_config = config.to_client_config(); + let client_result = build_client(&client_config)?; + let client = client_result.client; - // Validate private key PEM (must be PKCS#8) - let mut key_pem_cursor = std::io::Cursor::new(key_pem_buf.as_slice()); - let keys_result: Vec<_> = rustls_pemfile::pkcs8_private_keys(&mut key_pem_cursor).collect(); - if keys_result.is_empty() { - return Err(format!("No PKCS#8 private keys found in '{}'. Ensure the file contains a valid PEM-encoded PKCS#8 private key.", key_path).into()); - } - for key in keys_result { - if let Err(e) = key { - return Err(format!("Failed to parse private key from '{}' as PKCS#8: {}. Please ensure the key is PEM-encoded and in PKCS#8 format.", key_path, e).into()); - } - } - - // Combine certificate PEM and key PEM into one buffer for reqwest::Identity. - let mut combined_pem_buf = Vec::new(); - combined_pem_buf.extend_from_slice(&cert_pem_buf); - if !cert_pem_buf.ends_with(b"\n") && !key_pem_buf.starts_with(b"\n") { - combined_pem_buf.push(b'\n'); // Add a newline separator if not present - } - combined_pem_buf.extend_from_slice(&key_pem_buf); - - let identity = reqwest::Identity::from_pem(&combined_pem_buf) - .map_err(|e| format!("Failed to create reqwest::Identity from PEM (cert+key): {}. Ensure the key is PKCS#8 and the certificate is valid.", e))?; - - client_builder = client_builder.identity(identity); - println!("Successfully configured mTLS with client certificate and key."); - - } else if client_cert_path_env.is_some() != client_key_path_env.is_some() { - // Only one of the two paths is set, which is an error - if client_cert_path_env.is_some() { - return Err("CLIENT_CERT_PATH is set, but CLIENT_KEY_PATH is missing for mTLS.".into()); - } else { - return Err("CLIENT_KEY_PATH is set, but CLIENT_CERT_PATH is missing for mTLS.".into()); - } - } - // --- END mTLS Configuration --- - - // --- NEW: Custom Headers Configuration --- - let custom_headers_str = env::var("CUSTOM_HEADERS").unwrap_or_else(|_| "".to_string()); - let mut parsed_headers = HeaderMap::new(); - - if !custom_headers_str.is_empty() { - println!("Attempting to parse CUSTOM_HEADERS: {}", custom_headers_str); - - // Parse headers with support for escaped commas - let header_pairs = parse_headers_with_escapes(&custom_headers_str); - - for header_pair_str in header_pairs { - let header_pair_str_trimmed = header_pair_str.trim(); - if header_pair_str_trimmed.is_empty() { - continue; // Skip empty parts - } - let parts: Vec<&str> = header_pair_str_trimmed.splitn(2, ':').collect(); - if parts.len() == 2 { - let name_str = parts[0].trim(); - let value_str = parts[1].trim(); - - if name_str.is_empty() { - return Err(format!("Invalid header format: Header name cannot be empty in '{}'.", header_pair_str_trimmed).into()); - } - - // Unescape the header value (replace \, with ,) - let unescaped_value = value_str.replace("\\,", ","); - - let header_name = HeaderName::from_str(name_str) - .map_err(|e| format!("Invalid header name: {}. Name: '{}'", e, name_str))?; - let header_value = HeaderValue::from_str(&unescaped_value) - .map_err(|e| format!("Invalid header value for '{}': {}. Value: '{}'", name_str, e, unescaped_value))?; - parsed_headers.insert(header_name, header_value); - } else { - return Err(format!("Invalid header format in CUSTOM_HEADERS: '{}'. Expected 'Name:Value'.", header_pair_str_trimmed).into()); - } - } - } - - // Apply headers to client_builder if any were parsed - if !parsed_headers.is_empty() { - client_builder = client_builder.default_headers(parsed_headers.clone()); // Clone for logging later - println!("Successfully configured custom default headers."); - } - // --- END NEW: Custom Headers Configuration --- - - let client = if skip_tls_verify { - println!("WARNING: Skipping TLS certificate verification."); - client_builder - .danger_accept_invalid_certs(true) - .danger_accept_invalid_hostnames(true) // Often needed with invalid certs - .build()? - } else { - client_builder.build()? - }; - // --- END NEW: Configure reqwest::Client --- - - // --- READ FROM ENVIRONMENT VARIABLES --- - let url = env::var("TARGET_URL") - .expect("TARGET_URL environment variable must be set"); - - // --- NEW: Optionally send JSON payload --- - let send_json = env::var("SEND_JSON").unwrap_or_else(|_| "false".to_string()).to_lowercase() == "true"; - let json_payload = if send_json { - Some(env::var("JSON_PAYLOAD") - .expect("JSON_PAYLOAD environment variable must be set when SEND_JSON=true")) - } else { - None - }; - - let num_concurrent_tasks_str = env::var("NUM_CONCURRENT_TASKS") - .unwrap_or_else(|_| "10".to_string()); - let num_concurrent_tasks: usize = num_concurrent_tasks_str.parse() - .expect("NUM_CONCURRENT_TASKS must be a valid number"); - - let overall_test_duration_str = env::var("TEST_DURATION") - .unwrap_or_else(|_| "2h".to_string()); - let overall_test_duration = parse_duration_string(&overall_test_duration_str) - .expect(&format!("Invalid TEST_DURATION format: '{}'. Use formats like '10m', '5h', '3d'.", overall_test_duration_str)); - - - // --- NEW: Load Model Configuration from Environment Variables --- - let load_model = { - let model_type = env::var("LOAD_MODEL_TYPE") - .unwrap_or_else(|_| "Concurrent".to_string()); // Default to Concurrent - - match model_type.as_str() { - "Concurrent" => LoadModel::Concurrent, - "Rps" => { - let target_rps: f64 = env::var("TARGET_RPS") - .expect("TARGET_RPS must be set for Rps model").parse()?; - LoadModel::Rps { target_rps } - }, - "RampRps" => { - let min_rps: f64 = env::var("MIN_RPS") - .expect("MIN_RPS must be set for RampRps").parse()?; - let max_rps: f64 = env::var("MAX_RPS") - .expect("MAX_RPS must be set for RampRps").parse()?; - let ramp_duration_str = env::var("RAMP_DURATION") // Use RAMP_DURATION for this specific model's ramp - .unwrap_or_else(|_| overall_test_duration_str.clone()).to_string(); // Default to overall test duration - let ramp_duration = parse_duration_string(&ramp_duration_str)?; - LoadModel::RampRps { min_rps, max_rps, ramp_duration } - }, - "DailyTraffic" => { - let min_rps: f64 = env::var("DAILY_MIN_RPS") - .expect("DAILY_MIN_RPS must be set for DailyTraffic model").parse()?; - let mid_rps: f64 = env::var("DAILY_MID_RPS") - .expect("DAILY_MID_RPS must be set for DailyTraffic model").parse()?; - let max_rps: f64 = env::var("DAILY_MAX_RPS") - .expect("DAILY_MAX_RPS must be set for DailyTraffic model").parse()?; - let cycle_duration_str = env::var("DAILY_CYCLE_DURATION") - .expect("DAILY_CYCLE_DURATION must be set for DailyTraffic model"); - let cycle_duration = parse_duration_string(&cycle_duration_str)?; - - // Ratios for DailyTraffic segments (sum should be <= 1.0) - let morning_ramp_ratio: f64 = env::var("MORNING_RAMP_RATIO").unwrap_or_else(|_| "0.125".to_string()).parse()?; - let peak_sustain_ratio: f64 = env::var("PEAK_SUSTAIN_RATIO").unwrap_or_else(|_| "0.167".to_string()).parse()?; - let mid_decline_ratio: f64 = env::var("MID_DECLINE_RATIO").unwrap_or_else(|_| "0.125".to_string()).parse()?; - let mid_sustain_ratio: f64 = env::var("MID_SUSTAIN_RATIO").unwrap_or_else(|_| "0.167".to_string()).parse()?; - let evening_decline_ratio: f64 = env::var("EVENING_DECLINE_RATIO").unwrap_or_else(|_| "0.167".to_string()).parse()?; - - // Basic validation of ratios - let total_ratios = morning_ramp_ratio + peak_sustain_ratio + mid_decline_ratio + mid_sustain_ratio + evening_decline_ratio; - if total_ratios > 1.0 { - eprintln!("Warning: Sum of DailyTraffic segment ratios exceeds 1.0 (Total: {}). Night sustain phase will be negative or very short.", total_ratios); - } - - LoadModel::DailyTraffic { - min_rps, mid_rps, max_rps, cycle_duration, - morning_ramp_ratio, peak_sustain_ratio, mid_decline_ratio, - mid_sustain_ratio, evening_decline_ratio, - } - }, - _ => panic!("Unknown LOAD_MODEL_TYPE: {}", model_type), - } - }; - // --- END NEW: Load Model Configuration --- - - // --- NEW: Optionally change request type --- - let request_type = env::var("REQUEST_TYPE").unwrap_or_else(|_| "POST".to_string()); - - println!("Starting load test:"); - println!(" Target URL: {}", url); - println!(" Request type: {}", request_type); - println!(" Concurrent Tasks: {}", num_concurrent_tasks); - println!(" Overall Test Duration: {:?}", overall_test_duration); - println!(" Load Model: {:?}", load_model); - println!(" Skip TLS Verify: {}", skip_tls_verify); - if env::var("CLIENT_CERT_PATH").is_ok() && env::var("CLIENT_KEY_PATH").is_ok() { - println!(" mTLS Enabled: Yes (using CLIENT_CERT_PATH and CLIENT_KEY_PATH)"); - } else { - println!(" mTLS Enabled: No (CLIENT_CERT_PATH or CLIENT_KEY_PATH not set, or only one was set)"); - } - - if !custom_headers_str.is_empty() { - if !parsed_headers.is_empty() { - println!(" Custom Headers Enabled: Yes"); - for (name, value) in parsed_headers.iter() { - println!(" {}: {}", name, value.to_str().unwrap_or("")); - } - } else { - println!(" Custom Headers Enabled: No (CUSTOM_HEADERS was set but resulted in no valid headers or was empty after parsing)"); - } - } else { - println!(" Custom Headers Enabled: No (CUSTOM_HEADERS not set)"); - } - - - // Start the Prometheus metrics HTTP server in a separate Tokio task - let metrics_port = 9090; // Default Prometheus scrape port - let metrics_addr = ([0, 0, 0, 0], metrics_port).into(); + // Print configuration summary + config.print_summary(&client_result.parsed_headers); + // Start the Prometheus metrics HTTP server + let metrics_port = 9090; let registry_arc = Arc::new(Mutex::new(prometheus::default_registry().clone())); - let serve_metrics = { + { let registry = registry_arc.clone(); - async move { - let make_svc = make_service_fn(move |_conn| { - let registry_clone = registry.clone(); - async move { - Ok::<_, hyper::Error>(service_fn(move |req| { - let registry_clone_inner = registry_clone.clone(); - async move { - metrics_handler(req, registry_clone_inner).await - } - })) - } - }); - - let server = Server::bind(&metrics_addr).serve(make_svc); - println!("Prometheus metrics server listening on http://0.0.0.0:{}", metrics_port); - if let Err(e) = server.await { - eprintln!("Metrics server error: {}", e); - } - } - }; - tokio::spawn(serve_metrics); + tokio::spawn(async move { + start_metrics_server(metrics_port, registry).await; + }); + } // Main loop to run for a duration let start_time = time::Instant::now(); - let _overall_test_duration_secs = overall_test_duration.as_secs_f64(); // Fixed unused variable warnings let mut handles = Vec::new(); - for i in 0..num_concurrent_tasks { + for i in 0..config.num_concurrent_tasks { + let worker_config = WorkerConfig { + task_id: i, + url: config.target_url.clone(), + request_type: config.request_type.clone(), + send_json: config.send_json, + json_payload: config.json_payload.clone(), + test_duration: config.test_duration, + load_model: config.load_model.clone(), + num_concurrent_tasks: config.num_concurrent_tasks, + }; + let client_clone = client.clone(); - let url_clone = url.to_string(); - let overall_test_duration_clone = overall_test_duration.clone(); - let start_time_clone = start_time.clone(); - let load_model_clone = load_model.clone(); - let num_concurrent_tasks_clone = num_concurrent_tasks.clone(); - let send_json_clone = send_json; - let json_payload_clone = json_payload.clone(); - let request_type_clone = request_type.clone(); + let start_time_clone = start_time; let handle = tokio::spawn(async move { - loop { - let elapsed_total_secs = time::Instant::now().duration_since(start_time_clone).as_secs_f64(); - - // Check if the total test duration has passed for this task - if elapsed_total_secs >= overall_test_duration_clone.as_secs_f64() { - println!("Task {} stopping after overall duration limit.", i); - break; // Exit this task's loop - } - - // Calculate current target RPS based on the chosen load model and elapsed time - let current_target_rps = load_model_clone.calculate_current_rps(elapsed_total_secs, overall_test_duration_clone.as_secs_f64()); - - // Calculate delay per task to achieve the current_target_rps - // Handle division by zero or extremely small RPS - let delay_ms = if current_target_rps > 0.0 { - (num_concurrent_tasks_clone as f64 * 1000.0 / current_target_rps).round() as u64 - } else { - u64::MAX // Effectively stop requests for this task if RPS is 0 - }; - - // Add metrics tracking as before - CONCURRENT_REQUESTS.inc(); - REQUEST_TOTAL.inc(); - - let request_start_time = time::Instant::now(); // Start timer - - // --- CHANGED: Support GET request type --- - let req = if request_type_clone == "GET" { - client_clone.get(&url_clone) - } else if request_type_clone == "POST" { - // --- CHANGED: Conditionally send POST with or without JSON --- - let req = client_clone.post(&url_clone); - if send_json_clone { - req.header("Content-Type", "application/json") - .body(json_payload_clone.clone().unwrap()) - } else { - req - } - } else { - eprintln!("Request type {} not currently supported", request_type_clone); - client_clone.get(&url_clone) // fallback to GET - }; - - match req.send().await { - Ok(response) => { - let status = response.status().as_u16().to_string(); - REQUEST_STATUS_CODES.with_label_values(&[&status]).inc(); - // Do not save the JWT token, just drop the response - }, - Err(e) => { - REQUEST_STATUS_CODES.with_label_values(&["error"]).inc(); - eprintln!("Task {}: Request to {} failed: {}", i, url_clone, e); - } - } - REQUEST_DURATION_SECONDS.observe(request_start_time.elapsed().as_secs_f64()); // Observe duration - CONCURRENT_REQUESTS.dec(); - - // Apply the calculated delay - if delay_ms > 0 && delay_ms != u64::MAX { - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } else if delay_ms == u64::MAX { - tokio::time::sleep(Duration::from_secs(3600)).await; // Sleep for a very long time if RPS is 0 - } - // If delay_ms is 0, no sleep, burst as fast as possible. - } + run_worker(client_clone, worker_config, start_time_clone).await; }); handles.push(handle); } // Wait for the total test duration to pass - tokio::time::sleep(overall_test_duration).await; + tokio::time::sleep(config.test_duration).await; println!("Main test duration completed. Signalling tasks to stop."); - // Add a brief pause to allow in-flight metrics to be updated by worker threads - // before we collect and print the final metrics. - // This is a pragmatic approach; for very high precision, a more complex - // synchronization mechanism with worker tasks would be needed. + // Brief pause to allow in-flight metrics to be updated tokio::time::sleep(Duration::from_secs(2)).await; println!("Collecting and printing final metrics..."); // Gather and print final metrics - let final_metrics_output = { - let encoder = TextEncoder::new(); - let metric_families = registry_arc.lock().unwrap().gather(); - let mut buffer = Vec::new(); - encoder.encode(&metric_families, &mut buffer).unwrap(); - String::from_utf8(buffer).unwrap_or_else(|e| { - eprintln!("Error encoding metrics to UTF-8: {}", e); - String::from("# ERROR ENCODING METRICS TO UTF-8") - }) - }; - + let final_metrics_output = gather_metrics_string(®istry_arc); println!("\n--- FINAL METRICS ---\n{}", final_metrics_output); println!("--- END OF FINAL METRICS ---\n"); @@ -695,100 +75,5 @@ async fn main() -> Result<(), Box> { tokio::time::sleep(Duration::from_secs(120)).await; println!("2-minute pause complete. Exiting."); - // The program will exit here, and all spawned tasks will be dropped. Ok(()) } - - -async fn metrics_handler( - _req: Request, - registry: Arc>, -) -> Result, hyper::Error> { - let encoder = TextEncoder::new(); - let metric_families = registry.lock().unwrap().gather(); - let mut buffer = Vec::new(); - encoder.encode(&metric_families, &mut buffer).unwrap(); - - let response = Response::builder() - .status(200) - .header("Content-Type", encoder.format_type()) - .body(Body::from(buffer)) - .unwrap(); - - Ok(response) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_headers_simple() { - let headers_str = "Content-Type:application/json,Authorization:Bearer token"; - let result = parse_headers_with_escapes(headers_str); - - assert_eq!(result.len(), 2); - assert_eq!(result[0], "Content-Type:application/json"); - assert_eq!(result[1], "Authorization:Bearer token"); - } - - #[test] - fn test_parse_headers_with_escaped_comma() { - let headers_str = "Connection:keep-alive,Keep-Alive:timeout=5\\,max=200"; - let result = parse_headers_with_escapes(headers_str); - - assert_eq!(result.len(), 2); - assert_eq!(result[0], "Connection:keep-alive"); - assert_eq!(result[1], "Keep-Alive:timeout=5,max=200"); - } - - #[test] - fn test_parse_headers_multiple_escaped_commas() { - let headers_str = "Accept:text/html\\,application/xml\\,application/json,User-Agent:Mozilla/5.0"; - let result = parse_headers_with_escapes(headers_str); - - assert_eq!(result.len(), 2); - assert_eq!(result[0], "Accept:text/html,application/xml,application/json"); - assert_eq!(result[1], "User-Agent:Mozilla/5.0"); - } - - #[test] - fn test_parse_headers_backslash_not_before_comma() { - let headers_str = "Path:C:\\Users\\test,Host:example.com"; - let result = parse_headers_with_escapes(headers_str); - - assert_eq!(result.len(), 2); - assert_eq!(result[0], "Path:C:\\Users\\test"); - assert_eq!(result[1], "Host:example.com"); - } - - #[test] - fn test_parse_headers_empty_and_whitespace() { - let headers_str = " Header1:value1 , , Header2:value2 "; - let result = parse_headers_with_escapes(headers_str); - - assert_eq!(result.len(), 2); - assert_eq!(result[0], " Header1:value1 "); - assert_eq!(result[1], " Header2:value2 "); - } - - #[test] - fn test_parse_headers_trailing_comma() { - let headers_str = "Header1:value1,Header2:value2,"; - let result = parse_headers_with_escapes(headers_str); - - assert_eq!(result.len(), 2); - assert_eq!(result[0], "Header1:value1"); - assert_eq!(result[1], "Header2:value2"); - } - - #[test] - fn test_parse_headers_complex_keep_alive() { - let headers_str = "Connection:keep-alive\\,close,Keep-Alive:timeout=5\\,max=1000\\,custom=value"; - let result = parse_headers_with_escapes(headers_str); - - assert_eq!(result.len(), 2); - assert_eq!(result[0], "Connection:keep-alive,close"); - assert_eq!(result[1], "Keep-Alive:timeout=5,max=1000,custom=value"); - } -} diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..f577806 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,102 @@ +use hyper::{Body, Request, Response, Server}; +use hyper::service::{make_service_fn, service_fn}; +use prometheus::{Encoder, Gauge, Histogram, IntCounter, IntCounterVec, Opts, Registry, TextEncoder}; +use std::env; +use std::sync::{Arc, Mutex}; + +lazy_static::lazy_static! { + pub static ref METRIC_NAMESPACE: String = + env::var("METRIC_NAMESPACE").unwrap_or_else(|_| "rust_loadtest".to_string()); + + pub static ref REQUEST_TOTAL: IntCounter = + IntCounter::with_opts( + Opts::new("requests_total", "Total number of HTTP requests made") + .namespace(METRIC_NAMESPACE.as_str()) + ).unwrap(); + + pub static ref REQUEST_STATUS_CODES: IntCounterVec = + IntCounterVec::new( + Opts::new("requests_status_codes_total", "Number of HTTP requests by status code") + .namespace(METRIC_NAMESPACE.as_str()), + &["status_code"] + ).unwrap(); + + pub static ref CONCURRENT_REQUESTS: Gauge = + Gauge::with_opts( + Opts::new("concurrent_requests", "Number of HTTP requests currently in flight") + .namespace(METRIC_NAMESPACE.as_str()) + ).unwrap(); + + pub static ref REQUEST_DURATION_SECONDS: Histogram = + Histogram::with_opts( + prometheus::HistogramOpts::new( + "request_duration_seconds", + "HTTP request latencies in seconds." + ).namespace(METRIC_NAMESPACE.as_str()) + ).unwrap(); +} + +/// Registers all metrics with the default Prometheus registry. +pub fn register_metrics() -> Result<(), Box> { + prometheus::default_registry().register(Box::new(REQUEST_TOTAL.clone()))?; + prometheus::default_registry().register(Box::new(REQUEST_STATUS_CODES.clone()))?; + prometheus::default_registry().register(Box::new(CONCURRENT_REQUESTS.clone()))?; + prometheus::default_registry().register(Box::new(REQUEST_DURATION_SECONDS.clone()))?; + Ok(()) +} + +/// HTTP handler for the Prometheus metrics endpoint. +pub async fn metrics_handler( + _req: Request, + registry: Arc>, +) -> Result, hyper::Error> { + let encoder = TextEncoder::new(); + let metric_families = registry.lock().unwrap().gather(); + let mut buffer = Vec::new(); + encoder.encode(&metric_families, &mut buffer).unwrap(); + + let response = Response::builder() + .status(200) + .header("Content-Type", encoder.format_type()) + .body(Body::from(buffer)) + .unwrap(); + + Ok(response) +} + +/// Starts the Prometheus metrics HTTP server. +pub async fn start_metrics_server(port: u16, registry: Arc>) { + let addr = ([0, 0, 0, 0], port).into(); + + let make_svc = make_service_fn(move |_conn| { + let registry_clone = registry.clone(); + async move { + Ok::<_, hyper::Error>(service_fn(move |req| { + let registry_clone_inner = registry_clone.clone(); + async move { metrics_handler(req, registry_clone_inner).await } + })) + } + }); + + let server = Server::bind(&addr).serve(make_svc); + println!( + "Prometheus metrics server listening on http://0.0.0.0:{}", + port + ); + + if let Err(e) = server.await { + eprintln!("Metrics server error: {}", e); + } +} + +/// Gathers and encodes metrics as a string for final output. +pub fn gather_metrics_string(registry: &Arc>) -> String { + let encoder = TextEncoder::new(); + let metric_families = registry.lock().unwrap().gather(); + let mut buffer = Vec::new(); + encoder.encode(&metric_families, &mut buffer).unwrap(); + String::from_utf8(buffer).unwrap_or_else(|e| { + eprintln!("Error encoding metrics to UTF-8: {}", e); + String::from("# ERROR ENCODING METRICS TO UTF-8") + }) +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..1173a2e --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,157 @@ +use std::str::FromStr; +use tokio::time::Duration; + +/// Parses a duration string in the format "10m", "5h", "3d". +/// +/// Supported units: +/// - `m` for minutes +/// - `h` for hours +/// - `d` for days +pub fn parse_duration_string(s: &str) -> Result { + let s = s.trim(); + + if s.is_empty() { + return Err("Duration string cannot be empty".to_string()); + } + + let unit_char = s.chars().last().unwrap(); + let value_str = &s[0..s.len() - 1]; + + let value = match u64::from_str(value_str) { + Ok(v) => v, + Err(_) => return Err(format!("Invalid numeric value in duration: '{}'", value_str)), + }; + + match unit_char { + 'm' => Ok(Duration::from_secs(value * 60)), + 'h' => Ok(Duration::from_secs(value * 60 * 60)), + 'd' => Ok(Duration::from_secs(value * 24 * 60 * 60)), + _ => Err(format!( + "Unknown duration unit: '{}'. Use 'm', 'h', or 'd'.", + unit_char + )), + } +} + +/// Parses a comma-separated header string with support for escaped commas. +/// +/// Use `\,` to include a literal comma in a header value. +/// Example: "Connection:keep-alive,Keep-Alive:timeout=5\,max=200" +pub fn parse_headers_with_escapes(headers_str: &str) -> Vec { + let mut headers = Vec::new(); + let mut current_header = String::new(); + let mut chars = headers_str.chars().peekable(); + + while let Some(ch) = chars.next() { + match ch { + '\\' => { + // Check if the next character is a comma + if chars.peek() == Some(&',') { + // This is an escaped comma, add it to the current header + current_header.push(','); + chars.next(); // Consume the comma + } else { + // Not escaping a comma, keep the backslash + current_header.push('\\'); + } + } + ',' => { + // This is a header separator + if !current_header.trim().is_empty() { + headers.push(current_header.clone()); + } + current_header.clear(); + } + _ => { + current_header.push(ch); + } + } + } + + // Don't forget the last header + if !current_header.trim().is_empty() { + headers.push(current_header); + } + + headers +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_headers_simple() { + let headers_str = "Content-Type:application/json,Authorization:Bearer token"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Content-Type:application/json"); + assert_eq!(result[1], "Authorization:Bearer token"); + } + + #[test] + fn test_parse_headers_with_escaped_comma() { + let headers_str = "Connection:keep-alive,Keep-Alive:timeout=5\\,max=200"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Connection:keep-alive"); + assert_eq!(result[1], "Keep-Alive:timeout=5,max=200"); + } + + #[test] + fn test_parse_headers_multiple_escaped_commas() { + let headers_str = + "Accept:text/html\\,application/xml\\,application/json,User-Agent:Mozilla/5.0"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + "Accept:text/html,application/xml,application/json" + ); + assert_eq!(result[1], "User-Agent:Mozilla/5.0"); + } + + #[test] + fn test_parse_headers_backslash_not_before_comma() { + let headers_str = "Path:C:\\Users\\test,Host:example.com"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Path:C:\\Users\\test"); + assert_eq!(result[1], "Host:example.com"); + } + + #[test] + fn test_parse_headers_empty_and_whitespace() { + let headers_str = " Header1:value1 , , Header2:value2 "; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], " Header1:value1 "); + assert_eq!(result[1], " Header2:value2 "); + } + + #[test] + fn test_parse_headers_trailing_comma() { + let headers_str = "Header1:value1,Header2:value2,"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Header1:value1"); + assert_eq!(result[1], "Header2:value2"); + } + + #[test] + fn test_parse_headers_complex_keep_alive() { + let headers_str = + "Connection:keep-alive\\,close,Keep-Alive:timeout=5\\,max=1000\\,custom=value"; + let result = parse_headers_with_escapes(headers_str); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "Connection:keep-alive,close"); + assert_eq!(result[1], "Keep-Alive:timeout=5,max=1000,custom=value"); + } +} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..b7a1f5a --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,101 @@ +use tokio::time::{self, Duration, Instant}; + +use crate::load_models::LoadModel; +use crate::metrics::{CONCURRENT_REQUESTS, REQUEST_DURATION_SECONDS, REQUEST_STATUS_CODES, REQUEST_TOTAL}; + +/// Configuration for a worker task. +pub struct WorkerConfig { + pub task_id: usize, + pub url: String, + pub request_type: String, + pub send_json: bool, + pub json_payload: Option, + pub test_duration: Duration, + pub load_model: LoadModel, + pub num_concurrent_tasks: usize, +} + +/// Runs a single worker task that sends HTTP requests according to the load model. +pub async fn run_worker(client: reqwest::Client, config: WorkerConfig, start_time: Instant) { + loop { + let elapsed_total_secs = Instant::now().duration_since(start_time).as_secs_f64(); + + // Check if the total test duration has passed + if elapsed_total_secs >= config.test_duration.as_secs_f64() { + println!( + "Task {} stopping after overall duration limit.", + config.task_id + ); + break; + } + + // Calculate current target RPS + let current_target_rps = config + .load_model + .calculate_current_rps(elapsed_total_secs, config.test_duration.as_secs_f64()); + + // Calculate delay per task to achieve the current_target_rps + let delay_ms = if current_target_rps > 0.0 { + (config.num_concurrent_tasks as f64 * 1000.0 / current_target_rps).round() as u64 + } else { + u64::MAX + }; + + // Track metrics + CONCURRENT_REQUESTS.inc(); + REQUEST_TOTAL.inc(); + + let request_start_time = time::Instant::now(); + + // Build and send request + let req = build_request(&client, &config); + + match req.send().await { + Ok(response) => { + let status = response.status().as_u16().to_string(); + REQUEST_STATUS_CODES.with_label_values(&[&status]).inc(); + } + Err(e) => { + REQUEST_STATUS_CODES.with_label_values(&["error"]).inc(); + eprintln!( + "Task {}: Request to {} failed: {}", + config.task_id, config.url, e + ); + } + } + + REQUEST_DURATION_SECONDS.observe(request_start_time.elapsed().as_secs_f64()); + CONCURRENT_REQUESTS.dec(); + + // Apply the calculated delay + if delay_ms > 0 && delay_ms != u64::MAX { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } else if delay_ms == u64::MAX { + // Sleep for a very long time if RPS is 0 + tokio::time::sleep(Duration::from_secs(3600)).await; + } + // If delay_ms is 0, no sleep, burst as fast as possible. + } +} + +fn build_request(client: &reqwest::Client, config: &WorkerConfig) -> reqwest::RequestBuilder { + match config.request_type.as_str() { + "GET" => client.get(&config.url), + "POST" => { + let req = client.post(&config.url); + if config.send_json { + req.header("Content-Type", "application/json") + .body(config.json_payload.clone().unwrap_or_default()) + } else { + req + } + } + _ => { + eprintln!( + "Request type {} not currently supported, falling back to GET", + config.request_type + ); + client.get(&config.url) + } + } +} From 655f99539707d8f6ed466f32e49157d04fb03152 Mon Sep 17 00:00:00 2001 From: cbaugus Date: Mon, 9 Feb 2026 13:04:24 -0600 Subject: [PATCH 2/7] Add unit tests for LoadModel, parse_duration_string, and config parsing - LoadModel: 22 tests covering all 4 variants (Concurrent, Rps, RampRps, DailyTraffic) including phase transitions, boundary values, cycle wrapping, and edge cases - parse_duration_string: 15 tests covering valid formats (m/h/d), whitespace trimming, and error cases (empty, invalid suffix, fractional, negative, non-numeric) - Config: 16 tests covering defaults, all load model types, optional fields, JSON payload, and panic on missing required vars Total: 62 tests passing (up from 7) Closes #16, Closes #17, Closes #18 Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 344 ++++++++++++++++++++++++++++++++++++++++++++- src/load_models.rs | 308 ++++++++++++++++++++++++++++++++++++++++ src/utils.rs | 118 ++++++++++++++++ 3 files changed, 769 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index d4e9d90..ac5ceda 100644 --- a/src/config.rs +++ b/src/config.rs @@ -183,7 +183,7 @@ impl Config { } } - /// Prints the configuration summary. + /// Prints the configuration summary to stdout. pub fn print_summary(&self, parsed_headers: &reqwest::header::HeaderMap) { println!("Starting load test:"); println!(" Target URL: {}", self.target_url); @@ -217,3 +217,345 @@ impl Config { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Mutex to serialize tests that modify environment variables, + // since Rust runs tests in parallel within the same process. + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + // Helper to clear all load-test-related env vars before each test + fn clear_env_vars() { + let vars = [ + "TARGET_URL", + "REQUEST_TYPE", + "SEND_JSON", + "JSON_PAYLOAD", + "NUM_CONCURRENT_TASKS", + "TEST_DURATION", + "LOAD_MODEL_TYPE", + "TARGET_RPS", + "MIN_RPS", + "MAX_RPS", + "RAMP_DURATION", + "DAILY_MIN_RPS", + "DAILY_MID_RPS", + "DAILY_MAX_RPS", + "DAILY_CYCLE_DURATION", + "MORNING_RAMP_RATIO", + "PEAK_SUSTAIN_RATIO", + "MID_DECLINE_RATIO", + "MID_SUSTAIN_RATIO", + "EVENING_DECLINE_RATIO", + "SKIP_TLS_VERIFY", + "RESOLVE_TARGET_ADDR", + "CLIENT_CERT_PATH", + "CLIENT_KEY_PATH", + "CUSTOM_HEADERS", + ]; + for var in vars { + env::remove_var(var); + } + } + + #[test] + fn defaults_with_minimal_config() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + + let config = Config::from_env().unwrap(); + assert_eq!(config.target_url, "https://example.com"); + assert_eq!(config.request_type, "POST"); + assert!(!config.send_json); + assert!(config.json_payload.is_none()); + assert_eq!(config.num_concurrent_tasks, 10); + assert_eq!(config.test_duration, Duration::from_secs(7200)); // 2h default + assert!(!config.skip_tls_verify); + assert!(config.resolve_target_addr.is_none()); + assert!(config.client_cert_path.is_none()); + assert!(config.client_key_path.is_none()); + assert!(config.custom_headers.is_none()); + + clear_env_vars(); + } + + #[test] + fn concurrent_model_is_default() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + + let config = Config::from_env().unwrap(); + assert!( + matches!(config.load_model, LoadModel::Concurrent), + "expected Concurrent, got {:?}", + config.load_model + ); + + clear_env_vars(); + } + + #[test] + fn rps_model_parsed() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("LOAD_MODEL_TYPE", "Rps"); + env::set_var("TARGET_RPS", "500.0"); + + let config = Config::from_env().unwrap(); + match config.load_model { + LoadModel::Rps { target_rps } => { + assert!((target_rps - 500.0).abs() < 0.001); + } + other => panic!("expected Rps, got {:?}", other), + } + + clear_env_vars(); + } + + #[test] + fn ramp_rps_model_parsed() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("LOAD_MODEL_TYPE", "RampRps"); + env::set_var("MIN_RPS", "10.0"); + env::set_var("MAX_RPS", "1000.0"); + env::set_var("RAMP_DURATION", "1h"); + + let config = Config::from_env().unwrap(); + match config.load_model { + LoadModel::RampRps { + min_rps, + max_rps, + ramp_duration, + } => { + assert!((min_rps - 10.0).abs() < 0.001); + assert!((max_rps - 1000.0).abs() < 0.001); + assert_eq!(ramp_duration, Duration::from_secs(3600)); + } + other => panic!("expected RampRps, got {:?}", other), + } + + clear_env_vars(); + } + + #[test] + fn ramp_rps_defaults_duration_to_test_duration() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("LOAD_MODEL_TYPE", "RampRps"); + env::set_var("MIN_RPS", "10.0"); + env::set_var("MAX_RPS", "100.0"); + env::set_var("TEST_DURATION", "30m"); + // RAMP_DURATION not set, should default to TEST_DURATION + + let config = Config::from_env().unwrap(); + match config.load_model { + LoadModel::RampRps { ramp_duration, .. } => { + assert_eq!(ramp_duration, Duration::from_secs(1800)); + } + other => panic!("expected RampRps, got {:?}", other), + } + + clear_env_vars(); + } + + #[test] + fn daily_traffic_model_parsed() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("LOAD_MODEL_TYPE", "DailyTraffic"); + env::set_var("DAILY_MIN_RPS", "10.0"); + env::set_var("DAILY_MID_RPS", "50.0"); + env::set_var("DAILY_MAX_RPS", "100.0"); + env::set_var("DAILY_CYCLE_DURATION", "1d"); + + let config = Config::from_env().unwrap(); + match config.load_model { + LoadModel::DailyTraffic { + min_rps, + mid_rps, + max_rps, + cycle_duration, + .. + } => { + assert!((min_rps - 10.0).abs() < 0.001); + assert!((mid_rps - 50.0).abs() < 0.001); + assert!((max_rps - 100.0).abs() < 0.001); + assert_eq!(cycle_duration, Duration::from_secs(86400)); + } + other => panic!("expected DailyTraffic, got {:?}", other), + } + + clear_env_vars(); + } + + #[test] + fn custom_request_type() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("REQUEST_TYPE", "GET"); + + let config = Config::from_env().unwrap(); + assert_eq!(config.request_type, "GET"); + + clear_env_vars(); + } + + #[test] + fn send_json_with_payload() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("SEND_JSON", "true"); + env::set_var("JSON_PAYLOAD", r#"{"key":"value"}"#); + + let config = Config::from_env().unwrap(); + assert!(config.send_json); + assert_eq!(config.json_payload.unwrap(), r#"{"key":"value"}"#); + + clear_env_vars(); + } + + #[test] + fn custom_concurrent_tasks() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("NUM_CONCURRENT_TASKS", "50"); + + let config = Config::from_env().unwrap(); + assert_eq!(config.num_concurrent_tasks, 50); + + clear_env_vars(); + } + + #[test] + fn custom_test_duration() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("TEST_DURATION", "30m"); + + let config = Config::from_env().unwrap(); + assert_eq!(config.test_duration, Duration::from_secs(1800)); + + clear_env_vars(); + } + + #[test] + fn skip_tls_verify_true() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("SKIP_TLS_VERIFY", "true"); + + let config = Config::from_env().unwrap(); + assert!(config.skip_tls_verify); + + clear_env_vars(); + } + + #[test] + fn optional_fields_populated() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("RESOLVE_TARGET_ADDR", "example.com:1.2.3.4:443"); + env::set_var("CLIENT_CERT_PATH", "/path/to/cert.pem"); + env::set_var("CLIENT_KEY_PATH", "/path/to/key.pem"); + env::set_var("CUSTOM_HEADERS", "Authorization:Bearer token"); + + let config = Config::from_env().unwrap(); + assert_eq!( + config.resolve_target_addr.unwrap(), + "example.com:1.2.3.4:443" + ); + assert_eq!(config.client_cert_path.unwrap(), "/path/to/cert.pem"); + assert_eq!(config.client_key_path.unwrap(), "/path/to/key.pem"); + assert_eq!(config.custom_headers.unwrap(), "Authorization:Bearer token"); + + clear_env_vars(); + } + + #[test] + fn to_client_config_maps_fields() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("SKIP_TLS_VERIFY", "true"); + env::set_var("RESOLVE_TARGET_ADDR", "host:1.2.3.4:443"); + + let config = Config::from_env().unwrap(); + let client_config = config.to_client_config(); + + assert!(client_config.skip_tls_verify); + assert_eq!( + client_config.resolve_target_addr.unwrap(), + "host:1.2.3.4:443" + ); + assert!(client_config.client_cert_path.is_none()); + assert!(client_config.client_key_path.is_none()); + + clear_env_vars(); + } + + #[test] + #[should_panic(expected = "TARGET_URL")] + fn missing_target_url_panics() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + // TARGET_URL not set + let _ = Config::from_env(); + clear_env_vars(); + } + + #[test] + #[should_panic(expected = "Unknown LOAD_MODEL_TYPE")] + fn unknown_load_model_panics() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("LOAD_MODEL_TYPE", "InvalidModel"); + + let _ = Config::from_env(); + clear_env_vars(); + } + + #[test] + #[should_panic(expected = "JSON_PAYLOAD")] + fn send_json_without_payload_panics() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("SEND_JSON", "true"); + // JSON_PAYLOAD not set + + let _ = Config::from_env(); + clear_env_vars(); + } +} diff --git a/src/load_models.rs b/src/load_models.rs index 9ac5a0f..9beecf7 100644 --- a/src/load_models.rs +++ b/src/load_models.rs @@ -179,3 +179,311 @@ impl LoadModel { from + (to - from) * (elapsed / duration) } } + +#[cfg(test)] +mod tests { + use super::*; + + const EPSILON: f64 = 0.001; + + fn assert_approx(actual: f64, expected: f64, msg: &str) { + assert!( + (actual - expected).abs() < EPSILON, + "{}: expected {}, got {}", + msg, + expected, + actual + ); + } + + // --- Concurrent model tests --- + + mod concurrent { + use super::*; + + #[test] + fn returns_f64_max() { + let model = LoadModel::Concurrent; + assert_eq!(model.calculate_current_rps(0.0, 100.0), f64::MAX); + } + + #[test] + fn returns_f64_max_regardless_of_elapsed_time() { + let model = LoadModel::Concurrent; + assert_eq!(model.calculate_current_rps(500.0, 1000.0), f64::MAX); + assert_eq!(model.calculate_current_rps(999.0, 1000.0), f64::MAX); + } + } + + // --- Rps model tests --- + + mod rps { + use super::*; + + #[test] + fn returns_constant_target_rps() { + let model = LoadModel::Rps { target_rps: 100.0 }; + assert_approx(model.calculate_current_rps(0.0, 60.0), 100.0, "at start"); + assert_approx(model.calculate_current_rps(30.0, 60.0), 100.0, "midway"); + assert_approx(model.calculate_current_rps(59.0, 60.0), 100.0, "near end"); + } + + #[test] + fn works_with_fractional_rps() { + let model = LoadModel::Rps { target_rps: 0.5 }; + assert_approx(model.calculate_current_rps(10.0, 60.0), 0.5, "fractional"); + } + + #[test] + fn works_with_high_rps() { + let model = LoadModel::Rps { + target_rps: 100000.0, + }; + assert_approx( + model.calculate_current_rps(10.0, 60.0), + 100000.0, + "high rps", + ); + } + } + + // --- RampRps model tests --- + + mod ramp_rps { + use super::*; + + fn make_model(min: f64, max: f64, secs: u64) -> LoadModel { + LoadModel::RampRps { + min_rps: min, + max_rps: max, + ramp_duration: Duration::from_secs(secs), + } + } + + #[test] + fn returns_min_at_start() { + let model = make_model(10.0, 100.0, 90); + assert_approx(model.calculate_current_rps(0.0, 90.0), 10.0, "at start"); + } + + #[test] + fn midpoint_of_ramp_up() { + // Duration 90s, first 1/3 = 30s. At 15s (midpoint of ramp-up): + // min + (max - min) * (15/30) = 10 + 90 * 0.5 = 55 + let model = make_model(10.0, 100.0, 90); + assert_approx( + model.calculate_current_rps(15.0, 90.0), + 55.0, + "midpoint ramp up", + ); + } + + #[test] + fn returns_max_at_end_of_ramp_up() { + // At 30s (end of first 1/3) + let model = make_model(10.0, 100.0, 90); + assert_approx( + model.calculate_current_rps(30.0, 90.0), + 100.0, + "end of ramp up", + ); + } + + #[test] + fn returns_max_during_sustain_phase() { + // Middle 1/3 is 30-60s + let model = make_model(10.0, 100.0, 90); + assert_approx( + model.calculate_current_rps(45.0, 90.0), + 100.0, + "mid sustain", + ); + } + + #[test] + fn midpoint_of_ramp_down() { + // Last 1/3 starts at 60s, ends at 90s. At 75s (midpoint): + // max - (max - min) * (15/30) = 100 - 90 * 0.5 = 55 + let model = make_model(10.0, 100.0, 90); + assert_approx( + model.calculate_current_rps(75.0, 90.0), + 55.0, + "midpoint ramp down", + ); + } + + #[test] + fn returns_min_at_end_of_ramp_down() { + let model = make_model(10.0, 100.0, 90); + assert_approx( + model.calculate_current_rps(90.0, 90.0), + 10.0, + "end of ramp down", + ); + } + + #[test] + fn does_not_go_below_min() { + // Past the ramp duration + let model = make_model(10.0, 100.0, 90); + let rps = model.calculate_current_rps(100.0, 100.0); + assert!(rps >= 10.0, "should not go below min, got {}", rps); + } + + #[test] + fn equal_min_max_returns_constant() { + let model = make_model(50.0, 50.0, 90); + assert_approx(model.calculate_current_rps(0.0, 90.0), 50.0, "at start"); + assert_approx(model.calculate_current_rps(45.0, 90.0), 50.0, "midway"); + assert_approx(model.calculate_current_rps(90.0, 90.0), 50.0, "at end"); + } + + #[test] + fn zero_duration_returns_max() { + let model = make_model(10.0, 100.0, 0); + assert_approx( + model.calculate_current_rps(0.0, 0.0), + 100.0, + "zero duration", + ); + } + } + + // --- DailyTraffic model tests --- + + mod daily_traffic { + use super::*; + + // Build a DailyTraffic model with a 1000s cycle for easy math. + // Ratios: morning_ramp=0.2, peak_sustain=0.1, mid_decline=0.2, + // mid_sustain=0.1, evening_decline=0.2 + // Night sustain is the remaining 0.2 + fn make_model() -> LoadModel { + LoadModel::DailyTraffic { + min_rps: 10.0, + mid_rps: 50.0, + max_rps: 100.0, + cycle_duration: Duration::from_secs(1000), + morning_ramp_ratio: 0.2, + peak_sustain_ratio: 0.1, + mid_decline_ratio: 0.2, + mid_sustain_ratio: 0.1, + evening_decline_ratio: 0.2, + } + } + + // Phase boundaries for the model above (1000s cycle): + // Phase 1: Morning ramp 0-200s (min 10 -> max 100) + // Phase 2: Peak sustain 200-300s (max 100) + // Phase 3: Mid decline 300-500s (max 100 -> mid 50) + // Phase 4: Mid sustain 500-600s (mid 50) + // Phase 5: Evening decline 600-800s (mid 50 -> min 10) + // Phase 6: Night sustain 800-1000s (min 10) + + #[test] + fn phase1_morning_ramp_start() { + let model = make_model(); + assert_approx( + model.calculate_current_rps(0.0, 1000.0), + 10.0, + "morning ramp start", + ); + } + + #[test] + fn phase1_morning_ramp_midpoint() { + // At 100s (midpoint of 0-200): 10 + (100-10) * (100/200) = 10 + 45 = 55 + let model = make_model(); + assert_approx( + model.calculate_current_rps(100.0, 1000.0), + 55.0, + "morning ramp midpoint", + ); + } + + #[test] + fn phase2_peak_sustain() { + let model = make_model(); + assert_approx( + model.calculate_current_rps(250.0, 1000.0), + 100.0, + "peak sustain", + ); + } + + #[test] + fn phase3_mid_decline_midpoint() { + // Phase 3: 300-500s. At 400s (midpoint): + // 100 + (50-100) * (100/200) = 100 - 25 = 75 + let model = make_model(); + assert_approx( + model.calculate_current_rps(400.0, 1000.0), + 75.0, + "mid decline midpoint", + ); + } + + #[test] + fn phase4_mid_sustain() { + let model = make_model(); + assert_approx( + model.calculate_current_rps(550.0, 1000.0), + 50.0, + "mid sustain", + ); + } + + #[test] + fn phase5_evening_decline_midpoint() { + // Phase 5: 600-800s. At 700s (midpoint): + // 50 + (10-50) * (100/200) = 50 - 20 = 30 + let model = make_model(); + assert_approx( + model.calculate_current_rps(700.0, 1000.0), + 30.0, + "evening decline midpoint", + ); + } + + #[test] + fn phase6_night_sustain() { + let model = make_model(); + assert_approx( + model.calculate_current_rps(900.0, 1000.0), + 10.0, + "night sustain", + ); + } + + #[test] + fn cycle_wraps_correctly() { + // At 1100s = 100s into second cycle = morning ramp midpoint + let model = make_model(); + assert_approx( + model.calculate_current_rps(1100.0, 2000.0), + 55.0, + "second cycle morning ramp midpoint", + ); + } + + #[test] + fn zero_cycle_duration_returns_max() { + let model = LoadModel::DailyTraffic { + min_rps: 10.0, + mid_rps: 50.0, + max_rps: 100.0, + cycle_duration: Duration::from_secs(0), + morning_ramp_ratio: 0.2, + peak_sustain_ratio: 0.1, + mid_decline_ratio: 0.2, + mid_sustain_ratio: 0.1, + evening_decline_ratio: 0.2, + }; + assert_approx( + model.calculate_current_rps(50.0, 100.0), + 100.0, + "zero cycle duration", + ); + } + } +} diff --git a/src/utils.rs b/src/utils.rs index 1173a2e..64e7207 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -80,6 +80,124 @@ pub fn parse_headers_with_escapes(headers_str: &str) -> Vec { mod tests { use super::*; + // --- parse_duration_string tests --- + + mod duration { + use super::*; + + #[test] + fn parse_minutes() { + assert_eq!( + parse_duration_string("10m").unwrap(), + Duration::from_secs(600) + ); + } + + #[test] + fn parse_hours() { + assert_eq!( + parse_duration_string("5h").unwrap(), + Duration::from_secs(18000) + ); + } + + #[test] + fn parse_days() { + assert_eq!( + parse_duration_string("3d").unwrap(), + Duration::from_secs(259200) + ); + } + + #[test] + fn parse_one_minute() { + assert_eq!( + parse_duration_string("1m").unwrap(), + Duration::from_secs(60) + ); + } + + #[test] + fn parse_zero_minutes() { + assert_eq!( + parse_duration_string("0m").unwrap(), + Duration::from_secs(0) + ); + } + + #[test] + fn parse_large_value() { + assert_eq!( + parse_duration_string("365d").unwrap(), + Duration::from_secs(365 * 86400) + ); + } + + #[test] + fn trims_whitespace() { + assert_eq!( + parse_duration_string(" 10m ").unwrap(), + Duration::from_secs(600) + ); + } + + #[test] + fn empty_string_errors() { + let err = parse_duration_string("").unwrap_err(); + assert!(err.contains("empty"), "error was: {}", err); + } + + #[test] + fn whitespace_only_errors() { + let err = parse_duration_string(" ").unwrap_err(); + assert!(err.contains("empty"), "error was: {}", err); + } + + #[test] + fn unknown_suffix_errors() { + let err = parse_duration_string("10x").unwrap_err(); + assert!(err.contains("Unknown duration unit"), "error was: {}", err); + } + + #[test] + fn seconds_suffix_not_supported() { + let err = parse_duration_string("10s").unwrap_err(); + assert!(err.contains("Unknown duration unit"), "error was: {}", err); + } + + #[test] + fn no_suffix_errors() { + let err = parse_duration_string("10").unwrap_err(); + assert!(err.contains("Unknown duration unit"), "error was: {}", err); + } + + #[test] + fn no_number_errors() { + let err = parse_duration_string("m").unwrap_err(); + assert!(err.contains("Invalid numeric"), "error was: {}", err); + } + + #[test] + fn fractional_number_errors() { + let err = parse_duration_string("5.5h").unwrap_err(); + assert!(err.contains("Invalid numeric"), "error was: {}", err); + } + + #[test] + fn negative_number_errors() { + let err = parse_duration_string("-5m").unwrap_err(); + assert!(err.contains("Invalid numeric"), "error was: {}", err); + } + + #[test] + fn letters_as_number_errors() { + let err = parse_duration_string("abcm").unwrap_err(); + assert!(err.contains("Invalid numeric"), "error was: {}", err); + } + } + + // --- parse_headers_with_escapes tests --- + #[test] fn test_parse_headers_simple() { let headers_str = "Content-Type:application/json,Authorization:Bearer token"; From bbab064a3cdcdbfa1115563373d80c3c5fb941e4 Mon Sep 17 00:00:00 2001 From: cbaugus Date: Mon, 9 Feb 2026 14:36:58 -0600 Subject: [PATCH 3/7] Add integration tests with wiremock mock HTTP server - 12 integration tests covering end-to-end worker behavior - Tests GET/POST requests, JSON payloads, status code tracking - Tests metrics recording (requests_total, duration, status codes) - Tests error handling (connection refused, timeouts) - Tests RPS rate limiting with timing validation - Tests slow response handling All tests use wiremock 0.5 for mock HTTP server Total: 74 tests passing (62 unit + 12 integration) Closes #19 Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 7 +- tests/integration_test.rs | 473 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 tests/integration_test.rs diff --git a/Cargo.toml b/Cargo.toml index 3edc99b..f2c30b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,5 @@ serde = { version = "1.0", features = ["derive"] } # For deserializing config if serde_json = "1.0" # For JSON parsing if needed thiserror = "1.0" # For error handling -#rand = "0.8.4" -#base64 = "0.13.0" -#log = "0.4.14" -#env_logger = "0.9.0" -#trust-dns-resolver = "0.20.1" +[dev-dependencies] +wiremock = "0.5" diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..678b317 --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,473 @@ +use std::sync::Once; +use tokio::time::{Duration, Instant}; +use wiremock::matchers::{body_string, header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use rust_loadtest::load_models::LoadModel; +use rust_loadtest::metrics::{ + register_metrics, CONCURRENT_REQUESTS, REQUEST_DURATION_SECONDS, REQUEST_STATUS_CODES, + REQUEST_TOTAL, +}; +use rust_loadtest::worker::{run_worker, WorkerConfig}; + +// Register metrics once across all tests in this file. +// Calling register_metrics() more than once would panic due to duplicate registration. +static INIT_METRICS: Once = Once::new(); + +fn init_metrics() { + INIT_METRICS.call_once(|| { + register_metrics().expect("Failed to register metrics"); + }); +} + +fn get_total_requests() -> u64 { + REQUEST_TOTAL.get() +} + +fn get_status_code_count(code: &str) -> u64 { + REQUEST_STATUS_CODES.with_label_values(&[code]).get() +} + +fn get_duration_count() -> u64 { + REQUEST_DURATION_SECONDS.get_sample_count() +} + +// --- GET request tests --- + +#[tokio::test] +async fn worker_sends_get_requests() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/test")) + .respond_with(ResponseTemplate::new(200)) + .expect(1..) + .mount(&server) + .await; + + let before = get_total_requests(); + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/test", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + let after = get_total_requests(); + assert!( + after > before, + "expected requests to increase, before={} after={}", + before, + after + ); +} + +// --- POST request tests --- + +#[tokio::test] +async fn worker_sends_post_requests() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/api")) + .respond_with(ResponseTemplate::new(201)) + .expect(1..) + .mount(&server) + .await; + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/api", server.uri()), + request_type: "POST".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + // wiremock will verify expectations on drop (at least 1 POST received) +} + +// --- POST with JSON body --- + +#[tokio::test] +async fn worker_sends_json_post_body() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/json")) + .and(header("Content-Type", "application/json")) + .and(body_string(r#"{"key":"value"}"#)) + .respond_with(ResponseTemplate::new(200)) + .expect(1..) + .mount(&server) + .await; + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/json", server.uri()), + request_type: "POST".to_string(), + send_json: true, + json_payload: Some(r#"{"key":"value"}"#.to_string()), + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + // wiremock will verify the JSON body and Content-Type header +} + +// --- Status code tracking --- + +#[tokio::test] +async fn worker_tracks_200_status_codes() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/ok")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let before_200 = get_status_code_count("200"); + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/ok", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + let after_200 = get_status_code_count("200"); + assert!( + after_200 > before_200, + "expected 200 count to increase, before={} after={}", + before_200, + after_200 + ); +} + +#[tokio::test] +async fn worker_tracks_404_status_codes() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/notfound")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let before_404 = get_status_code_count("404"); + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/notfound", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + let after_404 = get_status_code_count("404"); + assert!( + after_404 > before_404, + "expected 404 count to increase, before={} after={}", + before_404, + after_404 + ); +} + +#[tokio::test] +async fn worker_tracks_500_status_codes() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/error")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let before_500 = get_status_code_count("500"); + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/error", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + let after_500 = get_status_code_count("500"); + assert!( + after_500 > before_500, + "expected 500 count to increase, before={} after={}", + before_500, + after_500 + ); +} + +// --- Duration metrics --- + +#[tokio::test] +async fn worker_records_request_duration() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/duration")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let before_count = get_duration_count(); + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/duration", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + let after_count = get_duration_count(); + assert!( + after_count > before_count, + "expected duration sample count to increase, before={} after={}", + before_count, + after_count + ); +} + +// --- Concurrent requests gauge --- + +#[tokio::test] +async fn concurrent_requests_returns_to_zero_after_worker_finishes() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/concurrent")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/concurrent", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + // After worker finishes, concurrent requests gauge should not be negative + let gauge = CONCURRENT_REQUESTS.get(); + assert!( + gauge >= 0.0, + "concurrent requests gauge should not be negative, got {}", + gauge + ); +} + +// --- Connection error handling --- + +#[tokio::test] +async fn worker_handles_connection_error_gracefully() { + init_metrics(); + + // Use a URL that will refuse connections + let before_errors = get_status_code_count("error"); + + let config = WorkerConfig { + task_id: 0, + url: "http://127.0.0.1:1/unreachable".to_string(), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(100)) + .build() + .unwrap(); + run_worker(client, config, Instant::now()).await; + + let after_errors = get_status_code_count("error"); + assert!( + after_errors > before_errors, + "expected error count to increase, before={} after={}", + before_errors, + after_errors + ); +} + +// --- RPS load model --- + +#[tokio::test] +async fn worker_respects_rps_rate_limit() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/rps")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + // Target 5 RPS with 1 worker for 3 seconds = ~15 requests + let config = WorkerConfig { + task_id: 0, + url: format!("{}/rps", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(3), + load_model: LoadModel::Rps { target_rps: 5.0 }, + num_concurrent_tasks: 1, + }; + + let start = Instant::now(); + let client = reqwest::Client::new(); + run_worker(client, config, start).await; + let elapsed = start.elapsed(); + + // Verify worker ran for approximately 3 seconds (rate limiting should prevent it from finishing early) + assert!( + elapsed.as_secs() >= 2 && elapsed.as_secs() <= 5, + "worker should run for ~3s with rate limiting, ran for {:?}", + elapsed + ); +} + +// --- Worker stops after test duration --- + +#[tokio::test] +async fn worker_stops_after_test_duration() { + init_metrics(); + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/timeout")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/timeout", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(2), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let start = Instant::now(); + let client = reqwest::Client::new(); + run_worker(client, config, start).await; + let elapsed = start.elapsed(); + + // Worker should finish within a reasonable time after the 2s duration + assert!( + elapsed.as_secs() <= 5, + "worker should stop near test duration, ran for {:?}", + elapsed + ); +} + +// --- Slow responses --- + +#[tokio::test] +async fn worker_handles_slow_responses() { + init_metrics(); + let server = MockServer::start().await; + + // Response with 500ms delay + Mock::given(method("GET")) + .and(path("/slow")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("ok") + .set_delay(Duration::from_millis(500)), + ) + .mount(&server) + .await; + + let before = get_total_requests(); + + let config = WorkerConfig { + task_id: 0, + url: format!("{}/slow", server.uri()), + request_type: "GET".to_string(), + send_json: false, + json_payload: None, + test_duration: Duration::from_secs(3), + load_model: LoadModel::Concurrent, + num_concurrent_tasks: 1, + }; + + let client = reqwest::Client::new(); + run_worker(client, config, Instant::now()).await; + + let after = get_total_requests(); + assert!( + after > before, + "expected requests even with slow responses, before={} after={}", + before, + after + ); +} From a02a973fcc4440a2846c7fcb3389c2f1cd6fe3e4 Mon Sep 17 00:00:00 2001 From: cbaugus Date: Mon, 9 Feb 2026 14:50:41 -0600 Subject: [PATCH 4/7] Add CI test and lint jobs with cargo fmt - Add lint job: cargo fmt --check, cargo clippy -- -D warnings - Add test job: cargo test --all-features --verbose - Build job now depends on test passing (test depends on lint) - Added caching for cargo registry and build artifacts - Only push Docker images on push events (not PRs) - Format all source code with cargo fmt CI pipeline now enforces: - Code formatting (rustfmt) - Lint rules (clippy with warnings as errors) - All tests passing (74 tests: 62 unit + 12 integration) Closes #20, Closes #21 Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build-cicd.yaml | 84 ++++++- Cargo.lock | 390 +++++++++++++++++++++++++++++- src/client.rs | 39 ++- src/config.rs | 4 +- src/load_models.rs | 4 +- src/metrics.rs | 6 +- src/utils.rs | 12 +- src/worker.rs | 4 +- 8 files changed, 506 insertions(+), 37 deletions(-) diff --git a/.github/workflows/build-cicd.yaml b/.github/workflows/build-cicd.yaml index c1527dc..0873545 100644 --- a/.github/workflows/build-cicd.yaml +++ b/.github/workflows/build-cicd.yaml @@ -1,19 +1,98 @@ -name: build deploy to nomad +name: CI/CD on: push: + branches: ["**"] + pull_request: + branches: [main] -#Build jobs: + # Lint job - runs first to catch formatting/style issues early + lint: + name: Lint (rustfmt & clippy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Check formatting + run: cargo fmt --all --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + # Test job - runs after lint passes + test: + name: Test Suite + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build- + + - name: Run tests + run: cargo test --all-features --verbose + + # Build and push job - only runs after tests pass build: + name: Build and Push Docker Images runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' steps: - name: Checkout uses: actions/checkout@v4 + - name: Set up QEMU uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -96,4 +175,3 @@ jobs: tags: cbaugus/rust_loadtest:${{ steps.docker_meta.outputs.TAG }}-Chainguard provenance: true push: true - diff --git a/Cargo.lock b/Cargo.lock index 9525ed6..484e19f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,53 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "atomic-waker" @@ -8,6 +55,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -54,6 +113,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -70,6 +138,31 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "deadpool" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421fe0f90f2ab22016f32a9881be5134fdd71c65298917084b0c7477cbc3856e" +dependencies = [ + "async-trait", + "deadpool-runtime", + "num_cpus", + "retain_mut", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "displaydoc" version = "0.2.5" @@ -97,6 +190,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -118,6 +226,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -125,6 +248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -133,6 +257,49 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -145,19 +312,41 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -167,7 +356,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -210,6 +399,12 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "0.2.12" @@ -265,6 +460,27 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel", + "base64 0.13.1", + "futures-lite", + "http 0.2.12", + "infer", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "url", +] + [[package]] name = "httparse" version = "1.10.1" @@ -345,7 +561,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -474,6 +690,21 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -558,10 +789,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -574,6 +815,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -603,7 +850,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -702,7 +949,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls 0.23.36", @@ -743,14 +990,37 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", ] [[package]] @@ -760,7 +1030,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", ] [[package]] @@ -772,6 +1051,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -781,13 +1069,42 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "http 1.4.0", @@ -819,6 +1136,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "retain_mut" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4389f1d5789befaf6029ebd9f7dac4af7f7e3d61b69d4f30e2ac02b57e7712b0" + [[package]] name = "ring" version = "0.17.14" @@ -849,6 +1172,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-rustls 0.25.0", + "wiremock", ] [[package]] @@ -1031,6 +1355,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1353,6 +1688,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -1361,6 +1697,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "want" version = "0.3.1" @@ -1370,6 +1712,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1626,6 +1974,28 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wiremock" +version = "0.5.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a3a53eaf34f390dd30d7b1b078287dd05df2aa2e21a589ccb80f5c7253c2e9" +dependencies = [ + "assert-json-diff", + "async-trait", + "base64 0.21.7", + "deadpool", + "futures", + "futures-timer", + "http-types", + "hyper 0.14.32", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/src/client.rs b/src/client.rs index 7359cbe..7c7e8e3 100644 --- a/src/client.rs +++ b/src/client.rs @@ -139,16 +139,25 @@ fn configure_mtls( println!("Attempting to load mTLS certificate from: {}", cert_path); println!("Attempting to load mTLS private key from: {}", key_path); - let mut cert_file = File::open(cert_path) - .map_err(|e| format!("Failed to open client certificate file '{}': {}", cert_path, e))?; + let mut cert_file = File::open(cert_path).map_err(|e| { + format!( + "Failed to open client certificate file '{}': {}", + cert_path, e + ) + })?; let mut cert_pem_buf = Vec::new(); - cert_file.read_to_end(&mut cert_pem_buf) - .map_err(|e| format!("Failed to read client certificate file '{}': {}", cert_path, e))?; + cert_file.read_to_end(&mut cert_pem_buf).map_err(|e| { + format!( + "Failed to read client certificate file '{}': {}", + cert_path, e + ) + })?; let mut key_file = File::open(key_path) .map_err(|e| format!("Failed to open client key file '{}': {}", key_path, e))?; let mut key_pem_buf = Vec::new(); - key_file.read_to_end(&mut key_pem_buf) + key_file + .read_to_end(&mut key_pem_buf) .map_err(|e| format!("Failed to read client key file '{}': {}", key_path, e))?; // Validate certificate PEM @@ -162,13 +171,15 @@ fn configure_mtls( return Err(format!( "Failed to parse PEM certificates from '{}': {}", cert_path, e - ).into()); + ) + .into()); } } // Validate private key PEM (must be PKCS#8) let mut key_pem_cursor = std::io::Cursor::new(key_pem_buf.as_slice()); - let keys_result: Vec<_> = rustls_pemfile::pkcs8_private_keys(&mut key_pem_cursor).collect(); + let keys_result: Vec<_> = + rustls_pemfile::pkcs8_private_keys(&mut key_pem_cursor).collect(); if keys_result.is_empty() { return Err(format!( "No PKCS#8 private keys found in '{}'. Ensure the file contains a valid PEM-encoded PKCS#8 private key.", @@ -240,7 +251,8 @@ fn configure_custom_headers( return Err(format!( "Invalid header format in CUSTOM_HEADERS: '{}'. Expected 'Name:Value'.", header_pair_str_trimmed - ).into()); + ) + .into()); } let name_str = parts[0].trim(); @@ -250,15 +262,20 @@ fn configure_custom_headers( return Err(format!( "Invalid header format: Header name cannot be empty in '{}'.", header_pair_str_trimmed - ).into()); + ) + .into()); } let unescaped_value = value_str.replace("\\,", ","); let header_name = HeaderName::from_str(name_str) .map_err(|e| format!("Invalid header name: {}. Name: '{}'", e, name_str))?; - let header_value = HeaderValue::from_str(&unescaped_value) - .map_err(|e| format!("Invalid header value for '{}': {}. Value: '{}'", name_str, e, unescaped_value))?; + let header_value = HeaderValue::from_str(&unescaped_value).map_err(|e| { + format!( + "Invalid header value for '{}': {}. Value: '{}'", + name_str, e, unescaped_value + ) + })?; parsed_headers.insert(header_name, header_value); } diff --git a/src/config.rs b/src/config.rs index ac5ceda..7de6c3b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -105,8 +105,8 @@ impl Config { let max_rps: f64 = env::var("MAX_RPS") .expect("MAX_RPS must be set for RampRps") .parse()?; - let ramp_duration_str = env::var("RAMP_DURATION") - .unwrap_or_else(|_| test_duration_str.to_string()); + let ramp_duration_str = + env::var("RAMP_DURATION").unwrap_or_else(|_| test_duration_str.to_string()); let ramp_duration = parse_duration_string(&ramp_duration_str)?; Ok(LoadModel::RampRps { min_rps, diff --git a/src/load_models.rs b/src/load_models.rs index 9beecf7..0382ce4 100644 --- a/src/load_models.rs +++ b/src/load_models.rs @@ -9,9 +9,7 @@ pub enum LoadModel { /// Fixed RPS target. /// Maintains a constant request rate throughout the test. - Rps { - target_rps: f64, - }, + Rps { target_rps: f64 }, /// Linear ramp up/down pattern. /// Divides the ramp_duration into thirds: diff --git a/src/metrics.rs b/src/metrics.rs index f577806..db4ba67 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -1,6 +1,8 @@ -use hyper::{Body, Request, Response, Server}; use hyper::service::{make_service_fn, service_fn}; -use prometheus::{Encoder, Gauge, Histogram, IntCounter, IntCounterVec, Opts, Registry, TextEncoder}; +use hyper::{Body, Request, Response, Server}; +use prometheus::{ + Encoder, Gauge, Histogram, IntCounter, IntCounterVec, Opts, Registry, TextEncoder, +}; use std::env; use std::sync::{Arc, Mutex}; diff --git a/src/utils.rs b/src/utils.rs index 64e7207..8d6faea 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -19,7 +19,12 @@ pub fn parse_duration_string(s: &str) -> Result { let value = match u64::from_str(value_str) { Ok(v) => v, - Err(_) => return Err(format!("Invalid numeric value in duration: '{}'", value_str)), + Err(_) => { + return Err(format!( + "Invalid numeric value in duration: '{}'", + value_str + )) + } }; match unit_char { @@ -119,10 +124,7 @@ mod tests { #[test] fn parse_zero_minutes() { - assert_eq!( - parse_duration_string("0m").unwrap(), - Duration::from_secs(0) - ); + assert_eq!(parse_duration_string("0m").unwrap(), Duration::from_secs(0)); } #[test] diff --git a/src/worker.rs b/src/worker.rs index b7a1f5a..aa3406c 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,7 +1,9 @@ use tokio::time::{self, Duration, Instant}; use crate::load_models::LoadModel; -use crate::metrics::{CONCURRENT_REQUESTS, REQUEST_DURATION_SECONDS, REQUEST_STATUS_CODES, REQUEST_TOTAL}; +use crate::metrics::{ + CONCURRENT_REQUESTS, REQUEST_DURATION_SECONDS, REQUEST_STATUS_CODES, REQUEST_TOTAL, +}; /// Configuration for a worker task. pub struct WorkerConfig { From 6fd8ecc84ea85773335efc87f9809dd7eb87638b Mon Sep 17 00:00:00 2001 From: cbaugus Date: Mon, 9 Feb 2026 16:41:03 -0600 Subject: [PATCH 5/7] Add proper error handling and validation to Config Implemented issue #23: - Added ConfigError enum with descriptive error types - Replaced all .expect() calls with proper error returns - Added validate() method for URL, concurrent tasks, and mTLS validation - Added helper functions: env_required(), env_parse_or(), env_bool() - Added Config::for_testing() for test support - Updated main.rs to print helpful error messages on config failure - Updated all tests to check for proper errors instead of panics - Added 5 new validation tests All 79 tests passing (67 unit + 12 integration) Co-Authored-By: Claude Sonnet 4.5 --- src/config.rs | 393 ++++++++++++++++++++++++++++++++++++++++---------- src/main.rs | 53 ++++++- 2 files changed, 371 insertions(+), 75 deletions(-) diff --git a/src/config.rs b/src/config.rs index 7de6c3b..21e95ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,36 @@ use std::env; +use thiserror::Error; use tokio::time::Duration; use crate::client::ClientConfig; use crate::load_models::LoadModel; use crate::utils::parse_duration_string; +/// Configuration errors with descriptive messages. +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Missing required environment variable: {0}")] + MissingEnvVar(String), + + #[error("Invalid value for {var}: {message}")] + InvalidValue { var: String, message: String }, + + #[error("mTLS configuration incomplete: both CLIENT_CERT_PATH and CLIENT_KEY_PATH must be set together, or neither")] + IncompleteMtls, + + #[error("Load model '{model}' requires: {required}")] + MissingLoadModelParams { model: String, required: String }, + + #[error("Invalid duration format for {var}: {message}")] + InvalidDuration { var: String, message: String }, + + #[error("URL validation failed: {0}")] + InvalidUrl(String), + + #[error("Parse error: {0}")] + ParseError(String), +} + /// Main configuration for the load test. #[derive(Debug, Clone)] pub struct Config { @@ -22,54 +48,73 @@ pub struct Config { pub custom_headers: Option, } +/// Helper to get a required environment variable. +fn env_required(name: &str) -> Result { + env::var(name).map_err(|_| ConfigError::MissingEnvVar(name.into())) +} + +/// Helper to parse an environment variable with a default value. +fn env_parse_or(name: &str, default: T) -> Result +where + T::Err: std::fmt::Display, +{ + match env::var(name) { + Ok(val) => val.parse().map_err(|e: T::Err| ConfigError::InvalidValue { + var: name.into(), + message: e.to_string(), + }), + Err(_) => Ok(default), + } +} + +/// Helper to parse a boolean environment variable. +fn env_bool(name: &str, default: bool) -> bool { + env::var(name) + .unwrap_or_else(|_| default.to_string()) + .to_lowercase() + == "true" +} + impl Config { /// Loads configuration from environment variables. - pub fn from_env() -> Result> { - let target_url = - env::var("TARGET_URL").expect("TARGET_URL environment variable must be set"); + pub fn from_env() -> Result { + let target_url = env_required("TARGET_URL")?; let request_type = env::var("REQUEST_TYPE").unwrap_or_else(|_| "POST".to_string()); - let send_json = env::var("SEND_JSON") - .unwrap_or_else(|_| "false".to_string()) - .to_lowercase() - == "true"; + let send_json = env_bool("SEND_JSON", false); let json_payload = if send_json { Some( - env::var("JSON_PAYLOAD") - .expect("JSON_PAYLOAD environment variable must be set when SEND_JSON=true"), + env_required("JSON_PAYLOAD").map_err(|_| ConfigError::MissingLoadModelParams { + model: "SEND_JSON=true".into(), + required: "JSON_PAYLOAD".into(), + })?, ) } else { None }; - let num_concurrent_tasks: usize = env::var("NUM_CONCURRENT_TASKS") - .unwrap_or_else(|_| "10".to_string()) - .parse() - .expect("NUM_CONCURRENT_TASKS must be a valid number"); + let num_concurrent_tasks: usize = env_parse_or("NUM_CONCURRENT_TASKS", 10)?; let test_duration_str = env::var("TEST_DURATION").unwrap_or_else(|_| "2h".to_string()); let test_duration = parse_duration_string(&test_duration_str).map_err(|e| { - format!( - "Invalid TEST_DURATION format: '{}'. {}", - test_duration_str, e - ) + ConfigError::InvalidDuration { + var: "TEST_DURATION".into(), + message: e, + } })?; let load_model = Self::parse_load_model(&test_duration_str)?; - let skip_tls_verify = env::var("SKIP_TLS_VERIFY") - .unwrap_or_else(|_| "false".to_string()) - .to_lowercase() - == "true"; + let skip_tls_verify = env_bool("SKIP_TLS_VERIFY", false); let resolve_target_addr = env::var("RESOLVE_TARGET_ADDR").ok(); let client_cert_path = env::var("CLIENT_CERT_PATH").ok(); let client_key_path = env::var("CLIENT_KEY_PATH").ok(); let custom_headers = env::var("CUSTOM_HEADERS").ok(); - Ok(Config { + let config = Config { target_url, request_type, send_json, @@ -82,32 +127,59 @@ impl Config { client_cert_path, client_key_path, custom_headers, - }) + }; + + config.validate()?; + Ok(config) } - fn parse_load_model( - test_duration_str: &str, - ) -> Result> { + fn parse_load_model(test_duration_str: &str) -> Result { let model_type = env::var("LOAD_MODEL_TYPE").unwrap_or_else(|_| "Concurrent".to_string()); match model_type.as_str() { "Concurrent" => Ok(LoadModel::Concurrent), "Rps" => { - let target_rps: f64 = env::var("TARGET_RPS") - .expect("TARGET_RPS must be set for Rps model") - .parse()?; + let target_rps: f64 = env_required("TARGET_RPS") + .map_err(|_| ConfigError::MissingLoadModelParams { + model: "Rps".into(), + required: "TARGET_RPS".into(), + })? + .parse() + .map_err(|e: std::num::ParseFloatError| ConfigError::InvalidValue { + var: "TARGET_RPS".into(), + message: e.to_string(), + })?; Ok(LoadModel::Rps { target_rps }) } "RampRps" => { - let min_rps: f64 = env::var("MIN_RPS") - .expect("MIN_RPS must be set for RampRps") - .parse()?; - let max_rps: f64 = env::var("MAX_RPS") - .expect("MAX_RPS must be set for RampRps") - .parse()?; + let min_rps: f64 = env_required("MIN_RPS") + .map_err(|_| ConfigError::MissingLoadModelParams { + model: "RampRps".into(), + required: "MIN_RPS".into(), + })? + .parse() + .map_err(|e: std::num::ParseFloatError| ConfigError::InvalidValue { + var: "MIN_RPS".into(), + message: e.to_string(), + })?; + let max_rps: f64 = env_required("MAX_RPS") + .map_err(|_| ConfigError::MissingLoadModelParams { + model: "RampRps".into(), + required: "MAX_RPS".into(), + })? + .parse() + .map_err(|e: std::num::ParseFloatError| ConfigError::InvalidValue { + var: "MAX_RPS".into(), + message: e.to_string(), + })?; let ramp_duration_str = env::var("RAMP_DURATION").unwrap_or_else(|_| test_duration_str.to_string()); - let ramp_duration = parse_duration_string(&ramp_duration_str)?; + let ramp_duration = parse_duration_string(&ramp_duration_str).map_err(|e| { + ConfigError::InvalidDuration { + var: "RAMP_DURATION".into(), + message: e, + } + })?; Ok(LoadModel::RampRps { min_rps, max_rps, @@ -115,34 +187,54 @@ impl Config { }) } "DailyTraffic" => { - let min_rps: f64 = env::var("DAILY_MIN_RPS") - .expect("DAILY_MIN_RPS must be set for DailyTraffic model") - .parse()?; - let mid_rps: f64 = env::var("DAILY_MID_RPS") - .expect("DAILY_MID_RPS must be set for DailyTraffic model") - .parse()?; - let max_rps: f64 = env::var("DAILY_MAX_RPS") - .expect("DAILY_MAX_RPS must be set for DailyTraffic model") - .parse()?; - let cycle_duration_str = env::var("DAILY_CYCLE_DURATION") - .expect("DAILY_CYCLE_DURATION must be set for DailyTraffic model"); - let cycle_duration = parse_duration_string(&cycle_duration_str)?; - - let morning_ramp_ratio: f64 = env::var("MORNING_RAMP_RATIO") - .unwrap_or_else(|_| "0.125".to_string()) - .parse()?; - let peak_sustain_ratio: f64 = env::var("PEAK_SUSTAIN_RATIO") - .unwrap_or_else(|_| "0.167".to_string()) - .parse()?; - let mid_decline_ratio: f64 = env::var("MID_DECLINE_RATIO") - .unwrap_or_else(|_| "0.125".to_string()) - .parse()?; - let mid_sustain_ratio: f64 = env::var("MID_SUSTAIN_RATIO") - .unwrap_or_else(|_| "0.167".to_string()) - .parse()?; - let evening_decline_ratio: f64 = env::var("EVENING_DECLINE_RATIO") - .unwrap_or_else(|_| "0.167".to_string()) - .parse()?; + let min_rps: f64 = env_required("DAILY_MIN_RPS") + .map_err(|_| ConfigError::MissingLoadModelParams { + model: "DailyTraffic".into(), + required: "DAILY_MIN_RPS".into(), + })? + .parse() + .map_err(|e: std::num::ParseFloatError| ConfigError::InvalidValue { + var: "DAILY_MIN_RPS".into(), + message: e.to_string(), + })?; + let mid_rps: f64 = env_required("DAILY_MID_RPS") + .map_err(|_| ConfigError::MissingLoadModelParams { + model: "DailyTraffic".into(), + required: "DAILY_MID_RPS".into(), + })? + .parse() + .map_err(|e: std::num::ParseFloatError| ConfigError::InvalidValue { + var: "DAILY_MID_RPS".into(), + message: e.to_string(), + })?; + let max_rps: f64 = env_required("DAILY_MAX_RPS") + .map_err(|_| ConfigError::MissingLoadModelParams { + model: "DailyTraffic".into(), + required: "DAILY_MAX_RPS".into(), + })? + .parse() + .map_err(|e: std::num::ParseFloatError| ConfigError::InvalidValue { + var: "DAILY_MAX_RPS".into(), + message: e.to_string(), + })?; + let cycle_duration_str = env_required("DAILY_CYCLE_DURATION").map_err(|_| { + ConfigError::MissingLoadModelParams { + model: "DailyTraffic".into(), + required: "DAILY_CYCLE_DURATION".into(), + } + })?; + let cycle_duration = parse_duration_string(&cycle_duration_str).map_err(|e| { + ConfigError::InvalidDuration { + var: "DAILY_CYCLE_DURATION".into(), + message: e, + } + })?; + + let morning_ramp_ratio: f64 = env_parse_or("MORNING_RAMP_RATIO", 0.125)?; + let peak_sustain_ratio: f64 = env_parse_or("PEAK_SUSTAIN_RATIO", 0.167)?; + let mid_decline_ratio: f64 = env_parse_or("MID_DECLINE_RATIO", 0.125)?; + let mid_sustain_ratio: f64 = env_parse_or("MID_SUSTAIN_RATIO", 0.167)?; + let evening_decline_ratio: f64 = env_parse_or("EVENING_DECLINE_RATIO", 0.167)?; let total_ratios = morning_ramp_ratio + peak_sustain_ratio @@ -168,7 +260,57 @@ impl Config { evening_decline_ratio, }) } - _ => panic!("Unknown LOAD_MODEL_TYPE: {}", model_type), + _ => Err(ConfigError::InvalidValue { + var: "LOAD_MODEL_TYPE".into(), + message: format!( + "Unknown load model '{}'. Valid options: Concurrent, Rps, RampRps, DailyTraffic", + model_type + ), + }), + } + } + + /// Validates the configuration for consistency and correctness. + fn validate(&self) -> Result<(), ConfigError> { + // Validate URL format + if !self.target_url.starts_with("http://") && !self.target_url.starts_with("https://") { + return Err(ConfigError::InvalidUrl( + "TARGET_URL must start with http:// or https://".into(), + )); + } + + // Validate num_concurrent_tasks + if self.num_concurrent_tasks == 0 { + return Err(ConfigError::InvalidValue { + var: "NUM_CONCURRENT_TASKS".into(), + message: "Must be greater than 0".into(), + }); + } + + // Validate mTLS (both cert and key, or neither) + if self.client_cert_path.is_some() != self.client_key_path.is_some() { + return Err(ConfigError::IncompleteMtls); + } + + Ok(()) + } + + /// Creates a default Config for testing purposes. + #[cfg(test)] + pub fn for_testing() -> Self { + Config { + target_url: "https://example.com".into(), + request_type: "GET".into(), + send_json: false, + json_payload: None, + num_concurrent_tasks: 10, + test_duration: Duration::from_secs(60), + load_model: LoadModel::Concurrent, + skip_tls_verify: false, + resolve_target_addr: None, + client_cert_path: None, + client_key_path: None, + custom_headers: None, } } @@ -523,31 +665,42 @@ mod tests { } #[test] - #[should_panic(expected = "TARGET_URL")] - fn missing_target_url_panics() { + fn missing_target_url_returns_error() { let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); clear_env_vars(); // TARGET_URL not set - let _ = Config::from_env(); + let result = Config::from_env(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::MissingEnvVar(ref var) if var == "TARGET_URL"), + "expected MissingEnvVar(TARGET_URL), got {:?}", + err + ); clear_env_vars(); } #[test] - #[should_panic(expected = "Unknown LOAD_MODEL_TYPE")] - fn unknown_load_model_panics() { + fn unknown_load_model_returns_error() { let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); clear_env_vars(); env::set_var("TARGET_URL", "https://example.com"); env::set_var("LOAD_MODEL_TYPE", "InvalidModel"); - let _ = Config::from_env(); + let result = Config::from_env(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::InvalidValue { ref var, .. } if var == "LOAD_MODEL_TYPE"), + "expected InvalidValue for LOAD_MODEL_TYPE, got {:?}", + err + ); clear_env_vars(); } #[test] - #[should_panic(expected = "JSON_PAYLOAD")] - fn send_json_without_payload_panics() { + fn send_json_without_payload_returns_error() { let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); clear_env_vars(); @@ -555,7 +708,99 @@ mod tests { env::set_var("SEND_JSON", "true"); // JSON_PAYLOAD not set - let _ = Config::from_env(); + let result = Config::from_env(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::MissingLoadModelParams { .. }), + "expected MissingLoadModelParams, got {:?}", + err + ); + clear_env_vars(); + } + + #[test] + fn invalid_url_format_returns_error() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "not-a-valid-url"); + + let result = Config::from_env(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::InvalidUrl(_)), + "expected InvalidUrl, got {:?}", + err + ); + clear_env_vars(); + } + + #[test] + fn zero_concurrent_tasks_returns_error() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("NUM_CONCURRENT_TASKS", "0"); + + let result = Config::from_env(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::InvalidValue { ref var, .. } if var == "NUM_CONCURRENT_TASKS"), + "expected InvalidValue for NUM_CONCURRENT_TASKS, got {:?}", + err + ); clear_env_vars(); } + + #[test] + fn incomplete_mtls_cert_only_returns_error() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("CLIENT_CERT_PATH", "/path/to/cert.pem"); + // CLIENT_KEY_PATH not set + + let result = Config::from_env(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::IncompleteMtls), + "expected IncompleteMtls, got {:?}", + err + ); + clear_env_vars(); + } + + #[test] + fn incomplete_mtls_key_only_returns_error() { + let _lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env_vars(); + + env::set_var("TARGET_URL", "https://example.com"); + env::set_var("CLIENT_KEY_PATH", "/path/to/key.pem"); + // CLIENT_CERT_PATH not set + + let result = Config::from_env(); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(err, ConfigError::IncompleteMtls), + "expected IncompleteMtls, got {:?}", + err + ); + clear_env_vars(); + } + + #[test] + fn for_testing_creates_valid_config() { + let config = Config::for_testing(); + assert_eq!(config.target_url, "https://example.com"); + assert_eq!(config.num_concurrent_tasks, 10); + assert!(matches!(config.load_model, LoadModel::Concurrent)); + } } diff --git a/src/main.rs b/src/main.rs index cb88c21..4dcbe24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,13 +6,64 @@ use rust_loadtest::config::Config; use rust_loadtest::metrics::{gather_metrics_string, register_metrics, start_metrics_server}; use rust_loadtest::worker::{run_worker, WorkerConfig}; +/// Prints helpful configuration documentation. +fn print_config_help() { + eprintln!("Required environment variables:"); + eprintln!( + " TARGET_URL - The URL to load test (must start with http:// or https://)" + ); + eprintln!(); + eprintln!("Optional environment variables:"); + eprintln!(" REQUEST_TYPE - HTTP method: GET or POST (default: POST)"); + eprintln!(" SEND_JSON - Send JSON payload: true or false (default: false)"); + eprintln!( + " JSON_PAYLOAD - JSON body for POST requests (required if SEND_JSON=true)" + ); + eprintln!( + " NUM_CONCURRENT_TASKS - Number of concurrent workers (default: 10, must be > 0)" + ); + eprintln!(" TEST_DURATION - Total test duration: 10m, 2h, 1d (default: 2h)"); + eprintln!(); + eprintln!("Load model configuration:"); + eprintln!(" LOAD_MODEL_TYPE - Concurrent, Rps, RampRps, or DailyTraffic (default: Concurrent)"); + eprintln!(" Rps model requires:"); + eprintln!(" TARGET_RPS - Target requests per second"); + eprintln!(" RampRps model requires:"); + eprintln!(" MIN_RPS - Starting requests per second"); + eprintln!(" MAX_RPS - Peak requests per second"); + eprintln!(" RAMP_DURATION - Duration to ramp (default: TEST_DURATION)"); + eprintln!(" DailyTraffic model requires:"); + eprintln!(" DAILY_MIN_RPS - Minimum (nighttime) RPS"); + eprintln!(" DAILY_MID_RPS - Medium (afternoon) RPS"); + eprintln!(" DAILY_MAX_RPS - Maximum (peak) RPS"); + eprintln!(" DAILY_CYCLE_DURATION - Full cycle duration (e.g., 1d)"); + eprintln!(); + eprintln!("TLS/mTLS configuration:"); + eprintln!(" SKIP_TLS_VERIFY - Skip TLS certificate verification (default: false)"); + eprintln!(" CLIENT_CERT_PATH - Path to client certificate for mTLS"); + eprintln!(" CLIENT_KEY_PATH - Path to client key for mTLS"); + eprintln!(" Note: Both CLIENT_CERT_PATH and CLIENT_KEY_PATH must be set together"); + eprintln!(); + eprintln!("Advanced configuration:"); + eprintln!(" RESOLVE_TARGET_ADDR - DNS override: hostname:ip:port"); + eprintln!(" CUSTOM_HEADERS - Comma-separated headers (use \\, for literal commas)"); + eprintln!(" METRIC_NAMESPACE - Prometheus metric namespace (default: rust_loadtest)"); +} + #[tokio::main] async fn main() -> Result<(), Box> { // Register Prometheus metrics register_metrics()?; // Load configuration from environment variables - let config = Config::from_env()?; + let config = match Config::from_env() { + Ok(c) => c, + Err(e) => { + eprintln!("Configuration error: {}\n", e); + print_config_help(); + std::process::exit(1); + } + }; // Build HTTP client with TLS and header configuration let client_config = config.to_client_config(); From 07d4a966b519d4feaff6a47d0bcdd306e5477ab5 Mon Sep 17 00:00:00 2001 From: cbaugus Date: Tue, 10 Feb 2026 09:45:49 -0600 Subject: [PATCH 6/7] Add structured logging with tracing Implemented issue #22: - Added tracing and tracing-subscriber dependencies - Initialized tracing subscriber with RUST_LOG and LOG_FORMAT support - Replaced all println!/eprintln! with structured logging - Added log levels: error, warn, info, debug - Added structured fields to all log messages - Worker lifecycle logging (starting, stopping, request completion) - JSON output format support for log aggregation - Updated help text with logging configuration - Added .gitignore for build artifacts Features: - RUST_LOG env var controls log level (error/warn/info/debug/trace) - LOG_FORMAT=json enables JSON output for log aggregation - Structured fields for filtering and searching - Timestamps and thread IDs included - Module targeting supported All 79 tests passing (67 unit + 12 integration) Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 14 +++++++++++++ Cargo.toml | 2 ++ src/config.rs | 57 +++++++++++++++++++++++--------------------------- src/main.rs | 54 +++++++++++++++++++++++++++++++++++++++++------ src/metrics.rs | 10 +++++---- src/worker.rs | 42 +++++++++++++++++++++++++++---------- 6 files changed, 127 insertions(+), 52 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6534088 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Rust build artifacts +/target/ +Cargo.lock + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db diff --git a/Cargo.toml b/Cargo.toml index f2c30b2..fb8b353 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ rustls-pemfile = "2.0.0" # For reading PEM files for rustls serde = { version = "1.0", features = ["derive"] } # For deserializing config if needed serde_json = "1.0" # For JSON parsing if needed thiserror = "1.0" # For error handling +tracing = "0.1" # Structured logging +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } # Logging subscriber with JSON support [dev-dependencies] wiremock = "0.5" diff --git a/src/config.rs b/src/config.rs index 21e95ae..8a0666e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::env; use thiserror::Error; use tokio::time::Duration; +use tracing::{info, warn}; use crate::client::ClientConfig; use crate::load_models::LoadModel; @@ -242,9 +243,9 @@ impl Config { + mid_sustain_ratio + evening_decline_ratio; if total_ratios > 1.0 { - eprintln!( - "Warning: Sum of DailyTraffic segment ratios exceeds 1.0 (Total: {}). Night sustain phase will be negative or very short.", - total_ratios + warn!( + total_ratios = total_ratios, + "DailyTraffic segment ratios exceed 1.0; night sustain phase will be negative or very short" ); } @@ -325,37 +326,31 @@ impl Config { } } - /// Prints the configuration summary to stdout. + /// Logs the configuration summary with structured fields. pub fn print_summary(&self, parsed_headers: &reqwest::header::HeaderMap) { - println!("Starting load test:"); - println!(" Target URL: {}", self.target_url); - println!(" Request type: {}", self.request_type); - println!(" Concurrent Tasks: {}", self.num_concurrent_tasks); - println!(" Overall Test Duration: {:?}", self.test_duration); - println!(" Load Model: {:?}", self.load_model); - println!(" Skip TLS Verify: {}", self.skip_tls_verify); - - if self.client_cert_path.is_some() && self.client_key_path.is_some() { - println!(" mTLS Enabled: Yes (using CLIENT_CERT_PATH and CLIENT_KEY_PATH)"); - } else { - println!(" mTLS Enabled: No (CLIENT_CERT_PATH or CLIENT_KEY_PATH not set, or only one was set)"); - } + let mtls_enabled = self.client_cert_path.is_some() && self.client_key_path.is_some(); + let custom_headers_count = parsed_headers.len(); + + info!( + target_url = %self.target_url, + request_type = %self.request_type, + concurrent_tasks = self.num_concurrent_tasks, + test_duration_secs = self.test_duration.as_secs(), + load_model = ?self.load_model, + skip_tls_verify = self.skip_tls_verify, + mtls_enabled = mtls_enabled, + custom_headers_count = custom_headers_count, + "Starting load test" + ); - if let Some(ref headers_str) = self.custom_headers { - if !headers_str.is_empty() && !parsed_headers.is_empty() { - println!(" Custom Headers Enabled: Yes"); - for (name, value) in parsed_headers.iter() { - println!( - " {}: {}", - name, - value.to_str().unwrap_or("") - ); - } - } else { - println!(" Custom Headers Enabled: No (CUSTOM_HEADERS was set but resulted in no valid headers or was empty after parsing)"); + if !parsed_headers.is_empty() { + for (name, value) in parsed_headers.iter() { + info!( + header_name = %name, + header_value = %value.to_str().unwrap_or(""), + "Custom header configured" + ); } - } else { - println!(" Custom Headers Enabled: No (CUSTOM_HEADERS not set)"); } } } diff --git a/src/main.rs b/src/main.rs index 4dcbe24..8059603 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,36 @@ use std::sync::{Arc, Mutex}; use tokio::time::{self, Duration}; +use tracing::{error, info}; +use tracing_subscriber::{fmt, EnvFilter}; use rust_loadtest::client::build_client; use rust_loadtest::config::Config; use rust_loadtest::metrics::{gather_metrics_string, register_metrics, start_metrics_server}; use rust_loadtest::worker::{run_worker, WorkerConfig}; +/// Initializes the tracing subscriber for structured logging. +fn init_tracing() { + let log_format = std::env::var("LOG_FORMAT").unwrap_or_default(); + + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("rust_loadtest=info")); + + if log_format == "json" { + fmt() + .with_env_filter(env_filter) + .with_target(true) + .with_thread_ids(true) + .json() + .init(); + } else { + fmt() + .with_env_filter(env_filter) + .with_target(true) + .with_thread_ids(true) + .init(); + } +} + /// Prints helpful configuration documentation. fn print_config_help() { eprintln!("Required environment variables:"); @@ -48,10 +73,18 @@ fn print_config_help() { eprintln!(" RESOLVE_TARGET_ADDR - DNS override: hostname:ip:port"); eprintln!(" CUSTOM_HEADERS - Comma-separated headers (use \\, for literal commas)"); eprintln!(" METRIC_NAMESPACE - Prometheus metric namespace (default: rust_loadtest)"); + eprintln!(); + eprintln!("Logging configuration:"); + eprintln!(" RUST_LOG - Log level: error, warn, info, debug, trace"); + eprintln!(" Examples: RUST_LOG=info, RUST_LOG=rust_loadtest=debug"); + eprintln!(" LOG_FORMAT - Output format: json or default (human-readable)"); } #[tokio::main] async fn main() -> Result<(), Box> { + // Initialize tracing subscriber + init_tracing(); + // Register Prometheus metrics register_metrics()?; @@ -59,6 +92,7 @@ async fn main() -> Result<(), Box> { let config = match Config::from_env() { Ok(c) => c, Err(e) => { + error!(error = %e, "Configuration error"); eprintln!("Configuration error: {}\n", e); print_config_help(); std::process::exit(1); @@ -84,6 +118,11 @@ async fn main() -> Result<(), Box> { }); } + info!( + metrics_port = metrics_port, + "Prometheus metrics server started" + ); + // Main loop to run for a duration let start_time = time::Instant::now(); @@ -111,20 +150,23 @@ async fn main() -> Result<(), Box> { // Wait for the total test duration to pass tokio::time::sleep(config.test_duration).await; - println!("Main test duration completed. Signalling tasks to stop."); + info!( + duration_secs = config.test_duration.as_secs(), + "Test duration completed, signalling workers to stop" + ); // Brief pause to allow in-flight metrics to be updated tokio::time::sleep(Duration::from_secs(2)).await; - println!("Collecting and printing final metrics..."); + info!("Collecting final metrics"); // Gather and print final metrics let final_metrics_output = gather_metrics_string(®istry_arc); - println!("\n--- FINAL METRICS ---\n{}", final_metrics_output); - println!("--- END OF FINAL METRICS ---\n"); + info!("\n--- FINAL METRICS ---\n{}", final_metrics_output); + info!("--- END OF FINAL METRICS ---"); - println!("Pausing for 2 minutes to allow final Prometheus scrape..."); + info!("Pausing for 2 minutes to allow final Prometheus scrape"); tokio::time::sleep(Duration::from_secs(120)).await; - println!("2-minute pause complete. Exiting."); + info!("Pause complete, exiting"); Ok(()) } diff --git a/src/metrics.rs b/src/metrics.rs index db4ba67..a08f6ed 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -5,6 +5,7 @@ use prometheus::{ }; use std::env; use std::sync::{Arc, Mutex}; +use tracing::{error, info}; lazy_static::lazy_static! { pub static ref METRIC_NAMESPACE: String = @@ -81,13 +82,14 @@ pub async fn start_metrics_server(port: u16, registry: Arc>) { }); let server = Server::bind(&addr).serve(make_svc); - println!( - "Prometheus metrics server listening on http://0.0.0.0:{}", - port + info!( + port = port, + addr = %addr, + "Metrics server listening" ); if let Err(e) = server.await { - eprintln!("Metrics server error: {}", e); + error!(error = %e, "Metrics server error"); } } diff --git a/src/worker.rs b/src/worker.rs index aa3406c..dd48f03 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,4 +1,5 @@ use tokio::time::{self, Duration, Instant}; +use tracing::{debug, error, info}; use crate::load_models::LoadModel; use crate::metrics::{ @@ -19,14 +20,22 @@ pub struct WorkerConfig { /// Runs a single worker task that sends HTTP requests according to the load model. pub async fn run_worker(client: reqwest::Client, config: WorkerConfig, start_time: Instant) { + debug!( + task_id = config.task_id, + url = %config.url, + load_model = ?config.load_model, + "Worker starting" + ); + loop { let elapsed_total_secs = Instant::now().duration_since(start_time).as_secs_f64(); // Check if the total test duration has passed if elapsed_total_secs >= config.test_duration.as_secs_f64() { - println!( - "Task {} stopping after overall duration limit.", - config.task_id + info!( + task_id = config.task_id, + elapsed_secs = elapsed_total_secs, + "Worker stopping after duration limit" ); break; } @@ -54,14 +63,25 @@ pub async fn run_worker(client: reqwest::Client, config: WorkerConfig, start_tim match req.send().await { Ok(response) => { - let status = response.status().as_u16().to_string(); - REQUEST_STATUS_CODES.with_label_values(&[&status]).inc(); + let status = response.status().as_u16(); + let status_str = status.to_string(); + REQUEST_STATUS_CODES.with_label_values(&[&status_str]).inc(); + + debug!( + task_id = config.task_id, + url = %config.url, + status_code = status, + latency_ms = request_start_time.elapsed().as_millis() as u64, + "Request completed" + ); } Err(e) => { REQUEST_STATUS_CODES.with_label_values(&["error"]).inc(); - eprintln!( - "Task {}: Request to {} failed: {}", - config.task_id, config.url, e + error!( + task_id = config.task_id, + url = %config.url, + error = %e, + "Request failed" ); } } @@ -93,9 +113,9 @@ fn build_request(client: &reqwest::Client, config: &WorkerConfig) -> reqwest::Re } } _ => { - eprintln!( - "Request type {} not currently supported, falling back to GET", - config.request_type + error!( + request_type = %config.request_type, + "Unsupported request type, falling back to GET" ); client.get(&config.url) } From 4b7bb036d119c132b93b08eb3ca5b0e0d3930a1d Mon Sep 17 00:00:00 2001 From: cbaugus Date: Tue, 10 Feb 2026 10:37:39 -0600 Subject: [PATCH 7/7] Update .gitignore to exclude business documents --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6534088..0cba2a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ Cargo.lock # OS files .DS_Store Thumbs.db + +# Business documents (keep local only) +PRODUCT_DESIGN.md +BUSINESS_PLAN.md