diff --git a/loadgen-rust/.gitignore b/loadgen-rust/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/loadgen-rust/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/loadgen-rust/Cargo.lock b/loadgen-rust/Cargo.lock new file mode 100644 index 0000000..a560cc3 --- /dev/null +++ b/loadgen-rust/Cargo.lock @@ -0,0 +1,2872 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[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-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +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 = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +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 = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[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 = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "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 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "governor" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0746aa765db78b521451ef74221663b57ba595bf83f75d0ce23cc09447c8139f" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.8.5", + "smallvec", + "spinning_top", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "base64 0.21.7", + "byteorder", + "crossbeam-channel", + "flate2", + "nom", + "num-traits", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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 = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "loadgen" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytesize", + "chrono", + "clap", + "ctrlc", + "flate2", + "governor", + "hdrhistogram", + "mockito", + "parking_lot", + "rand 0.8.5", + "regex", + "reqwest", + "roaring", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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 = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nix" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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 = "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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +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 = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[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 = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[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", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "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", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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 = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[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 = "roaring" +version = "0.10.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[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.103.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[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.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[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 = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "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 = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "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-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.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 = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "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 = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +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 = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.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" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/loadgen-rust/Cargo.toml b/loadgen-rust/Cargo.toml new file mode 100644 index 0000000..833c046 --- /dev/null +++ b/loadgen-rust/Cargo.toml @@ -0,0 +1,109 @@ +# Copyright (C) INFINI Labs & INFINI LIMITED. +# +# The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +# and as commercial software. +# +# For commercial licensing, contact us at: +# - Website: infinilabs.com +# - Email: hello@infini.ltd +# +# Open Source licensed under AGPL V3: +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[package] +name = "loadgen" +version = "0.1.0" +edition = "2021" +authors = ["INFINI Labs "] +description = "A high-performance HTTP load generator and testing suite" +license = "AGPL-3.0" +homepage = "https://github.com/infinilabs/loadgen" +repository = "https://github.com/infinilabs/loadgen" +keywords = ["load-testing", "http", "benchmark", "performance"] +categories = ["command-line-utilities", "development-tools::testing"] + +[dependencies] +# Async runtime +tokio = { version = "1", features = ["full", "sync", "time", "signal", "rt-multi-thread", "macros"] } + +# CLI argument parsing +clap = { version = "4", features = ["derive", "env"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1" + +# HTTP client +reqwest = { version = "0.12", features = ["gzip", "rustls-tls", "json", "stream"] } + +# UUID generation +uuid = { version = "1", features = ["v4"] } + +# Time handling +chrono = { version = "0.4", features = ["serde"] } + +# Random number generation +rand = "0.8" + +# Roaring bitmap for int_array_bitmap variable type +roaring = "0.10" + +# Base64 encoding for bitmap serialization +base64 = "0.22" + +# Regex for assertions and DSL parsing +regex = "1" + +# Histogram for latency metrics +hdrhistogram = "7" + +# Rate limiting +governor = "0.7" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Synchronization primitives +parking_lot = "0.12" + +# Compression +flate2 = "1" + +# Error handling +thiserror = "1" +anyhow = "1" + +# Signal handling (Ctrl+C) +ctrlc = { version = "3", features = ["termination"] } + +# Formatting byte sizes +bytesize = "1" + +[dev-dependencies] +# Testing utilities +tempfile = "3" +mockito = "1" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true +opt-level = 3 + +[profile.bench] +lto = true +codegen-units = 1 diff --git a/loadgen-rust/README.md b/loadgen-rust/README.md new file mode 100644 index 0000000..9a16761 --- /dev/null +++ b/loadgen-rust/README.md @@ -0,0 +1,152 @@ +# INFINI Loadgen (Rust) + +A high-performance HTTP load generator and testing suite written in Rust, compatible with the original Go implementation's configuration files. + +## Features + +- **High Performance**: Built with Tokio async runtime for maximum throughput +- **Compatible Configuration**: Supports the same YAML and DSL configuration formats as the Go version +- **Variable System**: Full support for all variable types including: + - `file` - Load from external files + - `list` - Inline defined lists + - `sequence` / `sequence64` - Auto-incrementing sequences + - `range` - Random numbers in range + - `uuid` - UUID v4 generation + - `now_local`, `now_utc`, `now_unix` - Time variables + - `now_with_format` - Custom formatted time + - `random_array` - Random arrays from other variables + - `int_array_bitmap` - Roaring bitmap encoded arrays +- **Template Engine**: `$[[variable]]` syntax for dynamic request generation +- **Response Assertions**: Validate responses with conditions (equals, contains, regexp, range, etc.) +- **Rate Limiting**: Control request rate with `-r` flag +- **Metrics**: Detailed latency histograms and percentiles + +## Building + +```bash +cd loadgen-rust +cargo build --release +``` + +The binary will be at `target/release/loadgen`. + +## Usage + +```bash +# Run with default config (loadgen.yml) +./loadgen + +# Run with specific config +./loadgen -C myconfig.yml + +# Run DSL file +./loadgen --run test.dsl + +# Load test with concurrency +./loadgen -c 10 -d 30 -r 1000 + +# Mixed mode (run both DSL and YAML) +./loadgen --run test.dsl -C config.yml --mixed +``` + +## CLI Options + +| Flag | Description | Default | +|------|-------------|---------| +| `-c, --concurrency` | Number of concurrent threads | 1 | +| `-d, --duration` | Test duration in seconds | 5 | +| `-r, --rate` | Max requests per second (-1 = unlimited) | -1 | +| `-l, --limit` | Total request limit (-1 = unlimited) | -1 | +| `--timeout` | Request timeout in seconds (0 = no timeout) | 0 | +| `--read-timeout` | Read timeout in seconds | 0 | +| `--write-timeout` | Write timeout in seconds | 0 | +| `--dial-timeout` | Connection dial timeout in seconds | 3 | +| `--compress` | Enable gzip compression | false | +| `--mixed` | Enable mixed YAML/DSL mode | false | +| `--total-rounds` | Number of request rounds (-1 = unlimited) | -1 | +| `--run` | Path to DSL file | - | +| `-C, --config` | Path to YAML config | loadgen.yml | +| `--log` | Log level (trace, debug, info, warn, error) | info | +| `--debug` | Enable debug mode | false | + +## Configuration Format + +### YAML Configuration (`loadgen.yml`) + +```yaml +env: + ES_USERNAME: elastic + ES_PASSWORD: password + ES_ENDPOINT: http://localhost:9200 + +runner: + total_rounds: 1 + no_warm: true + log_requests: false + assert_invalid: false + assert_error: false + default_endpoint: $[[env.ES_ENDPOINT]] + default_basic_auth: + username: $[[env.ES_USERNAME]] + password: $[[env.ES_PASSWORD]] + +variables: + - name: id + type: sequence + - name: uuid + type: uuid + - name: user + type: list + data: + - alice + - bob + - charlie + +requests: + - request: + method: POST + url: /_bulk + body: | + {"index": {"_index": "test", "_id": "$[[uuid]]"}} + {"id": "$[[id]]", "user": "$[[user]]"} +``` + +### DSL Configuration + +```dsl +# runner: {"total_rounds": 1, "no_warm": true} +# variables: [{"name": "id", "type": "sequence"}] + +DELETE /test + +PUT /test + +POST /test/_doc/$[[id]] +{ + "name": "test document" +} + +GET /test/_search +# 200 +``` + +## Response Assertions + +```yaml +requests: + - request: + method: GET + url: /test/_search + assert: + equals: + _ctx.response.status: 200 + contains: + _ctx.response.body: "hits" + range: + _ctx.elapsed: + lte: 1000 +``` + +## License + +AGPL-3.0 - See [LICENSE](../LICENSE) for details. diff --git a/loadgen-rust/src/assertion/conditions.rs b/loadgen-rust/src/assertion/conditions.rs new file mode 100644 index 0000000..d95d6f2 --- /dev/null +++ b/loadgen-rust/src/assertion/conditions.rs @@ -0,0 +1,345 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Assertion conditions for response validation + +use std::collections::HashMap; + +use regex::Regex; +use tracing::debug; + +use crate::config::types::AssertConfig; + +/// Assertion checker for validating responses +pub struct AssertionChecker<'a> { + ctx: &'a HashMap, +} + +impl<'a> AssertionChecker<'a> { + /// Create a new assertion checker + pub fn new(ctx: &'a HashMap) -> Self { + Self { ctx } + } + + /// Check all assertions in the config + pub fn check(&self, config: &AssertConfig) -> bool { + // Check equals + for (field, expected) in &config.equals { + if !self.check_equals(field, expected) { + debug!("equals assertion failed for {}: expected {:?}", field, expected); + return false; + } + } + + // Check not_equals + for (field, expected) in &config.not_equals { + if self.check_equals(field, expected) { + debug!("not_equals assertion failed for {}", field); + return false; + } + } + + // Check contains + for (field, expected) in &config.contains { + if !self.check_contains(field, expected) { + debug!("contains assertion failed for {}: expected to contain {:?}", field, expected); + return false; + } + } + + // Check not_contains + for (field, expected) in &config.not_contains { + if self.check_contains(field, expected) { + debug!("not_contains assertion failed for {}", field); + return false; + } + } + + // Check regexp + for (field, pattern) in &config.regexp { + if !self.check_regexp(field, pattern) { + debug!("regexp assertion failed for {}: pattern {:?}", field, pattern); + return false; + } + } + + // Check not_regexp + for (field, pattern) in &config.not_regexp { + if self.check_regexp(field, pattern) { + debug!("not_regexp assertion failed for {}", field); + return false; + } + } + + // Check range + for (field, range) in &config.range { + if !self.check_range(field, range) { + debug!("range assertion failed for {}", field); + return false; + } + } + + // Check in + for (field, values) in &config.in_list { + if !self.check_in(field, values) { + debug!("in assertion failed for {}", field); + return false; + } + } + + // Check not_in + for (field, values) in &config.not_in { + if self.check_in(field, values) { + debug!("not_in assertion failed for {}", field); + return false; + } + } + + true + } + + /// Get a value from the context + fn get_value(&self, field: &str) -> Option<&serde_json::Value> { + // Direct lookup + if let Some(value) = self.ctx.get(field) { + return Some(value); + } + + // Try nested lookup for body_json paths + if field.starts_with("_ctx.response.body_json.") { + if let Some(body_json) = self.ctx.get("_ctx.response.body_json") { + let path = field.strip_prefix("_ctx.response.body_json.")?; + return self.get_nested_value(body_json, path); + } + } + + None + } + + /// Get a nested value from JSON + fn get_nested_value<'b>(&self, json: &'b serde_json::Value, path: &str) -> Option<&'b serde_json::Value> { + let parts: Vec<&str> = path.split('.').collect(); + let mut current = json; + + for part in parts { + match current { + serde_json::Value::Object(map) => { + current = map.get(part)?; + } + serde_json::Value::Array(arr) => { + let idx: usize = part.parse().ok()?; + current = arr.get(idx)?; + } + _ => return None, + } + } + + Some(current) + } + + /// Check equals assertion + fn check_equals(&self, field: &str, expected: &serde_json::Value) -> bool { + match self.get_value(field) { + Some(actual) => actual == expected, + None => false, + } + } + + /// Check contains assertion + fn check_contains(&self, field: &str, expected: &str) -> bool { + match self.get_value(field) { + Some(serde_json::Value::String(s)) => s.contains(expected), + Some(v) => v.to_string().contains(expected), + None => false, + } + } + + /// Check regexp assertion + fn check_regexp(&self, field: &str, pattern: &str) -> bool { + let regex = match Regex::new(pattern) { + Ok(r) => r, + Err(_) => return false, + }; + + match self.get_value(field) { + Some(serde_json::Value::String(s)) => regex.is_match(s), + Some(v) => regex.is_match(&v.to_string()), + None => false, + } + } + + /// Check range assertion + fn check_range(&self, field: &str, range: &crate::config::types::RangeCondition) -> bool { + let value = match self.get_value(field) { + Some(serde_json::Value::Number(n)) => n.as_f64(), + _ => None, + }; + + let value = match value { + Some(v) => v, + None => return false, + }; + + if let Some(gte) = range.gte { + if value < gte { + return false; + } + } + + if let Some(gt) = range.gt { + if value <= gt { + return false; + } + } + + if let Some(lte) = range.lte { + if value > lte { + return false; + } + } + + if let Some(lt) = range.lt { + if value >= lt { + return false; + } + } + + true + } + + /// Check in assertion + fn check_in(&self, field: &str, values: &[serde_json::Value]) -> bool { + match self.get_value(field) { + Some(actual) => values.contains(actual), + None => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_ctx(pairs: Vec<(&str, serde_json::Value)>) -> HashMap { + pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect() + } + + #[test] + fn test_check_equals() { + let ctx = make_ctx(vec![ + ("_ctx.response.status", serde_json::json!(200)), + ("_ctx.response.body", serde_json::json!("hello")), + ]); + + let checker = AssertionChecker::new(&ctx); + + assert!(checker.check_equals("_ctx.response.status", &serde_json::json!(200))); + assert!(!checker.check_equals("_ctx.response.status", &serde_json::json!(201))); + assert!(checker.check_equals("_ctx.response.body", &serde_json::json!("hello"))); + } + + #[test] + fn test_check_contains() { + let ctx = make_ctx(vec![ + ("_ctx.response.body", serde_json::json!("hello world")), + ]); + + let checker = AssertionChecker::new(&ctx); + + assert!(checker.check_contains("_ctx.response.body", "world")); + assert!(!checker.check_contains("_ctx.response.body", "foo")); + } + + #[test] + fn test_check_regexp() { + let ctx = make_ctx(vec![ + ("_ctx.response.body", serde_json::json!("hello123world")), + ]); + + let checker = AssertionChecker::new(&ctx); + + assert!(checker.check_regexp("_ctx.response.body", r"\d+")); + assert!(!checker.check_regexp("_ctx.response.body", r"^\d+$")); + } + + #[test] + fn test_check_range() { + let ctx = make_ctx(vec![ + ("_ctx.elapsed", serde_json::json!(150)), + ]); + + let checker = AssertionChecker::new(&ctx); + + let range = crate::config::types::RangeCondition { + gte: Some(100.0), + lte: Some(200.0), + ..Default::default() + }; + + assert!(checker.check_range("_ctx.elapsed", &range)); + + let range2 = crate::config::types::RangeCondition { + gt: Some(150.0), + ..Default::default() + }; + + assert!(!checker.check_range("_ctx.elapsed", &range2)); + } + + #[test] + fn test_check_in() { + let ctx = make_ctx(vec![ + ("_ctx.response.status", serde_json::json!(200)), + ]); + + let checker = AssertionChecker::new(&ctx); + + assert!(checker.check_in( + "_ctx.response.status", + &[serde_json::json!(200), serde_json::json!(201)] + )); + assert!(!checker.check_in( + "_ctx.response.status", + &[serde_json::json!(400), serde_json::json!(500)] + )); + } + + #[test] + fn test_check_config() { + let ctx = make_ctx(vec![ + ("_ctx.response.status", serde_json::json!(200)), + ("_ctx.response.body", serde_json::json!("success")), + ]); + + let checker = AssertionChecker::new(&ctx); + + let mut config = AssertConfig::default(); + config.equals.insert("_ctx.response.status".to_string(), serde_json::json!(200)); + config.contains.insert("_ctx.response.body".to_string(), "success".to_string()); + + assert!(checker.check(&config)); + + config.equals.insert("_ctx.response.status".to_string(), serde_json::json!(201)); + assert!(!checker.check(&config)); + } +} diff --git a/loadgen-rust/src/assertion/mod.rs b/loadgen-rust/src/assertion/mod.rs new file mode 100644 index 0000000..19812eb --- /dev/null +++ b/loadgen-rust/src/assertion/mod.rs @@ -0,0 +1,26 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Assertion module for loadgen + +pub mod conditions; diff --git a/loadgen-rust/src/config/dsl.rs b/loadgen-rust/src/config/dsl.rs new file mode 100644 index 0000000..ad57225 --- /dev/null +++ b/loadgen-rust/src/config/dsl.rs @@ -0,0 +1,328 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! DSL parser for loadgen +//! +//! The DSL format supports: +//! - Comments starting with # +//! - JSON configuration in comments (# runner: {...}, # variables: [...]) +//! - HTTP method and path on a single line (GET /path, POST /path) +//! - Request body on following lines until next HTTP method or comment +//! - Expected status code comment (# 200) +//! - Request configuration comment (# request: {...}) + +use anyhow::{Context, Result}; +use regex::Regex; +use std::path::Path; +use tracing::debug; + +use super::types::{AppConfig, LoaderConfig, Request, RequestItem, RunnerConfig, Variable}; + +/// Parse a DSL file and return a LoaderConfig +pub async fn parse_dsl_file(path: &Path, app_config: &AppConfig) -> Result { + let contents = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("Failed to read DSL file: {:?}", path))?; + + parse_dsl(&contents, app_config) +} + +/// Parse DSL content and return a LoaderConfig +pub fn parse_dsl(content: &str, app_config: &AppConfig) -> Result { + let mut config = LoaderConfig { + runner: app_config.runner.clone(), + variables: app_config.variables.clone(), + requests: Vec::new(), + }; + + // Regex patterns + let http_method_re = Regex::new(r"^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$").unwrap(); + let json_comment_re = Regex::new(r"^#\s*(runner|variables|request):\s*(.+)$").unwrap(); + let status_comment_re = Regex::new(r"^#\s*(\d{3})\s*$").unwrap(); + + let mut current_request: Option = None; + let mut current_body = String::new(); + let mut in_body = false; + let mut pending_json_config: Option<(String, String)> = None; + let mut multiline_json = String::new(); + let mut in_multiline_json = false; + let mut json_brace_count = 0; + let mut json_bracket_count = 0; + let mut json_type = String::new(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Handle multiline JSON in comments + if in_multiline_json { + // Continue collecting JSON + if trimmed.starts_with('#') { + let json_part = trimmed.trim_start_matches('#').trim(); + multiline_json.push_str(json_part); + multiline_json.push('\n'); + + // Count braces/brackets + for c in json_part.chars() { + match c { + '{' => json_brace_count += 1, + '}' => json_brace_count -= 1, + '[' => json_bracket_count += 1, + ']' => json_bracket_count -= 1, + _ => {} + } + } + + // Check if JSON is complete + if json_brace_count == 0 && json_bracket_count == 0 { + in_multiline_json = false; + pending_json_config = Some((json_type.clone(), multiline_json.clone())); + multiline_json.clear(); + } + } else { + // Not a comment, JSON is malformed + in_multiline_json = false; + multiline_json.clear(); + } + continue; + } + + // Check for JSON configuration comments + if let Some(caps) = json_comment_re.captures(trimmed) { + let config_type = caps.get(1).unwrap().as_str(); + let json_str = caps.get(2).unwrap().as_str(); + + // Count opening braces/brackets + json_brace_count = 0; + json_bracket_count = 0; + for c in json_str.chars() { + match c { + '{' => json_brace_count += 1, + '}' => json_brace_count -= 1, + '[' => json_bracket_count += 1, + ']' => json_bracket_count -= 1, + _ => {} + } + } + + if json_brace_count == 0 && json_bracket_count == 0 { + // Complete JSON on one line + pending_json_config = Some((config_type.to_string(), json_str.to_string())); + } else { + // Multiline JSON + in_multiline_json = true; + json_type = config_type.to_string(); + multiline_json = json_str.to_string(); + multiline_json.push('\n'); + } + continue; + } + + // Process pending JSON config + if let Some((config_type, json_str)) = pending_json_config.take() { + match config_type.as_str() { + "runner" => { + if let Ok(runner) = serde_json::from_str::(&json_str) { + config.runner = runner; + } + } + "variables" => { + if let Ok(vars) = serde_json::from_str::>(&json_str) { + config.variables.extend(vars); + } + } + "request" => { + if let Some(ref mut req) = current_request { + if let Ok(request) = serde_json::from_str::(&json_str) { + if let Some(ref mut existing) = req.request { + // Merge request config + if !request.runtime_variables.is_empty() { + existing.runtime_variables = request.runtime_variables; + } + if !request.runtime_body_line_variables.is_empty() { + existing.runtime_body_line_variables = request.runtime_body_line_variables; + } + if request.repeat_body_n_times > 0 { + existing.repeat_body_n_times = request.repeat_body_n_times; + } + if request.basic_auth.is_some() { + existing.basic_auth = request.basic_auth; + } + } + } + } + } + _ => {} + } + } + + // Skip empty lines + if trimmed.is_empty() { + if in_body { + current_body.push('\n'); + } + continue; + } + + // Skip regular comments (not JSON config) + if trimmed.starts_with('#') { + // Check for status code comment + if let Some(caps) = status_comment_re.captures(trimmed) { + // This is an expected status code, could be used for assertions + let _status: i32 = caps.get(1).unwrap().as_str().parse().unwrap_or(200); + // We don't do anything with status code comments for now + } + + // Check for // style comments (DSL comments) + if trimmed.starts_with("# //") || trimmed.starts_with("#//") { + continue; + } + + // End body collection on regular comment + if in_body && !current_body.is_empty() { + if let Some(ref mut req) = current_request { + if let Some(ref mut request) = req.request { + request.body = current_body.trim_end().to_string(); + } + } + current_body.clear(); + in_body = false; + } + continue; + } + + // Check for HTTP method line + if let Some(caps) = http_method_re.captures(trimmed) { + // Save previous request + if let Some(mut req) = current_request.take() { + if in_body && !current_body.is_empty() { + if let Some(ref mut request) = req.request { + request.body = current_body.trim_end().to_string(); + } + } + config.requests.push(req); + } + current_body.clear(); + + let method = caps.get(1).unwrap().as_str(); + let path = caps.get(2).unwrap().as_str(); + + current_request = Some(RequestItem { + request: Some(Request { + method: method.to_string(), + url: path.to_string(), + ..Default::default() + }), + ..Default::default() + }); + in_body = true; + continue; + } + + // Collect body lines + if in_body { + if !current_body.is_empty() { + current_body.push('\n'); + } + current_body.push_str(line); + } + } + + // Save last request + if let Some(mut req) = current_request.take() { + if in_body && !current_body.is_empty() { + if let Some(ref mut request) = req.request { + request.body = current_body.trim_end().to_string(); + } + } + config.requests.push(req); + } + + debug!("Parsed {} requests from DSL", config.requests.len()); + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_simple_dsl() { + let dsl = r#" +DELETE /medcl + +PUT /medcl + +POST /medcl/_doc/1 +{ + "name": "medcl" +} + +GET /medcl/_search +"#; + + let app_config = AppConfig::default(); + let config = parse_dsl(dsl, &app_config).unwrap(); + + assert_eq!(config.requests.len(), 4); + + let req0 = config.requests[0].request.as_ref().unwrap(); + assert_eq!(req0.method, "DELETE"); + assert_eq!(req0.url, "/medcl"); + + let req2 = config.requests[2].request.as_ref().unwrap(); + assert_eq!(req2.method, "POST"); + assert_eq!(req2.url, "/medcl/_doc/1"); + assert!(req2.body.contains("medcl")); + } + + #[test] + fn test_parse_dsl_with_runner_config() { + let dsl = r#" +# runner: {"total_rounds": 5, "no_warm": true} + +GET /test +"#; + + let app_config = AppConfig::default(); + let config = parse_dsl(dsl, &app_config).unwrap(); + + assert_eq!(config.runner.total_rounds, 5); + assert!(config.runner.no_warm); + } + + #[test] + fn test_parse_dsl_with_variables() { + let dsl = r#" +# variables: [{"name": "id", "type": "sequence"}] + +POST /test/$[[id]] +"#; + + let app_config = AppConfig::default(); + let config = parse_dsl(dsl, &app_config).unwrap(); + + assert_eq!(config.variables.len(), 1); + assert_eq!(config.variables[0].name, "id"); + assert_eq!(config.variables[0].var_type, "sequence"); + } +} diff --git a/loadgen-rust/src/config/mod.rs b/loadgen-rust/src/config/mod.rs new file mode 100644 index 0000000..ae344e5 --- /dev/null +++ b/loadgen-rust/src/config/mod.rs @@ -0,0 +1,28 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Configuration module for loadgen + +pub mod dsl; +pub mod types; +pub mod yaml; diff --git a/loadgen-rust/src/config/types.rs b/loadgen-rust/src/config/types.rs new file mode 100644 index 0000000..c7c4394 --- /dev/null +++ b/loadgen-rust/src/config/types.rs @@ -0,0 +1,427 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Configuration type definitions matching the Go implementation + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Basic authentication credentials +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BasicAuth { + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, +} + +/// HTTP request definition +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Request { + /// HTTP method (GET, POST, PUT, DELETE, etc.) + #[serde(default)] + pub method: String, + + /// Request URL (can contain template variables) + #[serde(default)] + pub url: String, + + /// Request body (can contain template variables) + #[serde(default)] + pub body: String, + + /// Simple mode - no variable substitution + #[serde(default)] + pub simple_mode: bool, + + /// Number of times to repeat the body in a single request + #[serde(default, alias = "body_repeat_times")] + pub repeat_body_n_times: i32, + + /// HTTP headers + #[serde(default)] + pub headers: Vec>, + + /// Basic authentication for this request + #[serde(default)] + pub basic_auth: Option, + + /// Disable header names normalizing + #[serde(default)] + pub disable_header_names_normalizing: bool, + + /// Runtime variables to set before executing the request + #[serde(default)] + pub runtime_variables: HashMap, + + /// Runtime variables to set per body line + #[serde(default)] + pub runtime_body_line_variables: HashMap, + + /// Number of times to execute this request + #[serde(default)] + pub execute_repeat_times: i32, +} + +/// Variable definition +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Variable { + /// Variable type (file, list, sequence, uuid, etc.) + #[serde(rename = "type")] + pub var_type: String, + + /// Variable name + #[serde(default)] + pub name: String, + + /// File path for file type variables + #[serde(default)] + pub path: String, + + /// Inline data for list type variables + #[serde(default)] + pub data: Vec, + + /// Date format for now_with_format type + #[serde(default)] + pub format: String, + + /// Range start for range/sequence types + #[serde(default)] + pub from: u64, + + /// Range end for range/sequence types + #[serde(default)] + pub to: u64, + + /// Character replacement map + #[serde(default)] + pub replace: HashMap, + + /// Size for random_array type + #[serde(default)] + pub size: usize, + + /// Variable key for random_array type + #[serde(default)] + pub variable_key: String, + + /// Variable type for random_array (number/string) + #[serde(default)] + pub variable_type: String, + + /// Whether to include square brackets for random_array + #[serde(default)] + pub square_bracket: bool, + + /// String bracket character for random_array + #[serde(default)] + pub string_bracket: String, +} + +/// Assertion condition configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AssertConfig { + /// Equals assertion: field -> expected value + #[serde(default)] + pub equals: HashMap, + + /// Not equals assertion + #[serde(default)] + pub not_equals: HashMap, + + /// Contains assertion + #[serde(default)] + pub contains: HashMap, + + /// Not contains assertion + #[serde(default)] + pub not_contains: HashMap, + + /// Regex match assertion + #[serde(default)] + pub regexp: HashMap, + + /// Regex not match assertion + #[serde(default)] + pub not_regexp: HashMap, + + /// Range assertion + #[serde(default)] + pub range: HashMap, + + /// In list assertion + #[serde(default, rename = "in")] + pub in_list: HashMap>, + + /// Not in list assertion + #[serde(default)] + pub not_in: HashMap>, +} + +/// Range condition for assertions +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RangeCondition { + #[serde(default)] + pub gte: Option, + #[serde(default)] + pub gt: Option, + #[serde(default)] + pub lte: Option, + #[serde(default)] + pub lt: Option, +} + +/// Sleep action between requests +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SleepAction { + #[serde(default)] + pub sleep_in_milli_seconds: i64, +} + +/// Request item with optional assertions +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RequestItem { + /// The HTTP request + #[serde(default)] + pub request: Option, + + /// Assertion conditions + #[serde(default)] + pub assert: Option, + + /// DSL-based assertion + #[serde(default)] + pub assert_dsl: String, + + /// Sleep action after request + #[serde(default)] + pub sleep: Option, + + /// Register response values to global context + #[serde(default)] + pub register: Vec>, +} + +/// Runner configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RunnerConfig { + /// How many rounds of requests to run + #[serde(default)] + pub total_rounds: i64, + + /// Skip warmup round + #[serde(default)] + pub no_warm: bool, + + /// Valid status codes during warmup + #[serde(default)] + pub valid_status_codes_during_warmup: Vec, + + /// Exit(1) if any assertion failed + #[serde(default)] + pub assert_invalid: bool, + + /// Continue running on assertion failure + #[serde(default)] + pub continue_on_assert_invalid: bool, + + /// Skip invalid assertions + #[serde(default)] + pub skip_invalid_assert: bool, + + /// Exit(2) if any error occurred + #[serde(default)] + pub assert_error: bool, + + /// Log all requests + #[serde(default)] + pub log_requests: bool, + + /// Benchmark only mode + #[serde(default)] + pub benchmark_only: bool, + + /// Use microseconds for duration + #[serde(default)] + pub duration_in_us: bool, + + /// Disable stats collection + #[serde(default)] + pub no_stats: bool, + + /// Disable size stats + #[serde(default)] + pub no_size_stats: bool, + + /// Metric sample size for histogram + #[serde(default = "default_metric_sample_size")] + pub metric_sample_size: usize, + + /// Log requests with these status codes + #[serde(default)] + pub log_status_codes: Vec, + + /// Disable header name normalizing + #[serde(default)] + pub disable_header_names_normalizing: bool, + + /// Reset context before test run + #[serde(default)] + pub reset_context: bool, + + /// Default endpoint for requests + #[serde(default)] + pub default_endpoint: String, + + /// Default basic auth for requests + #[serde(default)] + pub default_basic_auth: Option, +} + +fn default_metric_sample_size() -> usize { + 10000 +} + +impl Default for RunnerConfig { + fn default() -> Self { + Self { + total_rounds: -1, + no_warm: false, + valid_status_codes_during_warmup: vec![], + assert_invalid: false, + continue_on_assert_invalid: false, + skip_invalid_assert: false, + assert_error: false, + log_requests: false, + benchmark_only: false, + duration_in_us: false, + no_stats: false, + no_size_stats: false, + metric_sample_size: 10000, + log_status_codes: vec![], + disable_header_names_normalizing: false, + reset_context: false, + default_endpoint: String::new(), + default_basic_auth: None, + } + } +} + +/// Test case configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Test { + /// Directory path containing test configurations + #[serde(default)] + pub path: String, + + /// Whether to use compression + #[serde(default)] + pub compress: bool, +} + +/// Loader configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LoaderConfig { + /// Variable definitions + #[serde(default)] + pub variables: Vec, + + /// Request definitions + #[serde(default)] + pub requests: Vec, + + /// Runner configuration + #[serde(default)] + pub runner: RunnerConfig, +} + +/// Application configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AppConfig { + /// Environment variables + #[serde(default, alias = "env")] + pub environments: HashMap, + + /// Test cases + #[serde(default)] + pub tests: Vec, + + /// Variable definitions + #[serde(default)] + pub variables: Vec, + + /// Request definitions + #[serde(default)] + pub requests: Vec, + + /// Runner configuration + #[serde(default)] + pub runner: RunnerConfig, +} + +impl AppConfig { + /// Convert to LoaderConfig + pub fn to_loader_config(&self) -> LoaderConfig { + LoaderConfig { + variables: self.variables.clone(), + requests: self.requests.clone(), + runner: self.runner.clone(), + } + } + + /// Check if environment variables exist + pub fn test_env(&self, env_vars: &[&str]) -> bool { + for env_var in env_vars { + match self.environments.get(*env_var) { + Some(v) if !v.is_empty() => continue, + _ => return false, + } + } + true + } +} + +/// Request result for statistics +#[derive(Debug, Clone, Default)] +pub struct RequestResult { + pub request_count: i32, + pub request_size: i64, + pub response_size: i64, + pub status: i32, + pub error: bool, + pub invalid: bool, + pub duration: std::time::Duration, +} + +impl RequestResult { + pub fn reset(&mut self) { + self.request_count = 0; + self.request_size = 0; + self.response_size = 0; + self.status = 0; + self.error = false; + self.invalid = false; + self.duration = std::time::Duration::ZERO; + } +} diff --git a/loadgen-rust/src/config/yaml.rs b/loadgen-rust/src/config/yaml.rs new file mode 100644 index 0000000..816c3a5 --- /dev/null +++ b/loadgen-rust/src/config/yaml.rs @@ -0,0 +1,142 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! YAML configuration loader + +use anyhow::{Context, Result}; +use std::path::Path; +use tracing::debug; + +use super::types::AppConfig; + +/// Load configuration from a YAML file +pub async fn load_config(path: &Path) -> Result { + debug!("Loading config from {:?}", path); + + // Check if file exists + if !path.exists() { + // Return default config if file doesn't exist + debug!("Config file not found, using defaults"); + return Ok(AppConfig::default()); + } + + // Read file contents + let contents = tokio::fs::read_to_string(path) + .await + .with_context(|| format!("Failed to read config file: {:?}", path))?; + + // Parse YAML + let mut config: AppConfig = serde_yaml::from_str(&contents) + .with_context(|| format!("Failed to parse config file: {:?}", path))?; + + // Merge system environment variables + for (key, value) in std::env::vars() { + if config.environments.contains_key(&key) { + config.environments.insert(key, value); + } + } + + debug!("Loaded config with {} requests", config.requests.len()); + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[tokio::test] + async fn test_load_config_not_found() { + let result = load_config(Path::new("nonexistent.yml")).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_load_config_basic() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#" +env: + TEST_VAR: test_value + +runner: + no_warm: true + total_rounds: 5 + +variables: + - name: test_id + type: sequence + +requests: + - request: + method: GET + url: http://localhost:8080/test +"# + ) + .unwrap(); + + let result = load_config(file.path()).await; + assert!(result.is_ok()); + + let config = result.unwrap(); + assert_eq!(config.environments.get("TEST_VAR"), Some(&"test_value".to_string())); + assert!(config.runner.no_warm); + assert_eq!(config.runner.total_rounds, 5); + assert_eq!(config.variables.len(), 1); + assert_eq!(config.requests.len(), 1); + } + + #[tokio::test] + async fn test_load_config_with_basic_auth() { + let mut file = NamedTempFile::new().unwrap(); + writeln!( + file, + r#" +runner: + default_basic_auth: + username: admin + password: secret + +requests: + - request: + method: POST + url: http://localhost:8080/api + basic_auth: + username: user + password: pass +"# + ) + .unwrap(); + + let result = load_config(file.path()).await; + assert!(result.is_ok()); + + let config = result.unwrap(); + assert!(config.runner.default_basic_auth.is_some()); + let auth = config.runner.default_basic_auth.unwrap(); + assert_eq!(auth.username, "admin"); + assert_eq!(auth.password, "secret"); + } +} diff --git a/loadgen-rust/src/http/client.rs b/loadgen-rust/src/http/client.rs new file mode 100644 index 0000000..c26277e --- /dev/null +++ b/loadgen-rust/src/http/client.rs @@ -0,0 +1,158 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! HTTP client wrapper for loadgen + +use std::time::Duration; + +use anyhow::Result; +use reqwest::{Client, Response}; +use tracing::debug; + +/// HTTP client configuration +#[derive(Debug, Clone)] +pub struct HttpClientConfig { + /// Request timeout in seconds (0 = no timeout) + pub timeout: u64, + /// Read timeout in seconds + pub read_timeout: u64, + /// Write timeout in seconds + pub write_timeout: u64, + /// Dial/connect timeout in seconds + pub dial_timeout: u64, + /// Enable gzip compression + pub compress: bool, + /// Disable header name normalization + pub disable_header_normalizing: bool, + /// Maximum connections per host + pub max_connections_per_host: usize, + /// User agent string + pub user_agent: String, +} + +impl Default for HttpClientConfig { + fn default() -> Self { + Self { + timeout: 0, + read_timeout: 0, + write_timeout: 0, + dial_timeout: 3, + compress: false, + disable_header_normalizing: false, + max_connections_per_host: 100, + user_agent: format!("loadgen-rust/{}", env!("CARGO_PKG_VERSION")), + } + } +} + +/// HTTP client wrapper +pub struct HttpClient { + client: Client, + config: HttpClientConfig, +} + +impl HttpClient { + /// Create a new HTTP client + pub fn new(config: HttpClientConfig) -> Result { + let mut builder = Client::builder() + .user_agent(&config.user_agent) + .pool_max_idle_per_host(config.max_connections_per_host) + .danger_accept_invalid_certs(true) // Match Go's InsecureSkipVerify + .use_rustls_tls(); + + // Set connect timeout + if config.dial_timeout > 0 { + builder = builder.connect_timeout(Duration::from_secs(config.dial_timeout)); + } + + // Set overall timeout + let effective_timeout = if config.timeout > 0 { + config.timeout + } else if config.read_timeout > 0 { + config.read_timeout + } else { + 0 + }; + + if effective_timeout > 0 { + builder = builder.timeout(Duration::from_secs(effective_timeout)); + } + + // Enable gzip + if config.compress { + builder = builder.gzip(true); + } + + let client = builder.build()?; + debug!("HTTP client created with config: {:?}", config); + + Ok(Self { client, config }) + } + + /// Get the internal reqwest client + pub fn client(&self) -> &Client { + &self.client + } + + /// Get the client configuration + pub fn config(&self) -> &HttpClientConfig { + &self.config + } + + /// Execute a request and return the response + pub async fn execute(&self, request: reqwest::Request) -> Result { + let response = self.client.execute(request).await?; + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = HttpClientConfig::default(); + assert_eq!(config.timeout, 0); + assert_eq!(config.dial_timeout, 3); + assert!(!config.compress); + } + + #[tokio::test] + async fn test_create_client() { + let config = HttpClientConfig::default(); + let client = HttpClient::new(config); + assert!(client.is_ok()); + } + + #[tokio::test] + async fn test_create_client_with_timeout() { + let config = HttpClientConfig { + timeout: 30, + dial_timeout: 5, + ..Default::default() + }; + let client = HttpClient::new(config); + assert!(client.is_ok()); + } +} diff --git a/loadgen-rust/src/http/mod.rs b/loadgen-rust/src/http/mod.rs new file mode 100644 index 0000000..35689d3 --- /dev/null +++ b/loadgen-rust/src/http/mod.rs @@ -0,0 +1,29 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! HTTP module for loadgen + +pub mod client; +pub mod request; + +pub use client::HttpClient; diff --git a/loadgen-rust/src/http/request.rs b/loadgen-rust/src/http/request.rs new file mode 100644 index 0000000..d800c57 --- /dev/null +++ b/loadgen-rust/src/http/request.rs @@ -0,0 +1,314 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! HTTP request building for loadgen + +use std::collections::HashMap; +use std::io::Write; + +use anyhow::{Context, Result}; +use flate2::write::GzEncoder; +use flate2::Compression; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT_ENCODING, CONTENT_ENCODING}; +use reqwest::{Body, Method, Url}; +use tracing::debug; + +use crate::config::types::{Request, RequestItem, RunnerConfig}; +use crate::template::TemplateEngine; +use crate::variable::provider::VariableProvider; + +/// Request builder for loadgen +pub struct RequestBuilder<'a> { + provider: &'a VariableProvider, + runner_config: &'a RunnerConfig, + compress: bool, +} + +impl<'a> RequestBuilder<'a> { + /// Create a new request builder + pub fn new( + provider: &'a VariableProvider, + runner_config: &'a RunnerConfig, + compress: bool, + ) -> Self { + Self { + provider, + runner_config, + compress, + } + } + + /// Build a reqwest Request from a RequestItem + pub fn build( + &self, + item: &RequestItem, + runtime_vars: &mut HashMap, + client: &reqwest::Client, + ) -> Result { + let request = item.request.as_ref().context("Request is None")?; + + // Process runtime variables + for (key, var_name) in &request.runtime_variables { + let value = self.provider.get_value(var_name, Some(runtime_vars)); + runtime_vars.insert(key.clone(), value); + } + + // Build URL + let url = self.build_url(request, runtime_vars)?; + debug!("Request URL: {}", url); + + // Get method + let method = self.parse_method(&request.method)?; + + // Start building request + let mut builder = client.request(method, url); + + // Set basic auth + builder = self.set_basic_auth(builder, request); + + // Build headers + builder = self.set_headers(builder, request, runtime_vars)?; + + // Build body + if !request.body.is_empty() { + let body = self.build_body(request, runtime_vars)?; + + if self.compress && !body.is_empty() { + // Compress body + let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); + encoder.write_all(body.as_bytes())?; + let compressed = encoder.finish()?; + + builder = builder + .header(CONTENT_ENCODING, "gzip") + .header(ACCEPT_ENCODING, "gzip") + .header("X-PayLoad-Compressed", "true") + .body(Body::from(compressed)); + } else { + builder = builder.body(Body::from(body.clone())); + } + + builder = builder.header("X-PayLoad-Size", body.len().to_string()); + } + + builder.build().context("Failed to build request") + } + + /// Build the URL with template substitution + fn build_url( + &self, + request: &Request, + runtime_vars: &HashMap, + ) -> Result { + let url_str = if TemplateEngine::has_template(&request.url) { + TemplateEngine::execute(&request.url, |var| { + self.provider.get_value(var, Some(runtime_vars)) + }) + } else { + request.url.clone() + }; + + // If URL doesn't have a host, use default endpoint + if url_str.starts_with('/') { + if self.runner_config.default_endpoint.is_empty() { + anyhow::bail!("URL is relative but no default_endpoint configured: {}", url_str); + } + let full_url = format!("{}{}", self.runner_config.default_endpoint.trim_end_matches('/'), url_str); + Url::parse(&full_url).context("Failed to parse URL with default endpoint") + } else { + Url::parse(&url_str).context("Failed to parse URL") + } + } + + /// Parse HTTP method + fn parse_method(&self, method: &str) -> Result { + match method.to_uppercase().as_str() { + "GET" => Ok(Method::GET), + "POST" => Ok(Method::POST), + "PUT" => Ok(Method::PUT), + "DELETE" => Ok(Method::DELETE), + "PATCH" => Ok(Method::PATCH), + "HEAD" => Ok(Method::HEAD), + "OPTIONS" => Ok(Method::OPTIONS), + _ => anyhow::bail!("Unsupported HTTP method: {}", method), + } + } + + /// Set basic auth on request + fn set_basic_auth( + &self, + mut builder: reqwest::RequestBuilder, + request: &Request, + ) -> reqwest::RequestBuilder { + // Check request-level auth first + if let Some(ref auth) = request.basic_auth { + if !auth.username.is_empty() { + builder = builder.basic_auth(&auth.username, Some(&auth.password)); + return builder; + } + } + + // Fall back to default auth + if let Some(ref auth) = self.runner_config.default_basic_auth { + if !auth.username.is_empty() { + builder = builder.basic_auth(&auth.username, Some(&auth.password)); + } + } + + builder + } + + /// Set headers on request + fn set_headers( + &self, + mut builder: reqwest::RequestBuilder, + request: &Request, + runtime_vars: &HashMap, + ) -> Result { + let mut headers = HeaderMap::new(); + + for header_map in &request.headers { + for (key, value) in header_map { + let header_value = if TemplateEngine::has_template(value) { + TemplateEngine::execute(value, |var| { + self.provider.get_value(var, Some(runtime_vars)) + }) + } else { + value.clone() + }; + + let name = HeaderName::try_from(key.as_str()) + .context(format!("Invalid header name: {}", key))?; + let value = HeaderValue::from_str(&header_value) + .context(format!("Invalid header value: {}", header_value))?; + headers.insert(name, value); + } + } + + builder = builder.headers(headers); + Ok(builder) + } + + /// Build request body with template substitution and body repetition + fn build_body( + &self, + request: &Request, + runtime_vars: &mut HashMap, + ) -> Result { + let repeat_times = if request.repeat_body_n_times > 0 { + request.repeat_body_n_times as usize + } else { + 1 + }; + + let mut result = String::new(); + + for _ in 0..repeat_times { + // Process runtime body line variables for each repetition + for (key, var_name) in &request.runtime_body_line_variables { + let value = self.provider.get_value(var_name, Some(runtime_vars)); + runtime_vars.insert(key.clone(), value); + } + + let body = if TemplateEngine::has_template(&request.body) { + TemplateEngine::execute(&request.body, |var| { + self.provider.get_value(var, Some(runtime_vars)) + }) + } else { + request.body.clone() + }; + + result.push_str(&body); + } + + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_method() { + let provider = VariableProvider::new(&[], HashMap::new()); + let runner_config = RunnerConfig::default(); + let builder = RequestBuilder::new(&provider, &runner_config, false); + + assert_eq!(builder.parse_method("GET").unwrap(), Method::GET); + assert_eq!(builder.parse_method("post").unwrap(), Method::POST); + assert_eq!(builder.parse_method("Put").unwrap(), Method::PUT); + assert!(builder.parse_method("INVALID").is_err()); + } + + #[test] + fn test_build_url_absolute() { + let provider = VariableProvider::new(&[], HashMap::new()); + let runner_config = RunnerConfig::default(); + let builder = RequestBuilder::new(&provider, &runner_config, false); + + let request = Request { + url: "http://localhost:9200/test".to_string(), + ..Default::default() + }; + + let url = builder.build_url(&request, &HashMap::new()).unwrap(); + assert_eq!(url.to_string(), "http://localhost:9200/test"); + } + + #[test] + fn test_build_url_relative() { + let provider = VariableProvider::new(&[], HashMap::new()); + let runner_config = RunnerConfig { + default_endpoint: "http://localhost:9200".to_string(), + ..Default::default() + }; + let builder = RequestBuilder::new(&provider, &runner_config, false); + + let request = Request { + url: "/test/_search".to_string(), + ..Default::default() + }; + + let url = builder.build_url(&request, &HashMap::new()).unwrap(); + assert_eq!(url.to_string(), "http://localhost:9200/test/_search"); + } + + #[test] + fn test_build_url_with_template() { + let mut env_vars = HashMap::new(); + env_vars.insert("ES_ENDPOINT".to_string(), "http://localhost:9200".to_string()); + + let provider = VariableProvider::new(&[], env_vars); + let runner_config = RunnerConfig::default(); + let builder = RequestBuilder::new(&provider, &runner_config, false); + + let request = Request { + url: "$[[env.ES_ENDPOINT]]/test".to_string(), + ..Default::default() + }; + + let url = builder.build_url(&request, &HashMap::new()).unwrap(); + assert_eq!(url.to_string(), "http://localhost:9200/test"); + } +} diff --git a/loadgen-rust/src/lib.rs b/loadgen-rust/src/lib.rs new file mode 100644 index 0000000..eda365e --- /dev/null +++ b/loadgen-rust/src/lib.rs @@ -0,0 +1,39 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! INFINI Loadgen - A high-performance HTTP load generator and testing suite +//! +//! This is a Rust implementation of the loadgen tool, supporting the same +//! YAML configuration and DSL formats as the original Go implementation. + +pub mod assertion; +pub mod config; +pub mod http; +pub mod loader; +pub mod runner; +pub mod template; +pub mod variable; + +pub use config::types::{AppConfig, LoaderConfig, RunnerConfig}; +pub use loader::generator::LoadGenerator; +pub use loader::stats::LoadStats; diff --git a/loadgen-rust/src/loader/generator.rs b/loadgen-rust/src/loader/generator.rs new file mode 100644 index 0000000..b9ad4cb --- /dev/null +++ b/loadgen-rust/src/loader/generator.rs @@ -0,0 +1,288 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Load generator - orchestrates workers and aggregates statistics + +use std::collections::HashMap; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use tokio::task::JoinSet; +use tracing::{error, info, warn}; + +use super::stats::{AggregatedStats, LoadStats}; +use super::worker::{Worker, WorkerConfig}; +use crate::config::types::LoaderConfig; +use crate::http::client::{HttpClient, HttpClientConfig}; +use crate::http::request::RequestBuilder; +use crate::variable::provider::VariableProvider; + +/// CLI options passed to the generator +#[derive(Debug, Clone)] +pub struct CliOptions { + /// Number of concurrent workers + pub concurrency: usize, + /// Duration in seconds + pub duration: u64, + /// Rate limit (requests per second) + pub rate_limit: i64, + /// Total request limit + pub request_limit: i64, + /// Request timeout + pub timeout: u64, + /// Read timeout + pub read_timeout: u64, + /// Write timeout + pub write_timeout: u64, + /// Dial timeout + pub dial_timeout: u64, + /// Enable compression + pub compress: bool, + /// Total rounds + pub total_rounds: i64, + /// Debug mode + pub debug: bool, +} + +impl Default for CliOptions { + fn default() -> Self { + Self { + concurrency: 1, + duration: 5, + rate_limit: -1, + request_limit: -1, + timeout: 0, + read_timeout: 0, + write_timeout: 0, + dial_timeout: 3, + compress: false, + total_rounds: -1, + debug: false, + } + } +} + +/// Load generator +pub struct LoadGenerator { + config: LoaderConfig, + options: CliOptions, + interrupted: Arc, + http_client: Arc, + provider: Arc, +} + +impl LoadGenerator { + /// Create a new load generator + pub fn new( + config: LoaderConfig, + options: CliOptions, + interrupted: Arc, + ) -> Result { + // Create HTTP client + let http_config = HttpClientConfig { + timeout: options.timeout, + read_timeout: options.read_timeout, + write_timeout: options.write_timeout, + dial_timeout: options.dial_timeout, + compress: options.compress, + disable_header_normalizing: config.runner.disable_header_names_normalizing, + max_connections_per_host: options.concurrency, + ..Default::default() + }; + let http_client = Arc::new(HttpClient::new(http_config)?); + + // Create variable provider with environment variables + let env_vars: HashMap = std::env::vars().collect(); + let provider = Arc::new(VariableProvider::new(&config.variables, env_vars)); + + Ok(Self { + config, + options, + interrupted, + http_client, + provider, + }) + } + + /// Run the load generator + pub async fn run(&mut self) -> Result { + // Override total_rounds from CLI if specified + let total_rounds = if self.options.total_rounds > 0 { + self.options.total_rounds + } else { + self.config.runner.total_rounds + }; + + // Warmup phase + if !self.config.runner.no_warm { + self.warmup().await?; + } + + // Calculate request limit per worker + let request_limit = self.options.request_limit; + let concurrency = if request_limit > 0 && (request_limit as usize) < self.options.concurrency { + request_limit as usize + } else { + self.options.concurrency + }; + + let requests_per_worker = if request_limit > 0 { + (request_limit + concurrency as i64 - 1) / concurrency as i64 + } else { + -1 + }; + + // Create shared stats + let stats = Arc::new(LoadStats::new()); + + // Start wall clock + let wall_start = Instant::now(); + + // Spawn workers + let mut tasks = JoinSet::new(); + + for i in 0..concurrency { + let worker_request_limit = if requests_per_worker > 0 { + let remaining = request_limit - (i as i64 * requests_per_worker); + remaining.min(requests_per_worker) + } else { + -1 + }; + + let worker_config = WorkerConfig { + duration: Duration::from_secs(self.options.duration), + request_limit: worker_request_limit, + rate_limit: self.options.rate_limit, + total_rounds, + compress: self.options.compress, + debug: self.options.debug, + }; + + let worker = Worker::new( + self.config.clone(), + worker_config, + Arc::clone(&self.http_client), + Arc::clone(&self.provider), + Arc::clone(&stats), + Arc::clone(&self.interrupted), + ); + + tasks.spawn(async move { + if let Err(e) = worker.run().await { + error!("Worker error: {}", e); + } + }); + } + + // Wait for all workers to complete + while let Some(result) = tasks.join_next().await { + if let Err(e) = result { + error!("Worker task panicked: {}", e); + } + } + + let wall_time = wall_start.elapsed(); + + // Aggregate and print stats + let aggregated = AggregatedStats::from(stats.as_ref()); + + if aggregated.num_requests == 0 { + error!("Error: No statistics collected / no requests found"); + } else { + aggregated.print(wall_time, &self.config.runner); + } + + Ok(aggregated) + } + + /// Run warmup phase + async fn warmup(&self) -> Result<()> { + info!("warmup started"); + + let global_ctx: HashMap = HashMap::new(); + + for item in &self.config.requests { + let _request = match &item.request { + Some(r) => r, + None => continue, + }; + + // Build request + let mut runtime_vars = global_ctx.clone(); + let request_builder = RequestBuilder::new( + &self.provider, + &self.config.runner, + self.options.compress, + ); + + let req = match request_builder.build(item, &mut runtime_vars, self.http_client.client()) { + Ok(r) => r, + Err(e) => { + error!("Failed to build warmup request: {}", e); + continue; + } + }; + + info!("[{}] {}", req.method(), req.url()); + + // Execute request + let start = Instant::now(); + let response = self.http_client.execute(req).await; + let duration = start.elapsed(); + + match response { + Ok(resp) => { + let status = resp.status().as_u16() as i32; + let body = resp.text().await.unwrap_or_default(); + + info!( + "status: {}, duration: {:?}, response: {}", + status, + duration, + if body.len() > 512 { &body[..512] } else { &body } + ); + + // Check if status indicates failure + let is_valid = self.config.runner.valid_status_codes_during_warmup.is_empty() + || self.config.runner.valid_status_codes_during_warmup.contains(&status); + + if !is_valid && (status >= 400 || status == 0) { + warn!( + "Warmup request returned status {}, are you sure to continue?", + status + ); + } + } + Err(e) => { + error!("Warmup request failed: {}", e); + warn!("Warmup request failed, are you sure to continue?"); + } + } + } + + info!("warmup finished"); + Ok(()) + } +} diff --git a/loadgen-rust/src/loader/mod.rs b/loadgen-rust/src/loader/mod.rs new file mode 100644 index 0000000..e7f9f0e --- /dev/null +++ b/loadgen-rust/src/loader/mod.rs @@ -0,0 +1,28 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Loader module for loadgen + +pub mod generator; +pub mod stats; +pub mod worker; diff --git a/loadgen-rust/src/loader/stats.rs b/loadgen-rust/src/loader/stats.rs new file mode 100644 index 0000000..a4296f0 --- /dev/null +++ b/loadgen-rust/src/loader/stats.rs @@ -0,0 +1,375 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Load statistics collection + +use std::collections::HashMap; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::time::Duration; + +use bytesize::ByteSize; +use hdrhistogram::Histogram; +use parking_lot::Mutex; + +/// Statistics collected during load generation +#[derive(Debug)] +pub struct LoadStats { + /// Total request size in bytes + pub total_request_size: AtomicI64, + /// Total response size in bytes + pub total_response_size: AtomicI64, + /// Total duration of all requests + pub total_duration: AtomicU64, + /// Minimum request time + pub min_request_time: AtomicU64, + /// Maximum request time + pub max_request_time: AtomicU64, + /// Number of requests completed + pub num_requests: AtomicU64, + /// Number of errors + pub num_errors: AtomicU64, + /// Number of invalid assertions + pub num_assert_invalid: AtomicU64, + /// Number of skipped assertions + pub num_assert_skipped: AtomicU64, + /// Status code counts + pub status_codes: Mutex>, + /// Latency histogram + pub histogram: Mutex>, +} + +impl Default for LoadStats { + fn default() -> Self { + Self::new() + } +} + +impl LoadStats { + /// Create new empty statistics + pub fn new() -> Self { + Self { + total_request_size: AtomicI64::new(0), + total_response_size: AtomicI64::new(0), + total_duration: AtomicU64::new(0), + min_request_time: AtomicU64::new(u64::MAX), + max_request_time: AtomicU64::new(0), + num_requests: AtomicU64::new(0), + num_errors: AtomicU64::new(0), + num_assert_invalid: AtomicU64::new(0), + num_assert_skipped: AtomicU64::new(0), + status_codes: Mutex::new(HashMap::new()), + histogram: Mutex::new(Histogram::new(3).unwrap()), + } + } + + /// Record a request result + pub fn record( + &self, + duration: Duration, + request_size: i64, + response_size: i64, + status: i32, + is_error: bool, + ) { + let duration_us = duration.as_micros() as u64; + + self.num_requests.fetch_add(1, Ordering::Relaxed); + self.total_duration.fetch_add(duration_us, Ordering::Relaxed); + self.total_request_size.fetch_add(request_size, Ordering::Relaxed); + self.total_response_size.fetch_add(response_size, Ordering::Relaxed); + + // Update min/max atomically + let mut current_min = self.min_request_time.load(Ordering::Relaxed); + while duration_us < current_min { + match self.min_request_time.compare_exchange_weak( + current_min, + duration_us, + Ordering::SeqCst, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current_min = x, + } + } + + let mut current_max = self.max_request_time.load(Ordering::Relaxed); + while duration_us > current_max { + match self.max_request_time.compare_exchange_weak( + current_max, + duration_us, + Ordering::SeqCst, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current_max = x, + } + } + + // Update status codes + { + let mut codes = self.status_codes.lock(); + *codes.entry(status).or_insert(0) += 1; + } + + // Update histogram + { + let mut hist = self.histogram.lock(); + let _ = hist.record(duration_us); + } + + if is_error { + self.num_errors.fetch_add(1, Ordering::Relaxed); + } + } + + /// Record an assertion failure + pub fn record_assert_invalid(&self) { + self.num_assert_invalid.fetch_add(1, Ordering::Relaxed); + } + + /// Record a skipped assertion + pub fn record_assert_skipped(&self) { + self.num_assert_skipped.fetch_add(1, Ordering::Relaxed); + } + + /// Merge another stats instance into this one + pub fn merge(&self, other: &LoadStats) { + self.total_request_size.fetch_add( + other.total_request_size.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + self.total_response_size.fetch_add( + other.total_response_size.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + self.total_duration.fetch_add( + other.total_duration.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + self.num_requests.fetch_add( + other.num_requests.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + self.num_errors.fetch_add( + other.num_errors.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + self.num_assert_invalid.fetch_add( + other.num_assert_invalid.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + self.num_assert_skipped.fetch_add( + other.num_assert_skipped.load(Ordering::Relaxed), + Ordering::Relaxed, + ); + + // Merge min + let other_min = other.min_request_time.load(Ordering::Relaxed); + let mut current_min = self.min_request_time.load(Ordering::Relaxed); + while other_min < current_min { + match self.min_request_time.compare_exchange_weak( + current_min, + other_min, + Ordering::SeqCst, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current_min = x, + } + } + + // Merge max + let other_max = other.max_request_time.load(Ordering::Relaxed); + let mut current_max = self.max_request_time.load(Ordering::Relaxed); + while other_max > current_max { + match self.max_request_time.compare_exchange_weak( + current_max, + other_max, + Ordering::SeqCst, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(x) => current_max = x, + } + } + + // Merge status codes + { + let other_codes = other.status_codes.lock(); + let mut self_codes = self.status_codes.lock(); + for (k, v) in other_codes.iter() { + *self_codes.entry(*k).or_insert(0) += v; + } + } + + // Merge histogram + { + let other_hist = other.histogram.lock(); + let mut self_hist = self.histogram.lock(); + let _ = self_hist.add(&*other_hist); + } + } +} + +/// Final aggregated statistics for display +pub struct AggregatedStats { + pub num_requests: u64, + pub num_errors: u64, + pub num_assert_invalid: u64, + pub num_assert_skipped: u64, + pub total_request_size: i64, + pub total_response_size: i64, + pub min_request_time: Duration, + pub max_request_time: Duration, + pub avg_request_time: Duration, + pub total_duration: Duration, + pub status_codes: HashMap, + pub p50: Duration, + pub p75: Duration, + pub p95: Duration, + pub p99: Duration, + pub p999: Duration, +} + +impl From<&LoadStats> for AggregatedStats { + fn from(stats: &LoadStats) -> Self { + let num_requests = stats.num_requests.load(Ordering::Relaxed); + let total_duration_us = stats.total_duration.load(Ordering::Relaxed); + let min_us = stats.min_request_time.load(Ordering::Relaxed); + let max_us = stats.max_request_time.load(Ordering::Relaxed); + + let histogram = stats.histogram.lock(); + + AggregatedStats { + num_requests, + num_errors: stats.num_errors.load(Ordering::Relaxed), + num_assert_invalid: stats.num_assert_invalid.load(Ordering::Relaxed), + num_assert_skipped: stats.num_assert_skipped.load(Ordering::Relaxed), + total_request_size: stats.total_request_size.load(Ordering::Relaxed), + total_response_size: stats.total_response_size.load(Ordering::Relaxed), + min_request_time: Duration::from_micros(if min_us == u64::MAX { 0 } else { min_us }), + max_request_time: Duration::from_micros(max_us), + avg_request_time: if num_requests > 0 { + Duration::from_micros(total_duration_us / num_requests) + } else { + Duration::ZERO + }, + total_duration: Duration::from_micros(total_duration_us), + status_codes: stats.status_codes.lock().clone(), + p50: Duration::from_micros(histogram.value_at_quantile(0.50)), + p75: Duration::from_micros(histogram.value_at_quantile(0.75)), + p95: Duration::from_micros(histogram.value_at_quantile(0.95)), + p99: Duration::from_micros(histogram.value_at_quantile(0.99)), + p999: Duration::from_micros(histogram.value_at_quantile(0.999)), + } + } +} + +impl AggregatedStats { + /// Print statistics in the format matching the Go implementation + pub fn print(&self, wall_time: Duration, config: &crate::config::types::RunnerConfig) { + let req_rate = self.num_requests as f64 / wall_time.as_secs_f64(); + let req_bytes_rate = self.total_request_size as f64 / wall_time.as_secs_f64(); + let total_bytes_rate = (self.total_request_size + self.total_response_size) as f64 + / wall_time.as_secs_f64(); + + // Summary line + if config.no_size_stats { + println!( + "\n{} requests finished in {:?}", + self.num_requests, wall_time + ); + } else { + println!( + "\n{} requests finished in {:?}, {} sent, {} received", + self.num_requests, + wall_time, + ByteSize::b(self.total_request_size as u64), + ByteSize::b(self.total_response_size as u64) + ); + } + + // Client metrics + println!("\n[Loadgen Client Metrics]"); + println!("Requests/sec:\t\t{:.2}", req_rate); + + if !config.benchmark_only && !config.no_size_stats { + println!("Request Traffic/sec:\t{}/s", ByteSize::b(req_bytes_rate as u64)); + println!("Total Transfer/sec:\t{}/s", ByteSize::b(total_bytes_rate as u64)); + } + + println!("Fastest Request:\t{:?}", self.min_request_time); + println!("Slowest Request:\t{:?}", self.max_request_time); + + if config.assert_error { + println!("Number of Errors:\t{}", self.num_errors); + } + + if config.assert_invalid { + println!("Assert Invalid:\t\t{}", self.num_assert_invalid); + println!("Assert Skipped:\t\t{}", self.num_assert_skipped); + } + + // Status codes + let mut sorted_codes: Vec<_> = self.status_codes.iter().collect(); + sorted_codes.sort_by_key(|(k, _)| *k); + for (code, count) in sorted_codes { + println!("Status {}:\t\t{}", code, count); + } + + // Latency metrics + if !config.benchmark_only && !config.no_stats { + println!("\n[Latency Metrics]"); + println!( + "{} samples of {} events", + self.num_requests, self.num_requests + ); + println!("Avg:\t\t{:?}", self.avg_request_time); + println!("p50:\t\t{:?}", self.p50); + println!("p75:\t\t{:?}", self.p75); + println!("p95:\t\t{:?}", self.p95); + println!("p99:\t\t{:?}", self.p99); + println!("p999:\t\t{:?}", self.p999); + println!("Max:\t\t{:?}", self.max_request_time); + println!("Min:\t\t{:?}", self.min_request_time); + } + + // Estimated server metrics + println!("\n[Estimated Server Metrics]"); + let server_req_rate = if self.total_duration.as_secs_f64() > 0.0 { + self.num_requests as f64 / self.total_duration.as_secs_f64() + } else { + 0.0 + }; + println!("Requests/sec:\t\t{:.2}", server_req_rate); + println!("Avg Req Time:\t\t{:?}", self.avg_request_time); + + if !config.benchmark_only && !config.no_size_stats { + let server_bytes_rate = (self.total_request_size + self.total_response_size) as f64 + / self.total_duration.as_secs_f64(); + println!("Transfer/sec:\t\t{}/s", ByteSize::b(server_bytes_rate as u64)); + } + + println!(); + } +} diff --git a/loadgen-rust/src/loader/worker.rs b/loadgen-rust/src/loader/worker.rs new file mode 100644 index 0000000..90afd26 --- /dev/null +++ b/loadgen-rust/src/loader/worker.rs @@ -0,0 +1,345 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Worker task for executing requests + +use std::collections::HashMap; +use std::num::NonZeroU32; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use governor::{Quota, RateLimiter}; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +use super::stats::LoadStats; +use crate::assertion::conditions::AssertionChecker; +use crate::config::types::{LoaderConfig, RequestItem}; +use crate::http::client::HttpClient; +use crate::http::request::RequestBuilder; +use crate::variable::provider::VariableProvider; + +/// Worker configuration +pub struct WorkerConfig { + /// Maximum duration for the worker to run + pub duration: Duration, + /// Maximum number of requests to send (-1 = unlimited) + pub request_limit: i64, + /// Rate limit (requests per second, -1 = unlimited) + pub rate_limit: i64, + /// Total number of rounds to run (-1 = unlimited) + pub total_rounds: i64, + /// Enable compression + pub compress: bool, + /// Debug mode + pub debug: bool, +} + +/// Worker for executing HTTP requests +pub struct Worker { + config: LoaderConfig, + worker_config: WorkerConfig, + http_client: Arc, + provider: Arc, + stats: Arc, + interrupted: Arc, + rate_limiter: Option>>, +} + +impl Worker { + /// Create a new worker + pub fn new( + config: LoaderConfig, + worker_config: WorkerConfig, + http_client: Arc, + provider: Arc, + stats: Arc, + interrupted: Arc, + ) -> Self { + // Create rate limiter if needed + let rate_limiter = if worker_config.rate_limit > 0 { + let rate = NonZeroU32::new(worker_config.rate_limit as u32) + .unwrap_or(NonZeroU32::new(1).unwrap()); + let quota = Quota::per_second(rate); + Some(Arc::new(RateLimiter::direct(quota))) + } else { + None + }; + + Self { + config, + worker_config, + http_client, + provider, + stats, + interrupted, + rate_limiter, + } + } + + /// Run the worker + pub async fn run(&self) -> Result<()> { + let start = Instant::now(); + let mut total_requests = 0i64; + let mut total_rounds = 0i64; + let mut global_ctx: HashMap = HashMap::new(); + + loop { + // Check if interrupted + if self.interrupted.load(Ordering::Relaxed) { + debug!("Worker interrupted"); + break; + } + + // Check duration limit + if start.elapsed() >= self.worker_config.duration { + debug!("Duration limit reached"); + break; + } + + // Check rounds limit + if self.worker_config.total_rounds > 0 && total_rounds >= self.worker_config.total_rounds { + debug!("Rounds limit reached"); + break; + } + + total_rounds += 1; + + // Execute all requests in the config + for item in &self.config.requests { + // Check request limit + if self.worker_config.request_limit > 0 + && total_requests >= self.worker_config.request_limit + { + debug!("Request limit reached"); + return Ok(()); + } + + // Rate limiting + if let Some(ref limiter) = self.rate_limiter { + while limiter.check().is_err() { + if self.interrupted.load(Ordering::Relaxed) { + return Ok(()); + } + sleep(Duration::from_millis(10)).await; + } + } + + // Execute request + let continue_next = self.execute_request(item, &mut global_ctx).await?; + total_requests += 1; + + if !continue_next { + break; + } + } + } + + debug!("Worker completed: {} requests in {:?}", total_requests, start.elapsed()); + Ok(()) + } + + /// Execute a single request + async fn execute_request( + &self, + item: &RequestItem, + global_ctx: &mut HashMap, + ) -> Result { + let request = match &item.request { + Some(r) => r, + None => return Ok(true), + }; + + let repeat_times = if request.execute_repeat_times > 0 { + request.execute_repeat_times as usize + } else { + 1 + }; + + for _ in 0..repeat_times { + let mut runtime_vars = global_ctx.clone(); + + // Build request + let request_builder = RequestBuilder::new( + &self.provider, + &self.config.runner, + self.worker_config.compress, + ); + + let req = match request_builder.build(item, &mut runtime_vars, self.http_client.client()) { + Ok(r) => r, + Err(e) => { + error!("Failed to build request: {}", e); + self.stats.num_errors.fetch_add(1, Ordering::Relaxed); + continue; + } + }; + + // Get request size + let request_size = req.body().map(|b| b.as_bytes().map(|b| b.len()).unwrap_or(0)).unwrap_or(0) as i64; + + // Log request if configured + if self.config.runner.log_requests { + info!( + "[{}] {}, {:?}", + req.method(), + req.url(), + req.headers() + ); + } + + // Execute request + let start = Instant::now(); + let response = self.http_client.execute(req).await; + let duration = start.elapsed(); + + match response { + Ok(resp) => { + let status = resp.status().as_u16() as i32; + let response_size = resp.content_length().unwrap_or(0) as i64; + + // Log response if configured + if self.config.runner.log_requests + || self.config.runner.log_status_codes.contains(&status) + { + info!("status: {}, duration: {:?}", status, duration); + } + + // Check for error status + let is_error = status >= 400 || status == 0; + self.stats.record(duration, request_size, response_size, status, is_error); + + // Handle assertions + if let Some(ref assert_config) = item.assert { + // Get response body for assertions + let body = resp.text().await.unwrap_or_default(); + + // Build context for assertions + let ctx = build_assert_context(status, &body, duration); + + // Check assertions + let checker = AssertionChecker::new(&ctx); + if !checker.check(assert_config) { + self.stats.record_assert_invalid(); + + if !self.config.runner.continue_on_assert_invalid { + warn!( + "{} {}, assertion failed, skipping subsequent requests", + request.method, request.url + ); + return Ok(false); + } + } + } + + // Handle register + for register_map in &item.register { + for (dest, src) in register_map { + if let Some(value) = get_ctx_value(&build_assert_context(status, "", duration), src) { + global_ctx.insert(dest.clone(), value); + } + } + } + } + Err(e) => { + error!("Request failed: {}", e); + self.stats.record(duration, request_size, 0, 0, true); + self.stats.record_assert_invalid(); + } + } + + // Handle sleep + if let Some(ref sleep_action) = item.sleep { + if sleep_action.sleep_in_milli_seconds > 0 { + sleep(Duration::from_millis(sleep_action.sleep_in_milli_seconds as u64)).await; + } + } + } + + Ok(true) + } +} + +/// Build assertion context from response +fn build_assert_context(status: i32, body: &str, duration: Duration) -> HashMap { + let mut ctx = HashMap::new(); + + // Response context + ctx.insert("_ctx.response.status".to_string(), serde_json::json!(status)); + ctx.insert("_ctx.response.body".to_string(), serde_json::json!(body)); + ctx.insert("_ctx.response.body_length".to_string(), serde_json::json!(body.len())); + ctx.insert("_ctx.elapsed".to_string(), serde_json::json!(duration.as_millis() as i64)); + + // Try to parse body as JSON + if let Ok(json) = serde_json::from_str::(body) { + ctx.insert("_ctx.response.body_json".to_string(), json); + } + + ctx +} + +/// Get a value from the context by path +fn get_ctx_value(ctx: &HashMap, path: &str) -> Option { + // Direct lookup first + if let Some(value) = ctx.get(path) { + return Some(value.to_string()); + } + + // Try JSON path lookup for body_json + if path.starts_with("_ctx.response.body_json.") { + if let Some(body_json) = ctx.get("_ctx.response.body_json") { + let json_path = path.strip_prefix("_ctx.response.body_json.")?; + return get_json_value(body_json, json_path); + } + } + + None +} + +/// Get a value from JSON by path (e.g., "foo.bar.baz") +fn get_json_value(json: &serde_json::Value, path: &str) -> Option { + let parts: Vec<&str> = path.split('.').collect(); + let mut current = json; + + for part in parts { + match current { + serde_json::Value::Object(map) => { + current = map.get(part)?; + } + serde_json::Value::Array(arr) => { + let idx: usize = part.parse().ok()?; + current = arr.get(idx)?; + } + _ => return None, + } + } + + match current { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + serde_json::Value::Bool(b) => Some(b.to_string()), + _ => Some(current.to_string()), + } +} diff --git a/loadgen-rust/src/main.rs b/loadgen-rust/src/main.rs new file mode 100644 index 0000000..370e9c8 --- /dev/null +++ b/loadgen-rust/src/main.rs @@ -0,0 +1,281 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! INFINI Loadgen - Main entry point +//! +//! A high-performance HTTP load generator and testing suite. + +use anyhow::Result; +use clap::Parser; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tracing::{error, info}; + +use loadgen::config::yaml::load_config; +use loadgen::loader::generator::LoadGenerator; +use loadgen::runner::test::TestRunner; + +/// CLI argument parser +#[derive(Parser, Debug)] +#[command( + name = "loadgen", + about = "A high-performance HTTP load generator and testing suite", + version, + author = "INFINI Labs ", + before_help = r#" + __ ___ _ ___ ___ __ __ + / / /___\/_\ / \/ _ \ /__\/\ \ \ + / / // ///_\\ / /\ / /_\//_\ / \/ / +/ /__/ \_// _ \/ /_// /_\\//__/ /\ / +\____|___/\_/ \_/___,'\____/\__/\_\ \/ + +HOME: https://github.com/infinilabs/loadgen/ +"# +)] +struct Cli { + /// Number of concurrent threads to use + #[arg(short = 'c', long = "concurrency", default_value = "1")] + concurrency: usize, + + /// Duration of the test in seconds + #[arg(short = 'd', long = "duration", default_value = "5")] + duration: u64, + + /// Maximum requests per second (fixed QPS), -1 for unlimited + #[arg(short = 'r', long = "rate", default_value = "-1")] + rate_limit: i64, + + /// Total number of requests to send, -1 for unlimited + #[arg(short = 'l', long = "limit", default_value = "-1")] + request_limit: i64, + + /// Request timeout in seconds, 0 for no timeout + #[arg(long = "timeout", default_value = "0")] + timeout: u64, + + /// Connection read timeout in seconds, 0 inherits from timeout + #[arg(long = "read-timeout", default_value = "0")] + read_timeout: u64, + + /// Connection write timeout in seconds, 0 inherits from timeout + #[arg(long = "write-timeout", default_value = "0")] + write_timeout: u64, + + /// Connection dial timeout in seconds + #[arg(long = "dial-timeout", default_value = "3")] + dial_timeout: u64, + + /// Enable gzip compression for requests + #[arg(long = "compress", default_value = "false")] + compress: bool, + + /// Enable mixed requests from YAML/DSL + #[arg(long = "mixed", default_value = "false")] + mixed: bool, + + /// Number of rounds for each request configuration, -1 for unlimited + #[arg(long = "total-rounds", default_value = "-1")] + total_rounds: i64, + + /// Path to a DSL-based request file to execute + #[arg(long = "run")] + dsl_file: Option, + + /// Path to YAML config file + #[arg(short = 'C', long = "config", default_value = "loadgen.yml")] + config: PathBuf, + + /// Log level (trace, debug, info, warn, error) + #[arg(long = "log", default_value = "info")] + log_level: String, + + /// Run in debug mode + #[arg(long = "debug", default_value = "false")] + debug: bool, +} + +fn print_header() { + println!( + r#" + __ ___ _ ___ ___ __ __ + / / /___\/_\ / \/ _ \ /__\/\ \ \ + / / // ///_\\ / /\ / /_\//_\ / \/ / +/ /__/ \_// _ \/ /_// /_\\//__/ /\ / +\____|___/\_/ \_/___,'\____/\__/\_\ \/ + +HOME: https://github.com/infinilabs/loadgen/ +"# + ); +} + +fn setup_logging(log_level: &str, debug: bool) { + let level = if debug { + "debug" + } else { + log_level + }; + + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(level)); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(false) + .with_thread_ids(false) + .init(); +} + +#[tokio::main] +async fn main() -> ExitCode { + let cli = Cli::parse(); + + print_header(); + setup_logging(&cli.log_level, cli.debug); + + // Setup Ctrl+C handler + let interrupted = Arc::new(AtomicBool::new(false)); + let interrupted_clone = Arc::clone(&interrupted); + + ctrlc::set_handler(move || { + info!("Received interrupt signal, shutting down..."); + interrupted_clone.store(true, Ordering::SeqCst); + }) + .expect("Failed to set Ctrl+C handler"); + + // Load configuration + let config = match load_config(&cli.config).await { + Ok(config) => config, + Err(e) => { + error!("Failed to load config: {}", e); + return ExitCode::from(1); + } + }; + + // Create CLI options + let cli_options = loadgen::loader::generator::CliOptions { + concurrency: cli.concurrency, + duration: cli.duration, + rate_limit: cli.rate_limit, + request_limit: cli.request_limit, + timeout: cli.timeout, + read_timeout: cli.read_timeout, + write_timeout: cli.write_timeout, + dial_timeout: cli.dial_timeout, + compress: cli.compress, + total_rounds: cli.total_rounds, + debug: cli.debug, + }; + + let mut exit_code = 0; + + // Run DSL file if specified + if let Some(dsl_file) = &cli.dsl_file { + info!("Running DSL based requests from {:?}", dsl_file); + match run_dsl_file(&config, dsl_file, &cli_options, Arc::clone(&interrupted)).await { + Ok(status) => { + if status != 0 { + exit_code = status; + } + } + Err(e) => { + error!("Failed to run DSL file: {}", e); + return ExitCode::from(1); + } + } + if !cli.mixed { + return ExitCode::from(exit_code as u8); + } + } + + // Run YAML-based requests + if !config.requests.is_empty() { + info!("Running YAML based requests"); + match run_loader(&config, &cli_options, Arc::clone(&interrupted)).await { + Ok(status) => { + if status != 0 { + exit_code = status; + } + } + Err(e) => { + error!("Failed to run loader: {}", e); + return ExitCode::from(2); + } + } + if !cli.mixed { + return ExitCode::from(exit_code as u8); + } + } + + // Run test suite if configured + if !config.tests.is_empty() { + info!("Running test suite"); + let runner = TestRunner::new(&config); + if !runner.run().await { + exit_code = 1; + } + } + + ExitCode::from(exit_code as u8) +} + +async fn run_dsl_file( + config: &loadgen::AppConfig, + path: &Path, + cli_options: &loadgen::loader::generator::CliOptions, + interrupted: Arc, +) -> Result { + use loadgen::config::dsl::parse_dsl_file; + + let loader_config = parse_dsl_file(path, config).await?; + run_loader_config(&loader_config, cli_options, interrupted).await +} + +async fn run_loader( + config: &loadgen::AppConfig, + cli_options: &loadgen::loader::generator::CliOptions, + interrupted: Arc, +) -> Result { + let loader_config = config.to_loader_config(); + run_loader_config(&loader_config, cli_options, interrupted).await +} + +async fn run_loader_config( + config: &loadgen::LoaderConfig, + cli_options: &loadgen::loader::generator::CliOptions, + interrupted: Arc, +) -> Result { + let mut generator = LoadGenerator::new(config.clone(), cli_options.clone(), interrupted)?; + let stats = generator.run().await?; + + // Check assertions + if config.runner.assert_invalid && stats.num_assert_invalid > 0 { + return Ok(1); + } + if config.runner.assert_error && stats.num_errors > 0 { + return Ok(2); + } + + Ok(0) +} diff --git a/loadgen-rust/src/runner/mod.rs b/loadgen-rust/src/runner/mod.rs new file mode 100644 index 0000000..2d57c7d --- /dev/null +++ b/loadgen-rust/src/runner/mod.rs @@ -0,0 +1,26 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Runner module for loadgen + +pub mod test; diff --git a/loadgen-rust/src/runner/test.rs b/loadgen-rust/src/runner/test.rs new file mode 100644 index 0000000..ffe4715 --- /dev/null +++ b/loadgen-rust/src/runner/test.rs @@ -0,0 +1,174 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Test runner for executing test suites + +use std::path::Path; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Instant; + +use chrono::Local; +use tracing::{debug, error, info}; + +use crate::config::dsl::parse_dsl_file; +use crate::config::types::AppConfig; +use crate::loader::generator::{CliOptions, LoadGenerator}; + +/// Test result +#[derive(Debug)] +pub struct TestResult { + pub failed: bool, + pub duration_ms: i64, + pub error: Option, +} + +/// Test message for reporting +#[derive(Debug)] +pub struct TestMsg { + pub path: String, + pub status: String, // ABORTED/FAILED/SUCCESS + pub duration_ms: i64, +} + +/// Test runner for executing test suites +pub struct TestRunner<'a> { + config: &'a AppConfig, +} + +impl<'a> TestRunner<'a> { + /// Create a new test runner + pub fn new(config: &'a AppConfig) -> Self { + Self { config } + } + + /// Run all tests + pub async fn run(&self) -> bool { + let mut results: Vec = Vec::new(); + + for test in &self.config.tests { + // Wait between tests + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let result = self.run_test(test).await; + let msg = TestMsg { + path: test.path.clone(), + status: match &result { + Ok(r) if r.failed => "FAILED".to_string(), + Ok(_) => "SUCCESS".to_string(), + Err(_) => "ABORTED".to_string(), + }, + duration_ms: result.as_ref().map(|r| r.duration_ms).unwrap_or(0), + }; + results.push(msg); + } + + // Print summary + let mut all_ok = true; + for msg in &results { + let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S"); + info!( + "[{}][TEST][{}] [{}] duration: {}ms", + timestamp, msg.status, msg.path, msg.duration_ms + ); + if msg.status != "SUCCESS" { + all_ok = false; + } + } + + all_ok + } + + /// Run a single test + async fn run_test(&self, test: &crate::config::types::Test) -> anyhow::Result { + let start = Instant::now(); + let mut result = TestResult { + failed: false, + duration_ms: 0, + error: None, + }; + + // Find DSL file + let dsl_path = Path::new(&test.path).join("loadgen.dsl"); + if !dsl_path.exists() { + debug!("DSL file not found: {:?}", dsl_path); + result.error = Some(format!("DSL file not found: {:?}", dsl_path)); + result.failed = true; + result.duration_ms = start.elapsed().as_millis() as i64; + return Ok(result); + } + + // Parse DSL + let loader_config = match parse_dsl_file(&dsl_path, self.config).await { + Ok(c) => c, + Err(e) => { + error!("Failed to parse DSL: {}", e); + result.error = Some(format!("Failed to parse DSL: {}", e)); + result.failed = true; + result.duration_ms = start.elapsed().as_millis() as i64; + return Ok(result); + } + }; + + // Create generator + let cli_options = CliOptions { + compress: test.compress, + ..Default::default() + }; + + let interrupted = Arc::new(AtomicBool::new(false)); + let mut generator = match LoadGenerator::new(loader_config.clone(), cli_options, interrupted) { + Ok(g) => g, + Err(e) => { + error!("Failed to create generator: {}", e); + result.error = Some(format!("Failed to create generator: {}", e)); + result.failed = true; + result.duration_ms = start.elapsed().as_millis() as i64; + return Ok(result); + } + }; + + // Run generator + let stats = match generator.run().await { + Ok(s) => s, + Err(e) => { + error!("Generator failed: {}", e); + result.error = Some(format!("Generator failed: {}", e)); + result.failed = true; + result.duration_ms = start.elapsed().as_millis() as i64; + return Ok(result); + } + }; + + // Check results + if loader_config.runner.assert_invalid && stats.num_assert_invalid > 0 { + result.failed = true; + } + if loader_config.runner.assert_error && stats.num_errors > 0 { + result.failed = true; + } + + result.duration_ms = start.elapsed().as_millis() as i64; + Ok(result) + } +} diff --git a/loadgen-rust/src/template/engine.rs b/loadgen-rust/src/template/engine.rs new file mode 100644 index 0000000..7cd2ba0 --- /dev/null +++ b/loadgen-rust/src/template/engine.rs @@ -0,0 +1,206 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Template engine for variable substitution +//! +//! Supports the $[[variable_name]] syntax used by loadgen + +use std::collections::HashMap; + +/// Template engine for processing variable substitutions +pub struct TemplateEngine; + +/// Start delimiter for variables +const START_DELIM: &str = "$[["; +/// End delimiter for variables +const END_DELIM: &str = "]]"; + +impl TemplateEngine { + /// Check if a string contains template variables + pub fn has_template(input: &str) -> bool { + input.contains(START_DELIM) + } + + /// Execute template substitution with a callback function for variable resolution + pub fn execute(input: &str, resolver: F) -> String + where + F: Fn(&str) -> String, + { + if !Self::has_template(input) { + return input.to_string(); + } + + let mut result = String::with_capacity(input.len()); + let mut remaining = input; + + while let Some(start_idx) = remaining.find(START_DELIM) { + // Append text before the variable + result.push_str(&remaining[..start_idx]); + + // Find the end of the variable + let after_start = &remaining[start_idx + START_DELIM.len()..]; + if let Some(end_idx) = after_start.find(END_DELIM) { + let var_name = &after_start[..end_idx]; + let value = resolver(var_name); + result.push_str(&value); + remaining = &after_start[end_idx + END_DELIM.len()..]; + } else { + // No closing delimiter found, append the rest and break + result.push_str(&remaining[start_idx..]); + remaining = ""; + break; + } + } + + // Append remaining text + result.push_str(remaining); + result + } + + /// Execute template substitution with a HashMap of variables + pub fn execute_with_map(input: &str, variables: &HashMap) -> String { + Self::execute(input, |name| { + variables + .get(name) + .cloned() + .unwrap_or_else(|| format!("$[[{}]]", name)) + }) + } + + /// Extract all variable names from a template string + pub fn extract_variables(input: &str) -> Vec { + let mut variables = Vec::new(); + let mut remaining = input; + + while let Some(start_idx) = remaining.find(START_DELIM) { + let after_start = &remaining[start_idx + START_DELIM.len()..]; + if let Some(end_idx) = after_start.find(END_DELIM) { + let var_name = &after_start[..end_idx]; + variables.push(var_name.to_string()); + remaining = &after_start[end_idx + END_DELIM.len()..]; + } else { + break; + } + } + + variables + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_has_template() { + assert!(TemplateEngine::has_template("Hello $[[name]]!")); + assert!(TemplateEngine::has_template("$[[a]] $[[b]]")); + assert!(!TemplateEngine::has_template("Hello world!")); + assert!(!TemplateEngine::has_template("$[name]")); + } + + #[test] + fn test_execute_simple() { + let result = TemplateEngine::execute("Hello $[[name]]!", |var| { + if var == "name" { + "World".to_string() + } else { + format!("unknown:{}", var) + } + }); + assert_eq!(result, "Hello World!"); + } + + #[test] + fn test_execute_multiple() { + let result = TemplateEngine::execute("$[[a]] and $[[b]]", |var| { + match var { + "a" => "first".to_string(), + "b" => "second".to_string(), + _ => "unknown".to_string(), + } + }); + assert_eq!(result, "first and second"); + } + + #[test] + fn test_execute_no_template() { + let result = TemplateEngine::execute("No template here", |_| "unused".to_string()); + assert_eq!(result, "No template here"); + } + + #[test] + fn test_execute_with_map() { + let mut vars = HashMap::new(); + vars.insert("name".to_string(), "Alice".to_string()); + vars.insert("greeting".to_string(), "Hello".to_string()); + + let result = TemplateEngine::execute_with_map("$[[greeting]] $[[name]]!", &vars); + assert_eq!(result, "Hello Alice!"); + } + + #[test] + fn test_execute_missing_variable() { + let vars = HashMap::new(); + let result = TemplateEngine::execute_with_map("Hello $[[name]]!", &vars); + assert_eq!(result, "Hello $[[name]]!"); + } + + #[test] + fn test_extract_variables() { + let vars = TemplateEngine::extract_variables("$[[a]] and $[[b.c]] = $[[d]]"); + assert_eq!(vars, vec!["a", "b.c", "d"]); + } + + #[test] + fn test_execute_nested_json() { + let result = TemplateEngine::execute( + r#"{"id": "$[[id]]", "name": "$[[name]]"}"#, + |var| match var { + "id" => "123".to_string(), + "name" => "test".to_string(), + _ => "".to_string(), + }, + ); + assert_eq!(result, r#"{"id": "123", "name": "test"}"#); + } + + #[test] + fn test_execute_env_variable() { + let result = TemplateEngine::execute( + "http://$[[env.ES_HOST]]:$[[env.ES_PORT]]/index", + |var| match var { + "env.ES_HOST" => "localhost".to_string(), + "env.ES_PORT" => "9200".to_string(), + _ => "".to_string(), + }, + ); + assert_eq!(result, "http://localhost:9200/index"); + } + + #[test] + fn test_execute_unclosed_delimiter() { + let result = TemplateEngine::execute("Hello $[[name", |_| "World".to_string()); + assert_eq!(result, "Hello $[[name"); + } +} diff --git a/loadgen-rust/src/template/mod.rs b/loadgen-rust/src/template/mod.rs new file mode 100644 index 0000000..1780b55 --- /dev/null +++ b/loadgen-rust/src/template/mod.rs @@ -0,0 +1,28 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Template module for loadgen + +pub mod engine; + +pub use engine::TemplateEngine; diff --git a/loadgen-rust/src/variable/mod.rs b/loadgen-rust/src/variable/mod.rs new file mode 100644 index 0000000..406fd80 --- /dev/null +++ b/loadgen-rust/src/variable/mod.rs @@ -0,0 +1,27 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Variable module for loadgen + +pub mod provider; +pub mod types; diff --git a/loadgen-rust/src/variable/provider.rs b/loadgen-rust/src/variable/provider.rs new file mode 100644 index 0000000..62b4c1b --- /dev/null +++ b/loadgen-rust/src/variable/provider.rs @@ -0,0 +1,420 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Variable provider - generates values for variables + +use std::collections::HashMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::sync::Arc; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use chrono::{Local, Utc}; +use rand::Rng; +use roaring::RoaringBitmap; +use uuid::Uuid; + +use super::types::{SequenceCounter32, SequenceCounter64, VariableType}; +use crate::config::types::Variable; + +/// Timestamp format for now_utc_lite +const TS_LAYOUT: &str = "%Y-%m-%dT%H:%M:%S%.3f"; + +/// Variable provider for generating variable values +pub struct VariableProvider { + /// Variable definitions + variables: HashMap, + /// Data loaded from files or inline definitions + dict: HashMap>, + /// 32-bit sequence counters + seq32: HashMap>, + /// 64-bit sequence counters + seq64: HashMap>, + /// Character replacers + replacers: HashMap>, + /// Environment variables + env_vars: HashMap, +} + +impl VariableProvider { + /// Create a new variable provider + pub fn new(variables: &[Variable], env_vars: HashMap) -> Self { + let mut provider = VariableProvider { + variables: HashMap::new(), + dict: HashMap::new(), + seq32: HashMap::new(), + seq64: HashMap::new(), + replacers: HashMap::new(), + env_vars, + }; + + for var in variables { + provider.register_variable(var.clone()); + } + + provider + } + + /// Register a variable + pub fn register_variable(&mut self, var: Variable) { + let name = var.name.trim().to_string(); + + // Load data from file if specified + let mut lines: Vec = Vec::new(); + + if !var.path.is_empty() { + if let Ok(file) = File::open(&var.path) { + let reader = BufReader::new(file); + for line in reader.lines().map_while(Result::ok) { + let trimmed = line.trim().to_string(); + if !trimmed.is_empty() { + lines.push(trimmed); + } + } + } + } + + // Add inline data + for item in &var.data { + let trimmed = item.trim().to_string(); + if !trimmed.is_empty() { + lines.push(trimmed); + } + } + + // Store replacer if specified + if !var.replace.is_empty() { + let replacements: Vec<(String, String)> = var + .replace + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + self.replacers.insert(name.clone(), replacements); + } + + // Create sequence counters + let var_type = VariableType::from(var.var_type.as_str()); + match var_type { + VariableType::Sequence => { + let counter = Arc::new(SequenceCounter32::new(var.from as u32, var.to as u32)); + self.seq32.insert(name.clone(), counter); + } + VariableType::Sequence64 => { + let counter = Arc::new(SequenceCounter64::new(var.from, var.to)); + self.seq64.insert(name.clone(), counter); + } + _ => {} + } + + self.dict.insert(name.clone(), lines); + self.variables.insert(name, var); + } + + /// Get a variable value + pub fn get_value(&self, key: &str, runtime_vars: Option<&HashMap>) -> String { + // Check runtime variables first + if let Some(vars) = runtime_vars { + if let Some(value) = vars.get(key) { + return value.clone(); + } + } + + // Check for environment variable reference + if let Some(env_key) = key.strip_prefix("env.") { + return self.env_vars.get(env_key).cloned().unwrap_or_default(); + } + + // Get variable definition + let var = match self.variables.get(key) { + Some(v) => v, + None => return "not_found".to_string(), + }; + + let raw_value = self.build_variable_value(var); + + // Apply replacer if exists + if let Some(replacements) = self.replacers.get(key) { + let mut result = raw_value; + for (from, to) in replacements { + result = result.replace(from, to); + } + return result; + } + + raw_value + } + + /// Build a variable value based on its type + fn build_variable_value(&self, var: &Variable) -> String { + let var_type = VariableType::from(var.var_type.as_str()); + + match var_type { + VariableType::Sequence => { + if let Some(counter) = self.seq32.get(&var.name) { + return counter.next().to_string(); + } + "0".to_string() + } + VariableType::Sequence64 => { + if let Some(counter) = self.seq64.get(&var.name) { + return counter.next().to_string(); + } + "0".to_string() + } + VariableType::Uuid => Uuid::new_v4().to_string(), + VariableType::NowLocal => Local::now().to_string(), + VariableType::NowUtc => Utc::now().to_string(), + VariableType::NowUtcLite => Utc::now().format(TS_LAYOUT).to_string(), + VariableType::NowUnix => Local::now().timestamp().to_string(), + VariableType::NowUnixInMs => Local::now().timestamp_millis().to_string(), + VariableType::NowUnixInMicro => Local::now().timestamp_micros().to_string(), + VariableType::NowUnixInNano => { + Local::now().timestamp_nanos_opt().unwrap_or(0).to_string() + } + VariableType::NowWithFormat => { + if var.format.is_empty() { + panic!("Date format is not set for variable: {}", var.name); + } + // Convert Go date format to chrono format + let format = convert_go_time_format(&var.format); + Local::now().format(&format).to_string() + } + VariableType::Range => { + let mut rng = rand::thread_rng(); + let range = var.to - var.from + 1; + let value = rng.gen_range(0..range) + var.from; + value.to_string() + } + VariableType::RandomArray => self.build_random_array(var), + VariableType::IntArrayBitmap => self.build_int_array_bitmap(var), + VariableType::File | VariableType::List => { + if let Some(data) = self.dict.get(&var.name) { + if data.is_empty() { + return String::new(); + } + if data.len() == 1 { + return data[0].clone(); + } + let mut rng = rand::thread_rng(); + let idx = rng.gen_range(0..data.len()); + return data[idx].clone(); + } + String::new() + } + VariableType::Env => { + self.env_vars.get(&var.name).cloned().unwrap_or_default() + } + } + } + + /// Build a random array value + fn build_random_array(&self, var: &Variable) -> String { + let mut result = String::new(); + + if var.square_bracket { + result.push('['); + } + + if !var.variable_key.is_empty() && var.size > 0 { + for i in 0..var.size { + if i > 0 { + result.push(','); + } + + // Get value from referenced variable + let value = self.get_value(&var.variable_key, None); + + // Add string brackets if needed + if var.variable_type == "string" { + let bracket = if var.string_bracket.is_empty() { + "\"" + } else { + &var.string_bracket + }; + result.push_str(bracket); + result.push_str(&value); + result.push_str(bracket); + } else { + result.push_str(&value); + } + } + } + + if var.square_bracket { + result.push(']'); + } + + result + } + + /// Build a roaring bitmap encoded integer array + fn build_int_array_bitmap(&self, var: &Variable) -> String { + let mut bitmap = RoaringBitmap::new(); + let mut rng = rand::thread_rng(); + + if var.size > 0 { + for _ in 0..var.size { + let range = var.to - var.from + 1; + let value = rng.gen_range(0..range) + var.from; + bitmap.insert(value as u32); + } + } + + let mut bytes = Vec::new(); + bitmap.serialize_into(&mut bytes).unwrap_or_default(); + BASE64.encode(&bytes) + } + + /// Clear all data (for reset_context) + pub fn clear(&mut self) { + self.dict.clear(); + self.seq32.clear(); + self.seq64.clear(); + self.variables.clear(); + self.replacers.clear(); + } +} + +/// Convert Go time format to chrono strftime format +fn convert_go_time_format(go_format: &str) -> String { + // Go uses a reference time: Mon Jan 2 15:04:05 MST 2006 + // This maps Go format specifiers to chrono format specifiers + // Order matters - replace longer patterns first to avoid partial matches + go_format + // Year - must be first to avoid issues with other numbers + .replace("2006", "%Y") + .replace("06", "%y") + // Month patterns - order by length (longer first) + .replace("January", "%B") + .replace("Jan", "%b") + .replace("01", "%m") + // Day patterns + .replace("Monday", "%A") + .replace("Mon", "%a") + .replace("02", "%d") + // Hour patterns - must be before minute patterns + .replace("15", "%H") // 24-hour + .replace("03", "%I") // 12-hour with leading zero + // Minute patterns - must be after hour patterns + .replace("04", "%M") + // Second patterns + .replace("05", "%S") + // Timezone patterns + .replace("-0700", "%z") + .replace("-07:00", "%:z") + .replace("-07", "%z") + .replace("MST", "%Z") + // AM/PM + .replace("PM", "%p") + .replace("pm", "%P") + // Subseconds + .replace(".000000000", "%.9f") + .replace(".000000", "%.6f") + .replace(".000", "%.3f") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sequence_variable() { + let vars = vec![Variable { + name: "id".to_string(), + var_type: "sequence".to_string(), + from: 1, + to: 100, + ..Default::default() + }]; + + let provider = VariableProvider::new(&vars, HashMap::new()); + + assert_eq!(provider.get_value("id", None), "1"); + assert_eq!(provider.get_value("id", None), "2"); + assert_eq!(provider.get_value("id", None), "3"); + } + + #[test] + fn test_uuid_variable() { + let vars = vec![Variable { + name: "uuid".to_string(), + var_type: "uuid".to_string(), + ..Default::default() + }]; + + let provider = VariableProvider::new(&vars, HashMap::new()); + + let value = provider.get_value("uuid", None); + assert!(Uuid::parse_str(&value).is_ok()); + } + + #[test] + fn test_range_variable() { + let vars = vec![Variable { + name: "num".to_string(), + var_type: "range".to_string(), + from: 10, + to: 20, + ..Default::default() + }]; + + let provider = VariableProvider::new(&vars, HashMap::new()); + + for _ in 0..100 { + let value: u64 = provider.get_value("num", None).parse().unwrap(); + assert!(value >= 10 && value <= 20); + } + } + + #[test] + fn test_list_variable() { + let vars = vec![Variable { + name: "user".to_string(), + var_type: "list".to_string(), + data: vec!["alice".to_string(), "bob".to_string(), "charlie".to_string()], + ..Default::default() + }]; + + let provider = VariableProvider::new(&vars, HashMap::new()); + + let value = provider.get_value("user", None); + assert!(["alice", "bob", "charlie"].contains(&value.as_str())); + } + + #[test] + fn test_env_variable() { + let mut env_vars = HashMap::new(); + env_vars.insert("TEST_VAR".to_string(), "test_value".to_string()); + + let provider = VariableProvider::new(&[], env_vars); + + assert_eq!(provider.get_value("env.TEST_VAR", None), "test_value"); + } + + #[test] + fn test_convert_go_time_format() { + assert_eq!(convert_go_time_format("2006-01-02"), "%Y-%m-%d"); + assert_eq!(convert_go_time_format("15:04:05"), "%H:%M:%S"); + assert_eq!(convert_go_time_format("2006-01-02T15:04:05-0700"), "%Y-%m-%dT%H:%M:%S%z"); + } +} diff --git a/loadgen-rust/src/variable/types.rs b/loadgen-rust/src/variable/types.rs new file mode 100644 index 0000000..a784d05 --- /dev/null +++ b/loadgen-rust/src/variable/types.rs @@ -0,0 +1,144 @@ +// Copyright (C) INFINI Labs & INFINI LIMITED. +// +// The INFINI Loadgen is offered under the GNU Affero General Public License v3.0 +// and as commercial software. +// +// For commercial licensing, contact us at: +// - Website: infinilabs.com +// - Email: hello@infini.ltd +// +// Open Source licensed under AGPL V3: +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Variable type definitions + +use std::sync::atomic::{AtomicU32, AtomicU64}; + +/// Variable types supported by loadgen +#[derive(Debug, Clone, PartialEq)] +pub enum VariableType { + /// Load values from a file + File, + /// Inline list of values + List, + /// 32-bit auto-incrementing sequence + Sequence, + /// 64-bit auto-incrementing sequence + Sequence64, + /// Random number in range + Range, + /// UUID v4 + Uuid, + /// Current local time + NowLocal, + /// Current UTC time + NowUtc, + /// Current UTC time in compact format + NowUtcLite, + /// Unix timestamp in seconds + NowUnix, + /// Unix timestamp in milliseconds + NowUnixInMs, + /// Unix timestamp in microseconds + NowUnixInMicro, + /// Unix timestamp in nanoseconds + NowUnixInNano, + /// Custom formatted time + NowWithFormat, + /// Random array from another variable + RandomArray, + /// Roaring bitmap encoded integer array + IntArrayBitmap, + /// Environment variable + Env, +} + +impl From<&str> for VariableType { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "file" => VariableType::File, + "list" => VariableType::List, + "sequence" => VariableType::Sequence, + "sequence64" => VariableType::Sequence64, + "range" => VariableType::Range, + "uuid" => VariableType::Uuid, + "now_local" => VariableType::NowLocal, + "now_utc" => VariableType::NowUtc, + "now_utc_lite" => VariableType::NowUtcLite, + "now_unix" => VariableType::NowUnix, + "now_unix_in_ms" => VariableType::NowUnixInMs, + "now_unix_in_micro" => VariableType::NowUnixInMicro, + "now_unix_in_nano" => VariableType::NowUnixInNano, + "now_with_format" => VariableType::NowWithFormat, + "random_array" => VariableType::RandomArray, + "int_array_bitmap" => VariableType::IntArrayBitmap, + "env" => VariableType::Env, + _ => VariableType::List, // Default to list + } + } +} + +/// A sequence counter for auto-incrementing variables +#[derive(Debug)] +pub struct SequenceCounter32 { + current: AtomicU32, + from: u32, + to: u32, +} + +impl SequenceCounter32 { + pub fn new(from: u32, to: u32) -> Self { + Self { + current: AtomicU32::new(from), + from, + to, + } + } + + pub fn next(&self) -> u32 { + let current = self.current.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.to > 0 && current >= self.to { + self.current.store(self.from, std::sync::atomic::Ordering::SeqCst); + return self.from; + } + current + } +} + +/// A sequence counter for 64-bit auto-incrementing variables +#[derive(Debug)] +pub struct SequenceCounter64 { + current: AtomicU64, + from: u64, + to: u64, +} + +impl SequenceCounter64 { + pub fn new(from: u64, to: u64) -> Self { + Self { + current: AtomicU64::new(from), + from, + to, + } + } + + pub fn next(&self) -> u64 { + let current = self.current.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if self.to > 0 && current >= self.to { + self.current.store(self.from, std::sync::atomic::Ordering::SeqCst); + return self.from; + } + current + } +}