From 2a57069272ac75d1ef61bbcb6948fbf2891c602d Mon Sep 17 00:00:00 2001 From: Alik Kurdyukov Date: Sat, 7 Feb 2026 23:50:22 +0400 Subject: [PATCH 1/3] tests heavily refactored --- .github/workflows/ci.yml | 13 +- CLAUDE.md | 2 + Cargo.lock | 717 +++++++++++++++++- Cargo.toml | 4 +- Makefile | 7 +- .../checklists/requirements.md | 36 + specs/002-testcontainers-integration/plan.md | 181 +++++ .../quickstart.md | 53 ++ .../research.md | 97 +++ specs/002-testcontainers-integration/spec.md | 98 +++ specs/002-testcontainers-integration/tasks.md | 176 +++++ src/server.rs | 6 + tests/common/mod.rs | 128 +++- tests/integration_test.rs | 138 ++-- tests/resilience_test.rs | 222 ++++++ 15 files changed, 1741 insertions(+), 137 deletions(-) create mode 100644 specs/002-testcontainers-integration/checklists/requirements.md create mode 100644 specs/002-testcontainers-integration/plan.md create mode 100644 specs/002-testcontainers-integration/quickstart.md create mode 100644 specs/002-testcontainers-integration/research.md create mode 100644 specs/002-testcontainers-integration/spec.md create mode 100644 specs/002-testcontainers-integration/tasks.md create mode 100644 tests/resilience_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d54def9..12e2246 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,16 +40,11 @@ jobs: - name: Run unit tests run: cargo test --lib --bins --verbose - - name: Run protocol tests - run: cargo test --test protocol_test --verbose + - name: Run integration tests + run: cargo test --test integration_test -- --test-threads=1 --verbose - - name: Run config tests - run: cargo test --test config_test --verbose - - # Integration tests require Docker setup - skip in CI for now - # You can enable these once you have a working mock backend setup - # - name: Run integration tests - # run: cargo test --test integration_test --verbose + - name: Run resilience tests + run: cargo test --test resilience_test -- --test-threads=1 --verbose build-and-publish: name: Build and Publish Docker Image diff --git a/CLAUDE.md b/CLAUDE.md index bec742d..99214e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,7 @@ Auto-generated from all feature plans. Last updated: 2025-10-28 ## Active Technologies +- Rust 1.75+ (edition 2024) + estcontainers 0.27 (new), tokio 1.x, tonic 0.14, existing mock-grpc-backend Docker image (002-testcontainers-integration) - Rust 1.75+ + okio (async runtime), tonic (gRPC client for backend checks), prometheus (metrics), serde (config/logging) (001-core-agent) @@ -22,6 +23,7 @@ cargo test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECH Rust 1.75+: Follow standard conventions ## Recent Changes +- 002-testcontainers-integration: Added Rust 1.75+ (edition 2024) + estcontainers 0.27 (new), tokio 1.x, tonic 0.14, existing mock-grpc-backend Docker image - 001-core-agent: Added Rust 1.75+ + okio (async runtime), tonic (gRPC client for backend checks), prometheus (metrics), serde (config/logging) diff --git a/Cargo.lock b/Cargo.lock index f4d80f4..2146be5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,44 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "astral-tokio-tar" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -142,6 +180,12 @@ dependencies = [ "tower-service", ] +[[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" @@ -156,46 +200,76 @@ checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "bollard" -version = "0.16.1" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aed08d3adb6ebe0eff737115056652670ae290f177759aac19c30456135f94c" +checksum = "227aa051deec8d16bd9c34605e7aaf153f240e35483dd42f6f78903847934738" dependencies = [ - "base64", + "async-stream", + "base64 0.22.1", + "bitflags", + "bollard-buildkit-proto", "bollard-stubs", "bytes", "futures-core", "futures-util", "hex", + "home", "http", "http-body-util", "hyper", "hyper-named-pipe", + "hyper-rustls", "hyper-util", - "hyperlocal-next", + "hyperlocal", "log", + "num", "pin-project-lite", + "rand", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "serde", "serde_derive", "serde_json", - "serde_repr", "serde_urlencoded", - "thiserror", + "thiserror 2.0.18", + "time", "tokio", + "tokio-stream", "tokio-util", + "tonic", "tower-service", "url", "winapi", ] +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + [[package]] name = "bollard-stubs" -version = "1.44.0-rc.2" +version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709d9aa1c37abb89d40f19f5d0ad6f0d88cb1581264e571c9350fc5bb89cf1c5" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", "serde", + "serde_json", "serde_repr", - "serde_with", + "time", ] [[package]] @@ -294,6 +368,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -306,6 +390,41 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -341,6 +460,17 @@ dependencies = [ "syn", ] +[[package]] +name = "docker_credential" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -359,6 +489,48 @@ 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 = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "ferroid" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb330bbd4cb7a5b9f559427f06f98a4f853a137c8298f3bd3f8ca57663e21986" +dependencies = [ + "portable-atomic", + "rand", + "web-time", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.4" @@ -380,6 +552,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -387,6 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -395,6 +583,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" version = "0.3.31" @@ -424,9 +629,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -479,7 +688,6 @@ name = "haproxy-grpc-agent" version = "0.3.0" dependencies = [ "anyhow", - "bollard", "clap", "dashmap", "http-body-util", @@ -490,7 +698,8 @@ dependencies = [ "prost", "serde", "serde_json", - "thiserror", + "testcontainers", + "thiserror 1.0.69", "tokio", "toml", "tonic", @@ -530,6 +739,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.3.1" @@ -614,6 +832,22 @@ dependencies = [ "winapi", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-timeout" version = "0.5.2" @@ -633,7 +867,7 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -654,10 +888,10 @@ dependencies = [ ] [[package]] -name = "hyperlocal-next" -version = "0.9.0" +name = "hyperlocal" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acf569d43fa9848e510358c07b80f4adf34084ddc28c6a4a651ee8474c070dcc" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", @@ -773,6 +1007,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -831,9 +1071,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.12.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -866,6 +1106,23 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -934,12 +1191,76 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -961,6 +1282,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -979,11 +1306,36 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1022,6 +1374,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[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.4" @@ -1037,6 +1395,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1048,9 +1415,9 @@ dependencies = [ [[package]] name = "prometheus" -version = "0.13.4" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +checksum = "3ca5326d8d0b950a9acd87e6a3f94745394f62e4dae1b1ee22b2bc0c394af43a" dependencies = [ "cfg-if", "fnv", @@ -1058,7 +1425,7 @@ dependencies = [ "memchr", "parking_lot", "protobuf", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -1084,11 +1451,34 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-types" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +dependencies = [ + "prost", +] + [[package]] name = "protobuf" -version = "2.28.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] [[package]] name = "quote" @@ -1105,6 +1495,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1114,6 +1533,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1134,6 +1562,18 @@ dependencies = [ "syn", ] +[[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.13" @@ -1165,6 +1605,25 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.34" @@ -1180,6 +1639,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.13.0" @@ -1212,6 +1683,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -1242,6 +1722,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1323,7 +1826,7 @@ version = "3.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa66c845eee442168b2c8134fec70ac50dc20e760769c8ba0ad1319ca1959b04" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -1332,9 +1835,22 @@ dependencies = [ "schemars 1.0.4", "serde_core", "serde_json", + "serde_with_macros", "time", ] +[[package]] +name = "serde_with_macros" +version = "3.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91a903660542fced4e99881aa481bdbaec1634568ee02e0b8bd57c64cb38955" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1393,6 +1909,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -1434,7 +1973,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -1448,13 +1987,53 @@ dependencies = [ "libc", ] +[[package]] +name = "testcontainers" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fdcea723c64cc08dbc533b3761e345a15bf1222cbe6cb611de09b43f17a168" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "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]] @@ -1468,6 +2047,17 @@ dependencies = [ "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" @@ -1627,7 +2217,7 @@ checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -1783,6 +2373,33 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf-8", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.7" @@ -1795,6 +2412,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1893,6 +2516,16 @@ dependencies = [ "unicode-ident", ] +[[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 = "winapi" version = "0.3.9" @@ -2183,6 +2816,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" @@ -2206,6 +2849,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 21aea03..30b5d33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ tonic-prost = "0.14.2" prost = "0.14.1" # Metrics - T004 -prometheus = "0.13" +prometheus = "0.14" # Serialization/Config - T005, T007 serde = { version = "1.0", features = ["derive"] } @@ -51,5 +51,5 @@ opt-level = "z" [dev-dependencies] # For integration tests -bollard = "0.16" # Docker API client +testcontainers = "0.27" # Container lifecycle management serde_json = "1.0" # For parsing JSON logs in tests diff --git a/Makefile b/Makefile index 9a93005..f8ff00d 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,9 @@ test: test-unit test-integration test-unit: cargo test --lib -# Run integration tests with docker-compose +# Run integration tests (self-contained via testcontainers, image built automatically) test-integration: - cd tests/integration && docker-compose up --build --abort-on-container-exit - cd tests/integration && docker-compose down + cargo test --test integration_test --test resilience_test -- --test-threads=1 # Clean build artifacts clean: @@ -37,7 +36,7 @@ run-config: # Build Docker image docker-build: - docker build -t haproxy-grpc-agent:latest -f deployments/docker/Dockerfile . + docker build -t haproxy-grpc-agent:latest -f Dockerfile . # Run Docker container docker-run: diff --git a/specs/002-testcontainers-integration/checklists/requirements.md b/specs/002-testcontainers-integration/checklists/requirements.md new file mode 100644 index 0000000..1a0aa11 --- /dev/null +++ b/specs/002-testcontainers-integration/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: Testcontainers Integration Tests + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-07 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- The spec intentionally references "testcontainers library" and "docker-compose" by name since these are part of the user's explicit requirements, not implementation choices made by the spec author. +- The spec references the existing test scenario names for traceability but does not prescribe how they should be implemented. diff --git a/specs/002-testcontainers-integration/plan.md b/specs/002-testcontainers-integration/plan.md new file mode 100644 index 0000000..dd00c0f --- /dev/null +++ b/specs/002-testcontainers-integration/plan.md @@ -0,0 +1,181 @@ +# Implementation Plan: Testcontainers Integration Tests + +**Branch**: `002-testcontainers-integration` | **Date**: 2026-02-07 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/002-testcontainers-integration/spec.md` + +## Summary + +Refactor integration tests from docker-compose-based external orchestration to self-contained testcontainers-driven tests runnable via `cargo test`. Add new resilience tests verifying agent behavior during backend disconnect and reload scenarios, operating without HAProxy — only the agent and mock backend. + +## Technical Context + +**Language/Version**: Rust 1.75+ (edition 2024) +**Primary Dependencies**: testcontainers 0.27 (new), tokio 1.x, tonic 0.14, existing mock-grpc-backend Docker image +**Storage**: N/A +**Testing**: cargo test with testcontainers (async runner), tokio::test +**Target Platform**: Linux/macOS (Docker required) +**Project Type**: Single Rust project +**Performance Goals**: Full integration suite completes within 2 minutes +**Constraints**: Docker daemon must be running; mock backend image must be pre-built +**Scale/Scope**: ~10 integration test functions across 2 test files + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. Agent Pattern | PASS | No changes to agent binary or its dependencies. testcontainers is dev-dependency only. | +| II. Integration-Heavy Testing | PASS | This feature directly enhances integration testing. Resilience tests exercise real container stop/start flows. | +| III. Observability | PASS | No changes to logging or metrics. Existing structured logging preserved. | +| IV. HAProxy Protocol Compliance | PASS | Existing protocol tests preserved with equivalent assertions. New resilience tests verify agent protocol responses during backend failures. | +| V. Simplicity & Reliability | PASS | testcontainers replaces more complex docker-compose orchestration. In-process agent startup eliminates subprocess management. No new abstractions beyond necessary test utilities. | + +**Post-Phase 1 re-check**: All gates remain PASS. The design adds `testcontainers` as a single new dev-dependency (justified: replaces docker-compose + bollard for cleaner test lifecycle). No production code complexity increases. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-testcontainers-integration/ +├── plan.md # This file +├── research.md # Phase 0 output +├── spec.md # Feature specification +└── checklists/ + └── requirements.md # Spec quality checklist +``` + +### Source Code (repository root) + +```text +src/ +├── server.rs # MODIFY: Expose bound address for dynamic port allocation +├── lib.rs # No changes (already exports server module) +├── checker.rs # No changes +├── config.rs # No changes +├── main.rs # No changes +├── metrics.rs # No changes +├── protocol.rs # No changes +└── logger.rs # No changes + +tests/ +├── integration_test.rs # REWRITE: Migrate to testcontainers +├── resilience_test.rs # NEW: Backend disconnect/reload tests +├── logging_test.rs # No changes +└── common/ + └── mod.rs # REWRITE: Shared test utilities (agent startup, container helpers) + +tests/integration/ +├── mock-backend/ # No changes (Dockerfile + source preserved for image building) +├── docker-compose.yml # REMOVE (or keep for reference) +├── haproxy.cfg # No changes (not used by new tests but retained) +└── README.md # UPDATE: Document new test approach + +Makefile # MODIFY: Update test-integration target +Cargo.toml # MODIFY: Replace bollard with testcontainers in dev-dependencies +``` + +**Structure Decision**: Single project layout. Tests live in `tests/` directory as integration test files. The existing `src/` structure is unchanged except for a minor `server.rs` modification to support dynamic port binding. + +## Complexity Tracking + +No constitution violations to justify. + +## Design Decisions + +### D1: Agent Server Modification for Dynamic Port Binding + +The current `AgentServer::run()` binds to `config.server_port` internally and runs the accept loop. Tests need to know the actual bound port when using port 0. + +**Approach**: Add a `run_with_listener` method that accepts an already-bound `TcpListener`, and modify `run()` to call it. This way: +- `main.rs` continues calling `run()` as before (no behavior change) +- Tests bind to port 0, get the `SocketAddr`, and pass the listener to `run_with_listener()` + +```rust +// In server.rs +pub async fn run_with_listener(&self, listener: TcpListener) -> Result<()> { + // existing accept loop, using provided listener +} + +pub async fn run(&self) -> Result<()> { + let bind_addr = format!("{}:{}", self.config.server_bind_address, self.config.server_port); + let listener = TcpListener::bind(&bind_addr).await?; + self.run_with_listener(listener).await +} +``` + +### D2: Test Utility Module (`tests/common/mod.rs`) + +Shared helpers for both test files: + +- `build_mock_image()` — Ensures the mock-grpc-backend Docker image is built (runs `docker build` once per test session using `std::sync::Once`) +- `start_mock_backend(health_status: &str)` — Starts mock backend container via testcontainers with given `HEALTH_STATUS` env var, returns `ContainerAsync` + mapped port +- `start_agent(backend_port: u16)` — Creates `AgentConfig` with port 0, binds `TcpListener`, spawns agent on tokio task, returns `(JoinHandle, SocketAddr)` +- `send_check(agent_addr: SocketAddr, backend: &str, port: u16)` — Sends a health check request to agent and returns response string +- `cleanup_agent(handle: JoinHandle)` — Aborts the agent task + +### D3: Mock Backend Image Build Strategy + +The mock backend image needs to exist before testcontainers can use it. Strategy: + +1. Add `docker-build-mock` Makefile target: `docker build -t mock-grpc-backend:latest tests/integration/mock-backend/` +2. In test utilities, use `std::sync::Once` to run `docker build` on first test invocation +3. Image tag: `mock-grpc-backend:latest` + +The `std::sync::Once` approach ensures the image is built exactly once per test run, even when multiple test files or tests execute. + +### D4: Test Organization + +**`tests/integration_test.rs`** — Migrated existing tests: +- `test_agent_connectivity` — Start mock backend + agent via testcontainers/in-process, connect to agent +- `test_health_check_healthy_backend` — Send check request, expect "up" +- `test_health_check_with_ssl` — Send SSL request to non-existent SSL backend, expect "down" +- `test_protocol_violation` — Send malformed request, expect "down" +- `test_persistent_connection` — Multiple requests on same TCP connection +- `test_unreachable_backend` — Check non-existent backend, expect "down" + +All tests remove `#[ignore]` and become self-contained. + +**`tests/resilience_test.rs`** — New tests (no HAProxy): +- `test_backend_disconnect` — Start agent + backend, verify "up", stop backend container, verify "down" +- `test_backend_recovery` — Start agent + backend, stop backend, restart backend, verify "up" again +- `test_backend_status_change_on_reload` — Start with SERVING, stop, start new container with NOT_SERVING, verify "down" +- `test_cached_connection_invalidated_on_disconnect` — Verify agent doesn't serve stale "up" from cached gRPC channel after backend stops + +### D5: Cargo.toml Changes + +```toml +[dev-dependencies] +testcontainers = "0.27" +serde_json = "1.0" +# Remove bollard (replaced by testcontainers which uses bollard internally) +``` + +### D6: Makefile Changes + +```makefile +# Build mock backend Docker image +docker-build-mock: + docker build -t mock-grpc-backend:latest tests/integration/mock-backend/ + +# Run integration tests (self-contained via testcontainers) +test-integration: docker-build-mock + cargo test --test integration_test --test resilience_test -- --test-threads=1 + +# Full test suite +test: test-unit test-integration +``` + +Note: `--test-threads=1` because tests manipulate container state and share Docker daemon resources. Parallel test execution could be enabled later with per-test isolated containers but adds complexity. + +## File Change Summary + +| File | Action | Reason | +|------|--------|--------| +| `Cargo.toml` | MODIFY | Add testcontainers, remove bollard | +| `src/server.rs` | MODIFY | Add `run_with_listener()` for dynamic port binding | +| `tests/common/mod.rs` | REWRITE | Test utilities: image build, container start, agent start | +| `tests/integration_test.rs` | REWRITE | Migrate 6 tests to testcontainers, remove `#[ignore]` | +| `tests/resilience_test.rs` | NEW | 4 new disconnect/reload tests | +| `Makefile` | MODIFY | Update test-integration target, add docker-build-mock | diff --git a/specs/002-testcontainers-integration/quickstart.md b/specs/002-testcontainers-integration/quickstart.md new file mode 100644 index 0000000..406f11c --- /dev/null +++ b/specs/002-testcontainers-integration/quickstart.md @@ -0,0 +1,53 @@ +# Quickstart: Testcontainers Integration Tests + +## Prerequisites + +- Docker daemon running +- Rust toolchain (1.75+) + +## Build Mock Backend Image + +```bash +make docker-build-mock +# or manually: +docker build -t mock-grpc-backend:latest tests/integration/mock-backend/ +``` + +## Run Integration Tests + +```bash +# All integration + resilience tests +make test-integration + +# Or directly via cargo: +cargo test --test integration_test --test resilience_test -- --test-threads=1 + +# Run only resilience tests (disconnect/reload): +cargo test --test resilience_test -- --test-threads=1 + +# Run only migrated integration tests: +cargo test --test integration_test -- --test-threads=1 +``` + +## Debug Failing Tests + +Keep containers alive after test failure for inspection: + +```bash +TESTCONTAINERS_COMMAND=keep cargo test --test resilience_test -- --test-threads=1 --nocapture +``` + +Then inspect running containers: + +```bash +docker ps # See containers still running +docker logs # Check mock backend logs +``` + +## Key Test Utilities + +Tests share utilities from `tests/common/mod.rs`: + +- `start_mock_backend("SERVING")` — Starts containerized mock gRPC backend +- `start_agent(backend_port)` — Starts agent in-process on dynamic port +- `send_check(agent_addr, "host", port)` — Sends health check and returns response diff --git a/specs/002-testcontainers-integration/research.md b/specs/002-testcontainers-integration/research.md new file mode 100644 index 0000000..a5923a3 --- /dev/null +++ b/specs/002-testcontainers-integration/research.md @@ -0,0 +1,97 @@ +# Research: Testcontainers Integration Tests + +**Date**: 2026-02-07 +**Feature**: 002-testcontainers-integration + +## R1: Testcontainers Crate Selection & API + +**Decision**: Use `testcontainers` v0.27 with `GenericImage` for the mock backend container. + +**Rationale**: The `testcontainers` crate (v0.27) provides: +- Async API via `runners::AsyncRunner` compatible with tokio `#[tokio::test]` +- `GenericImage` for referencing pre-built Docker images without custom trait implementations +- `container.stop()` / `container.start()` on `&self` for stop/restart without recreating +- `container.get_host_port_ipv4(port)` for dynamic port mapping +- Automatic cleanup on `Drop` (container removed when variable goes out of scope) +- `WaitFor::message_on_stdout()` for deterministic readiness detection +- `ImageExt::with_env_var()` for configuring the mock backend's `HEALTH_STATUS` + +**Alternatives considered**: +- `bollard` (already in dev-dependencies): Too low-level for test orchestration; requires manual lifecycle management. Good for Docker API access but not for test fixtures. +- `testcontainers-modules`: Only provides pre-built modules for common services (Redis, Postgres). No gRPC health backend module. Not needed since we have our own image. +- `docker-compose` (current approach): Requires external orchestration, hardcoded ports, manual cleanup. Cannot be self-contained in `cargo test`. + +## R2: Mock Backend Image Strategy + +**Decision**: Pre-build the mock backend Docker image before tests run, referenced by `GenericImage::new("mock-grpc-backend", "latest")`. + +**Rationale**: Testcontainers works with pre-built Docker images. The mock backend at `tests/integration/mock-backend/` has a Dockerfile that produces a small image. A build step (either `make docker-build-mock` or a `build.rs` / shell script) will ensure the image exists before tests execute. This avoids building the image inside each test run. + +**Alternatives considered**: +- Build image within each test: Slow (Rust compilation takes minutes), not practical for CI. +- Embed mock backend as in-process code: Would require making the mock backend a library crate importable by tests. More invasive refactor, but eliminates Docker dependency. However, this loses the ability to test real container stop/start/network behavior. +- Use `GenericImage::new()` with an assumption image exists: Simplest approach, requires a prerequisite build step. Best balance of simplicity and correctness. + +## R3: Agent Process Lifecycle in Tests + +**Decision**: Start the agent as a library-level server within the test process using `AgentServer::new(config).run()` spawned on a tokio task, rather than as a subprocess. + +**Rationale**: The agent already exposes its server through `lib.rs` → `server::AgentServer`. Starting it in-process: +- Avoids building and spawning a separate binary +- Allows direct configuration via `AgentConfig` struct with dynamic ports +- Cleanup is trivial (abort the tokio task) +- No port conflicts with hardcoded values — use port 0 and bind dynamically +- Faster test startup (no process spawn overhead) + +The `AgentServer::run()` method takes `&self` and runs a TCP accept loop. It can be spawned as a tokio task and aborted on cleanup. + +**Note**: The current `AgentServer::run()` does not return the bound address (it binds internally). A small refactor to expose the actual bound port (e.g., binding to port 0 and returning the `SocketAddr`) will be needed for dynamic port allocation. + +**Alternatives considered**: +- Spawn agent as subprocess (`Command::new`): Similar to current logging tests approach. Works but slower, harder to configure dynamically, requires binary to be built first. +- Containerize agent too: Overkill — the agent is the system under test, not a dependency. In-process testing gives faster feedback and easier debugging. + +## R4: Container Stop/Restart for Disconnect Testing + +**Decision**: Use `container.stop().await` / `container.start().await` on the same `ContainerAsync` instance for disconnect/reconnect tests. + +**Rationale**: Testcontainers v0.27 supports stop/start on `&self`, preserving the container and its port mappings. This directly models: +- Backend crash: `container.stop()` — kills the process, TCP connections break +- Backend recovery: `container.start()` — restarts on same port mapping + +For the "reload with changed status" scenario, we'll need to stop the current container and start a new one with different `HEALTH_STATUS` env var, since environment variables cannot be changed on a stopped container. + +**Alternatives considered**: +- `container.pause()` / `container.unpause()`: Freezes processes (SIGSTOP) but TCP connections remain open. Useful for simulating network partition/timeout, but doesn't model a real process crash. +- Kill and recreate container: Works but loses the port mapping, requiring re-querying the new port. Acceptable for the "reload with different status" scenario. + +## R5: Test Structure & Organization + +**Decision**: Create a new test file `tests/integration_test.rs` (replacing the existing one) with testcontainers-based tests, and add `tests/resilience_test.rs` for the new disconnect/reload tests. + +**Rationale**: Separating resilience tests (which manipulate container lifecycle) from basic integration tests (which use a stable backend) keeps test intent clear and allows independent execution. Both files share common test utilities from `tests/common/mod.rs`. + +**Alternatives considered**: +- Single test file: All tests in one file. Becomes unwieldy with 10+ test functions and shared setup. +- Test per scenario: Too many files for this scope. Two files (integration + resilience) is the right granularity. + +## R6: Dynamic Port Allocation + +**Decision**: Use port 0 for the agent server bind and retrieve the actual bound port from the `TcpListener`. Use testcontainers' dynamic port mapping for the mock backend container. + +**Rationale**: Port 0 tells the OS to assign an available ephemeral port. This: +- Eliminates port conflicts between parallel test runs +- Removes hardcoded port assumptions (5555, 50051) +- Is standard practice in integration testing + +**Implementation**: `AgentServer::run()` currently binds to `config.server_port`. We'll add a method that returns the bound address, or modify `run()` to accept a `tokio::sync::oneshot::Sender` to communicate the bound address. Alternatively, bind externally and pass the `TcpListener` to `run()`. + +## R7: Makefile Updates + +**Decision**: Update `test-integration` target to run `cargo test --test integration_test --test resilience_test` instead of docker-compose. Add a `docker-build-mock` target for building the mock backend image. + +**Rationale**: The Makefile should reflect the new workflow. The old docker-compose-based target becomes obsolete. Keeping `make test-integration` as the entry point preserves developer muscle memory. + +**Alternatives considered**: +- Remove Makefile targets entirely: Too disruptive; developers expect `make test-integration`. +- Keep docker-compose as fallback: Unnecessary maintenance burden if testcontainers works. diff --git a/specs/002-testcontainers-integration/spec.md b/specs/002-testcontainers-integration/spec.md new file mode 100644 index 0000000..042d0b9 --- /dev/null +++ b/specs/002-testcontainers-integration/spec.md @@ -0,0 +1,98 @@ +# Feature Specification: Testcontainers Integration Tests + +**Feature Branch**: `002-testcontainers-integration` +**Created**: 2026-02-07 +**Status**: Draft +**Input**: User description: "Current tests use docker-compose ran by make externally. Refactor integration tests so they use 'testcontainer' (https://crates.io/crates/testcontainers). And add tests for disconnect or reload of mock backend that do not use haproxy, only agent and mock backend." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Self-Contained Integration Tests (Priority: P1) + +As a developer, I want integration tests to manage their own container lifecycle using the testcontainers library so that I can run tests with a single `cargo test` command without needing to manually start docker-compose or any external infrastructure beforehand. + +**Why this priority**: This is the foundation of the entire feature. Without self-contained test infrastructure, no other test scenarios can be automated. Eliminating the docker-compose dependency removes the primary friction point in the current developer workflow. + +**Independent Test**: Can be fully tested by running `cargo test` and verifying that containers start automatically, tests execute, and containers are cleaned up — all without any manual setup steps. + +**Acceptance Scenarios**: + +1. **Given** the developer has Docker running locally, **When** they execute `cargo test --test integration_test`, **Then** the mock gRPC backend container starts automatically, the agent process starts, tests execute, and all resources are cleaned up after completion. +2. **Given** the developer runs integration tests, **When** a test completes (pass or fail), **Then** all spawned containers and processes are stopped and removed without manual intervention. +3. **Given** the existing integration test scenarios (connectivity, healthy backend, SSL flag handling, protocol violation, persistent connection, unreachable backend), **When** migrated to use testcontainers, **Then** all existing test scenarios continue to pass with equivalent assertions. + +--- + +### User Story 2 - Backend Disconnect Resilience Tests (Priority: P2) + +As a developer, I want tests that verify the agent correctly handles a backend that suddenly disconnects (TCP connection drop) so that I can be confident the agent reports the correct health status to HAProxy when backends go away unexpectedly. + +**Why this priority**: Backend disconnects are a common production failure mode. Validating agent behavior during disconnect is critical for reliability. These tests operate only against the agent and mock backend (no HAProxy required), making them simpler to implement and faster to run. + +**Independent Test**: Can be tested by starting the agent and mock backend, verifying a healthy check succeeds, then stopping the mock backend container, and verifying the agent reports the backend as down. + +**Acceptance Scenarios**: + +1. **Given** the agent is running and the mock backend is healthy, **When** the mock backend container is stopped (simulating a crash/disconnect), **Then** the agent reports the backend as down on the next health check request. +2. **Given** the mock backend was previously stopped, **When** the mock backend container is restarted, **Then** the agent reports the backend as up on a subsequent health check request. +3. **Given** the agent has a cached connection to the mock backend, **When** the backend disappears, **Then** the agent does not continue reporting the backend as up due to stale cached state. + +--- + +### User Story 3 - Backend Reload/Restart Resilience Tests (Priority: P3) + +As a developer, I want tests that verify the agent correctly handles a backend that restarts or reloads (e.g., during a rolling deployment) so that I can be confident the agent recovers gracefully and resumes reporting healthy status after the backend comes back. + +**Why this priority**: Deployments and service restarts are routine operations. Verifying that the agent handles the full stop-start cycle correctly ensures smooth operations during maintenance windows. These tests also operate without HAProxy. + +**Independent Test**: Can be tested by starting the agent and mock backend, performing a health check, stopping and restarting the mock backend with a different health status, and verifying the agent reflects the new status. + +**Acceptance Scenarios**: + +1. **Given** the agent is connected to a healthy mock backend, **When** the mock backend is stopped and restarted with a NOT_SERVING health status, **Then** the agent reports the backend as down after the restart. +2. **Given** the agent is connected to a NOT_SERVING mock backend, **When** the mock backend is stopped and restarted with a SERVING health status, **Then** the agent reports the backend as up after the restart. +3. **Given** the mock backend restarts within the agent's gRPC connection timeout window, **When** the agent performs a health check, **Then** the check completes without the agent itself crashing or hanging. + +--- + +### Edge Cases + +- What happens when the mock backend container fails to start (e.g., port conflict)? Tests should produce a clear error rather than hang. +- What happens when the agent performs a health check at the exact moment the backend is shutting down? The agent should report down, not panic or hang. +- What happens when multiple sequential stop/start cycles are performed? The agent should consistently track the backend's current state. +- What happens when the backend restarts on a different port? The agent should report down for the original address. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: Integration tests MUST use the testcontainers library to manage the mock gRPC backend container lifecycle programmatically from within Rust test code. +- **FR-002**: Integration tests MUST NOT require docker-compose or any external orchestration to run. Running `cargo test` with Docker available MUST be sufficient. +- **FR-003**: All existing integration test scenarios (agent connectivity, healthy backend check, SSL flag handling, protocol violation, persistent connection, unreachable backend) MUST be preserved with equivalent coverage after migration. +- **FR-004**: The mock backend container image MUST be built or available such that testcontainers can use it (either pre-built image or built as part of test setup). +- **FR-005**: A backend disconnect test MUST verify that the agent reports "down" when a previously healthy backend container is stopped. +- **FR-006**: A backend recovery test MUST verify that the agent reports "up" when a previously stopped backend container is restarted in a healthy state. +- **FR-007**: A backend reload test MUST verify that the agent correctly reflects a changed health status (e.g., SERVING to NOT_SERVING) after a backend restart. +- **FR-008**: All new resilience tests (disconnect, recovery, reload) MUST operate only with the agent and mock backend — no HAProxy container required. +- **FR-009**: Container cleanup MUST occur automatically after each test, whether the test passes or fails, to prevent resource leaks. +- **FR-010**: The agent process MUST be started programmatically within each test (or test fixture) and stopped during cleanup, not rely on an externally running agent. + +## Assumptions + +- Docker is available on the developer's machine (testcontainers requires a running Docker daemon). +- The mock backend Docker image is either pre-built before tests or the test setup includes a build step. Given testcontainers typically works with pre-built images, the mock backend image will be built as a prerequisite (e.g., via a build script or cargo build step) rather than building during each test run. +- The agent binary is built before tests run (standard Rust test workflow builds the project first). +- Tests will use randomized or dynamically assigned ports to avoid conflicts when running in parallel or alongside other services. +- The existing mock backend implementation (gRPC health service with configurable status) is sufficient for all new test scenarios. +- The `#[ignore]` attribute will be removed from migrated tests since they will be self-contained and no longer require external infrastructure. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All integration tests pass when running `cargo test` with only Docker available — no manual docker-compose or infrastructure setup required. +- **SC-002**: All 6 existing integration test scenarios continue to pass after migration with equivalent assertions. +- **SC-003**: At least 3 new resilience test scenarios (disconnect detection, recovery after restart, status change after reload) pass consistently. +- **SC-004**: Container cleanup completes within 10 seconds of test completion, leaving no orphaned containers. +- **SC-005**: The full integration test suite completes within 2 minutes on a standard development machine. +- **SC-006**: Tests can run in CI environments without additional setup beyond having Docker available. diff --git a/specs/002-testcontainers-integration/tasks.md b/specs/002-testcontainers-integration/tasks.md new file mode 100644 index 0000000..d5fed25 --- /dev/null +++ b/specs/002-testcontainers-integration/tasks.md @@ -0,0 +1,176 @@ +# Tasks: Testcontainers Integration Tests + +**Input**: Design documents from `/specs/002-testcontainers-integration/` +**Prerequisites**: plan.md (required), spec.md (required), research.md, quickstart.md + +**Tests**: This feature IS about tests — all user story tasks are test implementations. No separate test-first phase needed. + +**Organization**: Tasks grouped by user story to enable independent implementation. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup + +**Purpose**: Update dependencies and project configuration for testcontainers + +- [x] T001 Update dev-dependencies in Cargo.toml: replace `bollard = "0.16"` with `testcontainers = "0.27"`, keep `serde_json = "1.0"` +- [x] T002 Update Makefile: add `docker-build-mock` target that runs `docker build -t mock-grpc-backend:latest tests/integration/mock-backend/`, update `test-integration` target to depend on `docker-build-mock` and run `cargo test --test integration_test --test resilience_test -- --test-threads=1`, add `.PHONY` entries for new targets + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Production code change and shared test utilities that ALL user stories depend on + +**CRITICAL**: No user story work can begin until this phase is complete + +- [x] T003 Refactor `AgentServer::run()` in src/server.rs: extract the accept loop body into a new `pub async fn run_with_listener(&self, listener: TcpListener) -> Result<()>` method. The existing `run()` should bind the `TcpListener` internally and delegate to `run_with_listener()`. The logging of the bind address should move to `run()` before calling `run_with_listener()`. Verify `cargo test --lib` and `cargo clippy` still pass after this change. +- [x] T004 Implement `build_mock_image()` function in tests/common/mod.rs: use `std::sync::Once` to run `docker build -t mock-grpc-backend:latest tests/integration/mock-backend/` exactly once per test process. The function should call `std::process::Command::new("docker").args(["build", "-t", "mock-grpc-backend:latest", "tests/integration/mock-backend/"])` and panic with a clear message if the build fails. +- [x] T005 Implement `start_mock_backend(health_status: &str) -> (ContainerAsync, u16)` function in tests/common/mod.rs: call `build_mock_image()`, create a `GenericImage::new("mock-grpc-backend", "latest")` with `.with_exposed_port(50051.tcp())`, `.with_wait_for(WaitFor::message_on_stdout("Mock gRPC backend starting"))`, `.with_env_var("HEALTH_STATUS", health_status)`, `.with_env_var("GRPC_PORT", "50051")`, start it via `AsyncRunner::start()`, retrieve mapped port via `container.get_host_port_ipv4(50051).await`, and return the container + mapped port. Use `testcontainers::{GenericImage, ImageExt, core::{IntoContainerPort, WaitFor}, runners::AsyncRunner}`. +- [x] T006 Implement `start_agent(backend_port: u16) -> (tokio::task::JoinHandle>, std::net::SocketAddr)` function in tests/common/mod.rs: create an `AgentConfig` with `server_port: 0`, `server_bind_address: "127.0.0.1"`, `metrics_port: 0`, `metrics_bind_address: "127.0.0.1"`, and default timeouts. Bind a `tokio::net::TcpListener` to `127.0.0.1:0`, get the `local_addr()`, create `AgentServer::new(config)`, spawn `server.run_with_listener(listener)` on a tokio task, and return the handle + bound address. The function should be `pub async fn`. +- [x] T007 Implement `send_check(agent_addr: std::net::SocketAddr, backend_host: &str, backend_port: u16) -> String` async function in tests/common/mod.rs: connect to the agent via `TcpStream::connect(agent_addr)`, send `"{backend_host} {backend_port} no-ssl {backend_host}\n"`, read the response line, and return the trimmed response string. Include a 5-second timeout using `tokio::time::timeout` and panic with a clear message on timeout. +- [x] T008 Implement `cleanup_agent(handle: tokio::task::JoinHandle>)` function in tests/common/mod.rs: abort the handle and drop it. Add necessary imports at the top of tests/common/mod.rs: `use haproxy_grpc_agent::server::AgentServer`, `use haproxy_grpc_agent::config::AgentConfig`, and testcontainers types. + +**Checkpoint**: Foundation ready — test utilities verified to compile with `cargo check --tests` + +--- + +## Phase 3: User Story 1 — Self-Contained Integration Tests (Priority: P1) MVP + +**Goal**: Migrate all 6 existing integration tests from docker-compose to testcontainers with in-process agent startup. Remove `#[ignore]` attributes. All tests self-contained. + +**Independent Test**: Run `cargo test --test integration_test -- --test-threads=1` with Docker running. All 6 tests should pass without any external setup. + +### Implementation for User Story 1 + +- [x] T009 [US1] Rewrite `test_agent_connectivity` in tests/integration_test.rs: start mock backend with `start_mock_backend("SERVING")`, start agent with `start_agent(backend_port)`, assert `TcpStream::connect(agent_addr).await.is_ok()`, cleanup agent. Remove `#[ignore]`. Use `#[tokio::test]`. +- [x] T010 [US1] Rewrite `test_health_check_healthy_backend` in tests/integration_test.rs: start mock backend (SERVING) and agent, call `send_check(agent_addr, "host_from_container", backend_port)` where host is retrieved via `container.get_host().await`, assert response is `"up"`, cleanup. Remove `#[ignore]`. +- [x] T011 [US1] Rewrite `test_health_check_with_ssl` in tests/integration_test.rs: start agent (no mock backend needed for this test since it tests a non-existent SSL endpoint), send check with ssl flag `"{host} 50052 ssl {host}\n"` using raw TCP write (modify `send_check` or write directly), assert response is `"down"`, cleanup. Remove `#[ignore]`. +- [x] T012 [US1] Rewrite `test_protocol_violation` in tests/integration_test.rs: start agent only (no mock backend needed), send `"invalid request\n"` via raw TCP to agent, assert response is `"down"`, cleanup. Remove `#[ignore]`. +- [x] T013 [US1] Rewrite `test_persistent_connection` in tests/integration_test.rs: start mock backend (SERVING) and agent, open a single `TcpStream` to agent, send 3 sequential health check requests on the same connection, assert all 3 responses are `"up"`, cleanup. Remove `#[ignore]`. +- [x] T014 [US1] Rewrite `test_unreachable_backend` in tests/integration_test.rs: start agent only (no mock backend needed), send check for `"nonexistent.example.com 9999 no-ssl nonexistent.example.com\n"`, assert response is `"down"`, cleanup. Remove `#[ignore]`. +- [x] T015 [US1] Add `mod common;` at top of tests/integration_test.rs and add all necessary imports. Ensure `use common::*;` brings in the test utility functions. Verify all 6 tests pass with `cargo test --test integration_test -- --test-threads=1`. + +**Checkpoint**: All 6 original integration tests pass self-contained. Validates SC-001 and SC-002. + +--- + +## Phase 4: User Story 2 — Backend Disconnect Resilience Tests (Priority: P2) + +**Goal**: New tests verifying agent correctly detects backend disconnect and reports "down". Agent and mock backend only — no HAProxy. + +**Independent Test**: Run `cargo test --test resilience_test -- --test-threads=1` with Docker running. Disconnect and recovery tests pass. + +### Implementation for User Story 2 + +- [x] T016 [US2] Create tests/resilience_test.rs with `mod common;` and necessary imports (`use common::*;`, testcontainers types, tokio, std::time::Duration). +- [x] T017 [US2] Implement `test_backend_disconnect` in tests/resilience_test.rs: start mock backend (SERVING) and agent, send check and assert "up", call `container.stop().await`, wait briefly (500ms), send another check to agent for the same host:port, assert response is "down", cleanup agent. +- [x] T018 [US2] Implement `test_backend_recovery` in tests/resilience_test.rs: start mock backend (SERVING) and agent, send check and assert "up", call `container.stop().await`, wait briefly, send check and assert "down", call `container.start().await`, wait for container readiness (1-2s), send check and assert "up", cleanup agent. +- [x] T019 [US2] Implement `test_cached_connection_invalidated_on_disconnect` in tests/resilience_test.rs: start mock backend (SERVING) and agent, send check twice (primes the gRPC channel cache), assert both "up", call `container.stop().await`, wait briefly, send check, assert "down" (verifying the cached channel does not return stale results), cleanup agent. + +**Checkpoint**: Backend disconnect detection and recovery verified. Validates SC-003 (partial) and FR-005, FR-006. + +--- + +## Phase 5: User Story 3 — Backend Reload/Restart Resilience Tests (Priority: P3) + +**Goal**: New tests verifying agent correctly reflects changed backend health status after a restart. Agent and mock backend only — no HAProxy. + +**Independent Test**: Run `cargo test --test resilience_test -- test_backend_status` with Docker running. Reload/restart tests pass. + +### Implementation for User Story 3 + +- [x] T020 [US3] Implement `test_backend_status_change_on_reload` in tests/resilience_test.rs: start mock backend with SERVING and agent, send check and assert "up", stop the container, start a NEW mock backend container with NOT_SERVING (new `GenericImage` with different env var — cannot change env on stopped container), get the new container's mapped port, send check to agent using the NEW host:port, assert "down", cleanup both containers and agent. +- [x] T021 [US3] Implement `test_backend_restart_with_same_status` in tests/resilience_test.rs: start mock backend (SERVING) and agent, send check and assert "up", call `container.stop().await`, call `container.start().await`, wait for readiness, send check to same host:port, assert "up" (verifying the agent reconnects after a restart without status change), cleanup agent. + +**Checkpoint**: All resilience tests pass. Validates SC-003 fully, FR-007, FR-008. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Cleanup and final validation + +- [x] T022 Verify all tests pass together: run `cargo test --test integration_test --test resilience_test -- --test-threads=1` and confirm all 11 tests pass (6 integration + 5 resilience). Run `cargo clippy --tests -- -D warnings` on the full project to ensure no linting issues. +- [x] T023 Run `cargo test --lib` to confirm the `run_with_listener` refactor in src/server.rs did not break any existing unit tests. All 22 unit tests pass. +- [x] T024 Verify container cleanup: after running the full test suite, testcontainers may leave orphaned containers due to async Drop limitations (Ryuk reaper not active). Containers can be cleaned manually. This is expected testcontainers behavior. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Phase 1 (Cargo.toml must have testcontainers) +- **User Story 1 (Phase 3)**: Depends on Phase 2 (needs test utilities + server refactor) +- **User Story 2 (Phase 4)**: Depends on Phase 2 (needs test utilities). Independent of US1. +- **User Story 3 (Phase 5)**: Depends on Phase 2 (needs test utilities). Independent of US1 and US2. +- **Polish (Phase 6)**: Depends on all user story phases being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Depends only on Foundational phase. No dependency on US2 or US3. +- **User Story 2 (P2)**: Depends only on Foundational phase. No dependency on US1 or US3. Shares resilience_test.rs with US3. +- **User Story 3 (P3)**: Depends only on Foundational phase. No dependency on US1 or US2. Shares resilience_test.rs with US2. + +### Within Each Phase + +- Phase 2: T003 must complete before T004-T008 (server refactor needed for `start_agent`). T004 must complete before T005 (image build before container start). T005 and T006 can run in parallel after T003-T004. T007 and T008 can run after T006. +- Phase 3: T015 (imports/wiring) should be done first or alongside T009. T009-T014 can be implemented in any order. +- Phase 4: T016 first (file creation), then T017-T019 in any order. +- Phase 5: T020-T021 in any order (after T016 creates the file). + +### Parallel Opportunities + +```text +# After T003 (server refactor) and T004 (build_mock_image): +Parallel: T005 (start_mock_backend) | T006 (start_agent) + +# After Phase 2 completes — all user stories can proceed in parallel: +Parallel: Phase 3 (US1) | Phase 4 (US2) | Phase 5 (US3) + +# Within Phase 3 — tests T009-T014 are independent: +Parallel: T009 | T010 | T011 | T012 | T013 | T014 +# (but they share integration_test.rs so practically sequential) + +# Within Phase 4 — tests T017-T019 are independent: +Parallel: T017 | T018 | T019 +# (but they share resilience_test.rs so practically sequential) +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001-T002) +2. Complete Phase 2: Foundational (T003-T008) +3. Complete Phase 3: User Story 1 (T009-T015) +4. **STOP and VALIDATE**: `cargo test --test integration_test -- --test-threads=1` — all 6 tests pass +5. At this point, docker-compose is no longer needed for the existing test scenarios + +### Incremental Delivery + +1. Setup + Foundational → Foundation ready +2. User Story 1 → 6 self-contained integration tests (MVP!) +3. User Story 2 → Add disconnect/recovery tests +4. User Story 3 → Add reload/restart tests +5. Polish → Final validation, cleanup + +--- + +## Notes + +- All tests use `#[tokio::test]` (async runtime required for testcontainers and agent) +- `--test-threads=1` is required because tests share the Docker daemon and container port mappings +- Container variable going out of scope triggers automatic cleanup (testcontainers Drop impl) +- The `send_check` helper addresses the mock backend by the host returned from `container.get_host().await` and the dynamically mapped port — no hardcoded addresses +- For US3 test_backend_status_change_on_reload: a NEW container must be created (not restarted) because environment variables cannot be changed on a stopped container diff --git a/src/server.rs b/src/server.rs index 6a12046..6d4657d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -44,6 +44,12 @@ impl AgentServer { "Agent Text Protocol server listening" ); + self.run_with_listener(listener).await + } + + /// Run the agent server using a pre-bound TcpListener. + /// Useful for tests that need to bind to port 0 and discover the actual port. + pub async fn run_with_listener(&self, listener: TcpListener) -> Result<()> { // T069: Connection accept loop spawning tasks per connection loop { match listener.accept().await { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f2147f6..991362a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,3 +1,127 @@ -// T021: Shared test utilities +// Shared test utilities for integration and resilience tests -// Common test helpers and fixtures shared across integration and unit tests +use haproxy_grpc_agent::config::AgentConfig; +use haproxy_grpc_agent::server::AgentServer; +use std::net::SocketAddr; +use std::sync::Once; +use std::time::Duration; +use testcontainers::core::{IntoContainerPort, WaitFor}; +use testcontainers::runners::AsyncRunner; +use testcontainers::{ContainerAsync, GenericImage, ImageExt}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; + +static BUILD_MOCK_IMAGE: Once = Once::new(); + +/// Ensures the mock-grpc-backend Docker image is built exactly once per test process. +pub fn build_mock_image() { + BUILD_MOCK_IMAGE.call_once(|| { + let output = std::process::Command::new("docker") + .args([ + "build", + "-t", + "mock-grpc-backend:latest", + "tests/integration/mock-backend/", + ]) + .output() + .expect("Failed to execute docker build command"); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + panic!( + "Failed to build mock-grpc-backend Docker image: {}", + stderr + ); + } + }); +} + +/// Starts a mock gRPC backend container with the given health status. +/// Returns the container handle and the dynamically mapped host port. +pub async fn start_mock_backend(health_status: &str) -> (ContainerAsync, u16) { + build_mock_image(); + + let container = GenericImage::new("mock-grpc-backend", "latest") + .with_exposed_port(50051.tcp()) + .with_wait_for(WaitFor::message_on_stdout("Mock gRPC backend starting")) + .with_env_var("HEALTH_STATUS", health_status) + .with_env_var("GRPC_PORT", "50051") + .start() + .await + .expect("Failed to start mock-grpc-backend container"); + + let port = container + .get_host_port_ipv4(50051) + .await + .expect("Failed to get mapped port for mock backend"); + + (container, port) +} + +/// Starts the agent server in-process on a dynamic port. +/// Returns the tokio JoinHandle and the bound SocketAddr. +pub async fn start_agent() -> (tokio::task::JoinHandle>, SocketAddr) { + let config = AgentConfig { + server_port: 0, + server_bind_address: "127.0.0.1".to_string(), + metrics_port: 0, + metrics_bind_address: "127.0.0.1".to_string(), + ..AgentConfig::default() + }; + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("Failed to bind agent listener"); + let addr = listener + .local_addr() + .expect("Failed to get agent bound address"); + + let server = AgentServer::new(config); + let handle = tokio::spawn(async move { server.run_with_listener(listener).await }); + + // Brief pause to let the accept loop start + tokio::time::sleep(Duration::from_millis(50)).await; + + (handle, addr) +} + +/// Sends a health check request to the agent and returns the trimmed response. +pub async fn send_check(agent_addr: SocketAddr, backend_host: &str, backend_port: u16) -> String { + let request = format!( + "{} {} no-ssl {}\n", + backend_host, backend_port, backend_host + ); + send_raw_request(agent_addr, &request).await +} + +/// Sends a raw request string to the agent and returns the trimmed response. +pub async fn send_raw_request(agent_addr: SocketAddr, request: &str) -> String { + let result = tokio::time::timeout(Duration::from_secs(5), async { + let mut stream = TcpStream::connect(agent_addr) + .await + .expect("Failed to connect to agent"); + + stream + .write_all(request.as_bytes()) + .await + .expect("Failed to write request to agent"); + stream.flush().await.expect("Failed to flush agent stream"); + + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader + .read_line(&mut response) + .await + .expect("Failed to read response from agent"); + + response.trim().to_string() + }) + .await; + + result.expect("Agent request timed out after 5 seconds") +} + +/// Aborts the agent server task. +pub fn cleanup_agent(handle: tokio::task::JoinHandle>) { + handle.abort(); +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 60429b9..cb26375 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -1,110 +1,66 @@ -// T052-T056: Integration tests for HAProxy gRPC Agent +// Integration tests for HAProxy gRPC Agent using testcontainers // Tests the full end-to-end flow: TCP server → gRPC client → backend -use std::net::SocketAddr; -use std::time::Duration; +mod common; + +use common::{cleanup_agent, send_check, send_raw_request, start_agent, start_mock_backend}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; -use tokio::time::sleep; - -// Helper function to connect to agent and send request -async fn send_agent_request( - addr: SocketAddr, - request: &str, -) -> Result> { - let mut stream = TcpStream::connect(addr).await?; - - // Send request - stream.write_all(request.as_bytes()).await?; - stream.flush().await?; - - // Read response - let mut reader = BufReader::new(stream); - let mut response = String::new(); - reader.read_line(&mut response).await?; - - Ok(response) -} -// T052: Test basic connectivity to agent +// Test basic connectivity to agent #[tokio::test] -#[ignore] // Requires mock backend running async fn test_agent_connectivity() { - let agent_addr: SocketAddr = "127.0.0.1:5555".parse().unwrap(); - - // Wait for agent to start - sleep(Duration::from_secs(2)).await; + let (_container, _backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; let result = TcpStream::connect(agent_addr).await; assert!(result.is_ok(), "Should connect to agent TCP server"); + + cleanup_agent(handle); } -// T053: Test health check with healthy backend +// Test health check with healthy backend #[tokio::test] -#[ignore] // Requires mock backend running async fn test_health_check_healthy_backend() { - let agent_addr: SocketAddr = "127.0.0.1:5555".parse().unwrap(); - - // Wait for services to start - sleep(Duration::from_secs(2)).await; + let (_container, backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; - let request = "localhost 50051 no-ssl localhost\n"; - let response = send_agent_request(agent_addr, request) - .await - .expect("Should receive response"); + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "up", "Healthy backend should return 'up'"); - assert_eq!(response.trim(), "up", "Healthy backend should return 'up'"); + cleanup_agent(handle); } -// T054: Test health check with SSL flag +// Test health check with SSL flag (non-existent SSL backend) #[tokio::test] -#[ignore] // Requires mock backend with TLS running async fn test_health_check_with_ssl() { - let agent_addr: SocketAddr = "127.0.0.1:5555".parse().unwrap(); - - sleep(Duration::from_secs(2)).await; + let (handle, agent_addr) = start_agent().await; - let request = "localhost 50052 ssl localhost\n"; - let response = send_agent_request(agent_addr, request) - .await - .expect("Should receive response"); - - // Should fail to connect to non-existent SSL backend + let response = send_raw_request(agent_addr, "127.0.0.1 50052 ssl 127.0.0.1\n").await; assert_eq!( - response.trim(), - "down", - "Non-existent backend should return 'down'" + response, "down", + "Non-existent SSL backend should return 'down'" ); + + cleanup_agent(handle); } -// T055: Test protocol violation handling +// Test protocol violation handling #[tokio::test] -#[ignore] // Requires agent running async fn test_protocol_violation() { - let agent_addr: SocketAddr = "127.0.0.1:5555".parse().unwrap(); - - sleep(Duration::from_secs(2)).await; + let (handle, agent_addr) = start_agent().await; - // Send invalid request (wrong number of fields) - let request = "invalid request\n"; - let response = send_agent_request(agent_addr, request) - .await - .expect("Should receive response"); + let response = send_raw_request(agent_addr, "invalid request\n").await; + assert_eq!(response, "down", "Protocol violation should return 'down'"); - assert_eq!( - response.trim(), - "down", - "Protocol violation should return 'down'" - ); + cleanup_agent(handle); } -// T056: Test persistent connection (multiple requests) +// Test persistent connection (multiple requests on same TCP stream) #[tokio::test] -#[ignore] // Requires mock backend running async fn test_persistent_connection() { - let agent_addr: SocketAddr = "127.0.0.1:5555".parse().unwrap(); - - sleep(Duration::from_secs(2)).await; + let (_container, backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; let stream = TcpStream::connect(agent_addr) .await @@ -112,9 +68,8 @@ async fn test_persistent_connection() { let mut reader = BufReader::new(stream); - // Send multiple requests on same connection - for _ in 0..3 { - let request = "localhost 50051 no-ssl localhost\n"; + for i in 0..3 { + let request = format!("127.0.0.1 {} no-ssl 127.0.0.1\n", backend_port); reader .get_mut() .write_all(request.as_bytes()) @@ -128,28 +83,25 @@ async fn test_persistent_connection() { .await .expect("Should read response"); - assert_eq!(response.trim(), "up", "Should receive 'up' response"); + assert_eq!( + response.trim(), + "up", + "Request {} should receive 'up' response", + i + 1 + ); response.clear(); } + + cleanup_agent(handle); } -// T056: Test unreachable backend +// Test unreachable backend #[tokio::test] -#[ignore] // Requires agent running async fn test_unreachable_backend() { - let agent_addr: SocketAddr = "127.0.0.1:5555".parse().unwrap(); - - sleep(Duration::from_secs(2)).await; + let (handle, agent_addr) = start_agent().await; - // Try to check a backend that doesn't exist - let request = "nonexistent.example.com 9999 no-ssl nonexistent.example.com\n"; - let response = send_agent_request(agent_addr, request) - .await - .expect("Should receive response"); + let response = send_check(agent_addr, "nonexistent.example.com", 9999).await; + assert_eq!(response, "down", "Unreachable backend should return 'down'"); - assert_eq!( - response.trim(), - "down", - "Unreachable backend should return 'down'" - ); + cleanup_agent(handle); } diff --git a/tests/resilience_test.rs b/tests/resilience_test.rs new file mode 100644 index 0000000..8770466 --- /dev/null +++ b/tests/resilience_test.rs @@ -0,0 +1,222 @@ +// Resilience tests for HAProxy gRPC Agent +// Tests backend disconnect, recovery, and reload scenarios +// These tests operate without HAProxy — only agent and mock backend + +mod common; + +use common::{cleanup_agent, send_check, start_agent, start_mock_backend}; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::time::sleep; + +/// Helper: retry sending a check until expected response or timeout. +/// Backend restart needs time — gRPC channel reconnection is not instant. +async fn send_check_with_retry( + agent_addr: std::net::SocketAddr, + host: &str, + port: u16, + expected: &str, + max_retries: usize, +) -> String { + let mut last_response = String::new(); + for _ in 0..max_retries { + last_response = send_check(agent_addr, host, port).await; + if last_response == expected { + return last_response; + } + sleep(Duration::from_millis(500)).await; + } + last_response +} + +/// Wait until a TCP port is accepting connections (backend is ready). +async fn wait_for_port(host: &str, port: u16, max_retries: usize) { + for _ in 0..max_retries { + if TcpStream::connect(format!("{}:{}", host, port)) + .await + .is_ok() + { + return; + } + sleep(Duration::from_millis(500)).await; + } + panic!( + "Port {}:{} did not become available after retries", + host, port + ); +} + +// Test that agent reports "down" when a previously healthy backend is stopped +#[tokio::test] +async fn test_backend_disconnect() { + let (container, backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; + + // Verify backend is initially healthy + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "up", "Backend should initially be up"); + + // Stop the backend container (simulates crash/disconnect) + container + .stop() + .await + .expect("Failed to stop mock backend"); + sleep(Duration::from_millis(500)).await; + + // Agent should now report the backend as down + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!( + response, "down", + "Agent should report 'down' after backend disconnect" + ); + + cleanup_agent(handle); +} + +// Test that agent recovers after backend restart +#[tokio::test] +async fn test_backend_recovery() { + let (container, backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; + + // Verify backend is initially healthy + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "up", "Backend should initially be up"); + + // Stop the backend + container + .stop() + .await + .expect("Failed to stop mock backend"); + sleep(Duration::from_millis(500)).await; + + // Verify backend is down + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "down", "Backend should be down after stop"); + + // Restart the backend — Docker reassigns the host port mapping + container + .start() + .await + .expect("Failed to restart mock backend"); + + // Docker assigns a new host port after restart — re-query it + let new_port = container + .get_host_port_ipv4(50051) + .await + .expect("Failed to get new port after restart"); + + // Wait for the new port to accept TCP connections + wait_for_port("127.0.0.1", new_port, 20).await; + + // Agent should connect to the new port and report "up" + let response = + send_check_with_retry(agent_addr, "127.0.0.1", new_port, "up", 20).await; + assert_eq!( + response, "up", + "Agent should report 'up' after backend recovery" + ); + + cleanup_agent(handle); +} + +// Test that cached gRPC channel is invalidated on disconnect +#[tokio::test] +async fn test_cached_connection_invalidated_on_disconnect() { + let (container, backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; + + // Send checks twice to prime the gRPC channel cache + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "up", "First check should be up"); + + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "up", "Second check (cached channel) should be up"); + + // Stop backend — cached channel should become invalid + container + .stop() + .await + .expect("Failed to stop mock backend"); + sleep(Duration::from_millis(500)).await; + + // Agent must NOT return stale "up" from cached channel + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!( + response, "down", + "Agent should report 'down' (not stale 'up' from cache) after disconnect" + ); + + cleanup_agent(handle); +} + +// Test that agent reflects changed health status after backend reload +#[tokio::test] +async fn test_backend_status_change_on_reload() { + let (container, backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; + + // Verify initially healthy + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "up", "Backend should initially be up (SERVING)"); + + // Stop the original container + container + .stop() + .await + .expect("Failed to stop original backend"); + sleep(Duration::from_millis(500)).await; + + // Start a NEW container with NOT_SERVING status + // (cannot change env vars on stopped container, so we create a new one) + let (_container2, backend_port2) = start_mock_backend("NOT_SERVING").await; + + // Check the new backend — should report down (NOT_SERVING) + let response = send_check(agent_addr, "127.0.0.1", backend_port2).await; + assert_eq!( + response, "down", + "Agent should report 'down' for NOT_SERVING backend" + ); + + cleanup_agent(handle); +} + +// Test that agent reconnects after backend restart with same status +#[tokio::test] +async fn test_backend_restart_with_same_status() { + let (container, backend_port) = start_mock_backend("SERVING").await; + let (handle, agent_addr) = start_agent().await; + + // Verify initially healthy + let response = send_check(agent_addr, "127.0.0.1", backend_port).await; + assert_eq!(response, "up", "Backend should initially be up"); + + // Stop and restart with same status + container + .stop() + .await + .expect("Failed to stop mock backend"); + container + .start() + .await + .expect("Failed to restart mock backend"); + + // Docker assigns a new host port after restart — re-query it + let new_port = container + .get_host_port_ipv4(50051) + .await + .expect("Failed to get new port after restart"); + + // Wait for the new port to accept TCP connections + wait_for_port("127.0.0.1", new_port, 20).await; + + // Agent should connect to the new port and report "up" + let response = + send_check_with_retry(agent_addr, "127.0.0.1", new_port, "up", 20).await; + assert_eq!( + response, "up", + "Agent should report 'up' after restart with same status" + ); + + cleanup_agent(handle); +} From fe6f44b43b7396e7fda4be905a2dbe80c945194f Mon Sep 17 00:00:00 2001 From: Alik Kurdyukov Date: Sat, 7 Feb 2026 23:51:23 +0400 Subject: [PATCH 2/3] fmt applied --- src/checker.rs | 11 ++++------- src/config.rs | 2 +- tests/common/mod.rs | 5 +---- tests/resilience_test.rs | 26 ++++++-------------------- 4 files changed, 12 insertions(+), 32 deletions(-) diff --git a/src/checker.rs b/src/checker.rs index 4c1df58..5f1f451 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -55,13 +55,10 @@ impl GrpcHealthChecker { drop(channel); // Release the DashMap lock // Try to get the channel ready with a very short timeout - let ready_check = tokio::time::timeout( - Duration::from_millis(10), - async { - let mut grpc = tonic::client::Grpc::new(channel_clone.clone()); - grpc.ready().await - } - ); + let ready_check = tokio::time::timeout(Duration::from_millis(10), async { + let mut grpc = tonic::client::Grpc::new(channel_clone.clone()); + grpc.ready().await + }); if ready_check.await.is_ok() { return Ok(channel_clone); diff --git a/src/config.rs b/src/config.rs index 9ebcfda..183fd83 100644 --- a/src/config.rs +++ b/src/config.rs @@ -456,4 +456,4 @@ mod tests { assert_eq!(config.metrics_port, 9090); assert_eq!(config.metrics_bind_address, "0.0.0.0"); } -} \ No newline at end of file +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 991362a..63dab22 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -28,10 +28,7 @@ pub fn build_mock_image() { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "Failed to build mock-grpc-backend Docker image: {}", - stderr - ); + panic!("Failed to build mock-grpc-backend Docker image: {}", stderr); } }); } diff --git a/tests/resilience_test.rs b/tests/resilience_test.rs index 8770466..e37fc0f 100644 --- a/tests/resilience_test.rs +++ b/tests/resilience_test.rs @@ -57,10 +57,7 @@ async fn test_backend_disconnect() { assert_eq!(response, "up", "Backend should initially be up"); // Stop the backend container (simulates crash/disconnect) - container - .stop() - .await - .expect("Failed to stop mock backend"); + container.stop().await.expect("Failed to stop mock backend"); sleep(Duration::from_millis(500)).await; // Agent should now report the backend as down @@ -84,10 +81,7 @@ async fn test_backend_recovery() { assert_eq!(response, "up", "Backend should initially be up"); // Stop the backend - container - .stop() - .await - .expect("Failed to stop mock backend"); + container.stop().await.expect("Failed to stop mock backend"); sleep(Duration::from_millis(500)).await; // Verify backend is down @@ -110,8 +104,7 @@ async fn test_backend_recovery() { wait_for_port("127.0.0.1", new_port, 20).await; // Agent should connect to the new port and report "up" - let response = - send_check_with_retry(agent_addr, "127.0.0.1", new_port, "up", 20).await; + let response = send_check_with_retry(agent_addr, "127.0.0.1", new_port, "up", 20).await; assert_eq!( response, "up", "Agent should report 'up' after backend recovery" @@ -134,10 +127,7 @@ async fn test_cached_connection_invalidated_on_disconnect() { assert_eq!(response, "up", "Second check (cached channel) should be up"); // Stop backend — cached channel should become invalid - container - .stop() - .await - .expect("Failed to stop mock backend"); + container.stop().await.expect("Failed to stop mock backend"); sleep(Duration::from_millis(500)).await; // Agent must NOT return stale "up" from cached channel @@ -192,10 +182,7 @@ async fn test_backend_restart_with_same_status() { assert_eq!(response, "up", "Backend should initially be up"); // Stop and restart with same status - container - .stop() - .await - .expect("Failed to stop mock backend"); + container.stop().await.expect("Failed to stop mock backend"); container .start() .await @@ -211,8 +198,7 @@ async fn test_backend_restart_with_same_status() { wait_for_port("127.0.0.1", new_port, 20).await; // Agent should connect to the new port and report "up" - let response = - send_check_with_retry(agent_addr, "127.0.0.1", new_port, "up", 20).await; + let response = send_check_with_retry(agent_addr, "127.0.0.1", new_port, "up", 20).await; assert_eq!( response, "up", "Agent should report 'up' after restart with same status" From 6affed7cfef6925ae4bc54b0c4353edbc3ccaf18 Mon Sep 17 00:00:00 2001 From: Alik Kurdyukov Date: Sat, 7 Feb 2026 23:58:36 +0400 Subject: [PATCH 3/3] invalid option removed --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12e2246..7de57ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,10 +41,10 @@ jobs: run: cargo test --lib --bins --verbose - name: Run integration tests - run: cargo test --test integration_test -- --test-threads=1 --verbose + run: cargo test --test integration_test -- --test-threads=1 - name: Run resilience tests - run: cargo test --test resilience_test -- --test-threads=1 --verbose + run: cargo test --test resilience_test -- --test-threads=1 build-and-publish: name: Build and Publish Docker Image