diff --git a/.cursor/rules/ask-generate-pr.mdc b/.cursor/rules/ask-generate-pr.mdc new file mode 100644 index 0000000..1de17d3 --- /dev/null +++ b/.cursor/rules/ask-generate-pr.mdc @@ -0,0 +1,22 @@ +--- +alwaysApply: true +--- + +# Ask task: generate PR text for copy/paste + +Apply this rule only when the Ask prompt starts with `gh-pr`. + +- Always output PR content inside Markdown code blocks so the user can copy/paste directly. +- Output exactly two Markdown code blocks and nothing else: + 1) `title` + 2) `description` +- Compare current branch against `main` unless the user specifies another base branch. +- Keep output minimal, include only what is needed. +- Title format: `: ` where `` is one of `fix`, `refactor`, `chore`, `feat`. +- Title summary must be sentence case: capitalize only the first word after + `:` (example: `refactor: Rebuild the whole project`). +- Description format: + - If short, do not add section headers, return only concise bullet points. + - Each bullet point must start with a capital letter. + - Add a Mermaid diagram only if the changes are complex and the diagram adds clarity. +- Do not include labels or label syntax. diff --git a/Cargo.lock b/Cargo.lock index 41d5971..a145b40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.3.4", "once_cell", "version_check", @@ -283,10 +283,13 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "itoa", "matchit", "memchr", @@ -294,10 +297,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -316,6 +324,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -339,12 +348,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.1" @@ -393,12 +396,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bytesize" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" - [[package]] name = "cap-fs-ext" version = "4.0.2" @@ -453,12 +450,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cfg-if" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" - [[package]] name = "cfg-if" version = "1.0.4" @@ -477,7 +468,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", "rand_core", ] @@ -668,7 +659,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -841,7 +832,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -999,7 +990,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -1228,7 +1219,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "wasi", @@ -1241,7 +1232,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "r-efi 5.3.0", "wasip2", @@ -1253,7 +1244,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "js-sys", "libc", "r-efi 6.0.0", @@ -1702,7 +1693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1759,7 +1750,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "combine", "jni-macros", "jni-sys", @@ -1818,7 +1809,7 @@ version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "futures-util", "wasm-bindgen", ] @@ -1882,11 +1873,12 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "macaw" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", + "async-stream", + "axum", "bytes", - "bytesize", "clap", "colored", "console-subscriber", @@ -1898,20 +1890,25 @@ dependencies = [ "macaw-http", "macaw-wasm", "macaw-ws", + "reqwest", "serde", "serde_json", - "signal", + "tempfile", "terminal_size", + "thiserror", "tokio", "tokio-stream", "toml", + "tower", "tracing", "tracing-subscriber", + "unicode-width", + "uuid", ] [[package]] name = "macaw-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "arrayvec", @@ -1947,7 +1944,7 @@ dependencies = [ [[package]] name = "macaw-http" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", @@ -1981,22 +1978,27 @@ dependencies = [ "tower-service", "tracing", "typetag", + "url", "uuid", ] [[package]] name = "macaw-wasm" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", + "dyn-clonable", "http", "httpmock", "macaw-core", "macaw-http", "reqwest", + "serde", "serde_json", "tempfile", "tokio", + "typetag", + "url", "uuid", "wasmtime", "wasmtime-wasi", @@ -2005,7 +2007,7 @@ dependencies = [ [[package]] name = "macaw-ws" -version = "0.1.0" +version = "0.2.0" dependencies = [ "ahash", "anyhow", @@ -2027,6 +2029,7 @@ dependencies = [ "tokio-tungstenite", "tracing", "typetag", + "url", ] [[package]] @@ -2104,19 +2107,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nix" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c722bee1037d430d0f8e687bbdbf222f27cc6e4e68d5caf630857bb2b6dbdce" -dependencies = [ - "bitflags 1.3.2", - "cc", - "cfg-if 0.1.10", - "libc", - "void", -] - [[package]] name = "nom" version = "7.1.3" @@ -2240,7 +2230,7 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "redox_syscall", "smallvec", @@ -2561,7 +2551,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags", ] [[package]] @@ -2618,6 +2608,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -2639,12 +2630,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -2655,7 +2648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "getrandom 0.2.17", "libc", "untrusted", @@ -2698,7 +2691,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.1", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2833,7 +2826,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2903,6 +2896,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_regex" version = "1.2.0" @@ -2922,6 +2926,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2941,7 +2957,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -2952,7 +2968,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.3.0", "digest 0.11.3", ] @@ -2963,7 +2979,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -2983,16 +2999,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f6ce83b159ab6984d2419f495134972b48754d13ff2e3f8c998339942b56ed9" -dependencies = [ - "libc", - "nix", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3128,7 +3134,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3222,7 +3228,7 @@ version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", ] [[package]] @@ -3465,7 +3471,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags", "bytes", "futures-util", "http", @@ -3690,12 +3696,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - [[package]] name = "walkdir" version = "2.5.0" @@ -3736,7 +3736,7 @@ version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -3805,13 +3805,26 @@ dependencies = [ "wasmparser 0.256.0", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" dependencies = [ - "bitflags 2.13.1", + "bitflags", "hashbrown 0.17.1", "indexmap", "semver", @@ -3824,7 +3837,7 @@ version = "0.256.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60bd825ffedc6cba8a642924ba7ae424afbc47811cffbcb7b92031ec24e59b4c" dependencies = [ - "bitflags 2.13.1", + "bitflags", "indexmap", "semver", ] @@ -3848,10 +3861,10 @@ checksum = "c80ca6098e0d4d06886d91d7f2cc3cb6623eb583c4c0ab3c89cbfb6098c8586c" dependencies = [ "addr2line", "async-trait", - "bitflags 2.13.1", + "bitflags", "bumpalo", "cc", - "cfg-if 1.0.4", + "cfg-if", "encoding_rs", "futures", "libc", @@ -3951,7 +3964,7 @@ version = "47.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6851ebc9e03cab23d9821d2b2505d380bdfe38f35ccec945247b05274d85c98b" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cranelift-codegen", "cranelift-control", "cranelift-entity", @@ -3979,7 +3992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b26da6d5f60d4c438da70bba3553fe810a840533a64156be287dca2081c6991" dependencies = [ "cc", - "cfg-if 1.0.4", + "cfg-if", "libc", "rustix", "wasmtime-environ", @@ -4003,7 +4016,7 @@ version = "47.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5684ba160951baad06a725696f3c590e2fb0e8067c2aebee27bf7f9259058e85" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "libc", "wasmtime-internal-core", "windows-sys 0.61.2", @@ -4015,7 +4028,7 @@ version = "47.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112eead527bffa8ff0646a11fb4339a9d52ddd1da2b9a6fce4aff84c815dd94f" dependencies = [ - "cfg-if 1.0.4", + "cfg-if", "cranelift-codegen", "log", "object", @@ -4040,7 +4053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c456ad6e81e0f46abfeca43687d18ea15e289c395b262ea6e0023e2682e088be" dependencies = [ "anyhow", - "bitflags 2.13.1", + "bitflags", "heck", "indexmap", "wit-parser", @@ -4053,11 +4066,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c1cf60cd6a565213af7074b4aad7bb5e110e3c6c6aae54425aa0500a0e15e86" dependencies = [ "async-trait", - "bitflags 2.13.1", + "bitflags", "bytes", "cap-fs-ext", "cap-std", - "cfg-if 1.0.4", + "cfg-if", "futures", "io-lifetimes 3.0.1", "rand", @@ -4220,7 +4233,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -4229,7 +4242,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -4247,14 +4269,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -4263,48 +4302,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" @@ -4317,7 +4404,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.13.1", + "bitflags", "windows-sys 0.59.0", ] diff --git a/Cargo.toml b/Cargo.toml index d816bf0..5bdd5a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ terminal_size = "0.4" arrayvec = { version = "0.7", features = [ "serde" ] } async-trait = "0.1.91" async-stream = "0.3.6" +axum = "0.8" base64 = "0.23" bytes = { version = "1.12", features = [ "serde" ] } chrono = { version = "0.4", features = [ "serde" ] } @@ -41,6 +42,7 @@ toml = "1.1" tokio = { version = "1", features = [ "full", "tracing" ] } tokio-tungstenite = { version = "0.30", features = [ "url", "rustls-tls-native-roots" ] } tokio-stream = { version = "0.1", features = [ "full" ] } +tower = "0.5" tracing = "0.1" typetag = "0.2.23" url = "2.5.8" @@ -69,13 +71,15 @@ rcgen = "0.14" [package] name = "macaw" -version.workspace = true -authors.workspace = true -edition.workspace = true +version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } [features] -default = [] -wasm = [ "dep:macaw-wasm" ] +default = [ "http", "ws" ] +http = [ "dep:macaw-http" ] +ws = [ "dep:macaw-ws" ] +wasm = [ "http", "dep:macaw-wasm" ] [lib] name = "macaw" @@ -84,10 +88,12 @@ path = "lib/lib.rs" [[example]] name = "recorder" path = "examples/recorder.rs" +required-features = [ "http", "ws" ] [[example]] name = "replayer" path = "examples/replayer.rs" +required-features = [ "http", "ws" ] [[example]] name = "wasm" @@ -100,25 +106,33 @@ path = "src/main.rs" [dependencies] macaw-core = { path = "macaw-core" } -macaw-http = { path = "macaw-http" } -macaw-ws = { path = "macaw-ws" } +macaw-http = { path = "macaw-http", optional = true } +macaw-ws = { path = "macaw-ws", optional = true } macaw-wasm = { path = "macaw-wasm", optional = true } anyhow = "*" +axum = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } clap = { workspace = true } toml = { workspace = true } serde = { workspace = true } -serde_json.workspace = true -bytes.workspace = true -http-body-util.workspace = true -hyper.workspace = true -hyper-util.workspace = true +serde_json = { workspace = true } +bytes = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true } +hyper-util = { workspace = true } colored = { workspace = true } terminal_size = { workspace = true } -futures.workspace = true -tracing.workspace = true -tokio.workspace = true +futures = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } tracing-subscriber = { version = "*", features = [ "env-filter" ] } console-subscriber = "0.5" tokio-stream = { version = "*", features = [ "full" ] } -signal = "0.7" -bytesize = "2" +reqwest = { workspace = true, features = ["json", "stream"] } +async-stream.workspace = true +unicode-width = "0.2.2" + +[dev-dependencies] +tempfile = { workspace = true } +tower = { workspace = true, features = [ "util" ] } diff --git a/README.md b/README.md index 68abaff..9215f95 100644 --- a/README.md +++ b/README.md @@ -59,23 +59,6 @@ macaw.wait_until_stopped().await?; ## CLI -The `macaw` binary provides record and replay commands driven by a TOML config file: - -```bash -# Record (proxies from macaw.toml) -macaw record ./data/record.json - -# Replay -macaw replay ./data/record.json - -# With debug mode (one-line traffic per message) -macaw -d record ./data/record.json -macaw -d replay ./data/record.json - -# Custom config file -macaw -c my_config.toml record ./data/record.json -``` - ### Config file (macaw.toml) ```toml @@ -92,7 +75,47 @@ target = "wss://echo.websocket.org/" overrides = "./overrides/ws.json" ``` -Recording stops and saves when you press Ctrl+C or kill the process. +### Control server and client + +Run a long-lived control server over TCP or a Unix socket: + +```bash +macaw serve --tcp 127.0.0.1:8080 +macaw serve --unix /tmp/macaw.sock +``` + +Use `macaw client` (or its `macaw ctl` alias) to manage profiles and sessions: + +```bash +macaw client profile create development --file config/macaw.toml +macaw client session record development --name test-run --output recordings/test.json +macaw client session list --profile development +macaw client watch test-run +macaw client session stop test-run +``` + +Commands that address a session (`get`, `stop`, `delete`, and `watch`) accept +either its ID or its unique name. + +Pass `--url http://host:port` or `--unix /path/to/socket` before the client +subcommand. `MACAW_CONTROL_URL` and `MACAW_CONTROL_UNIX` provide equivalent +defaults. Use `--output jsonl` for scripts and newline-delimited event streams. + +The foreground workflow creates a session, displays its proxy endpoints, +watches traffic, and stops and flushes the session on Ctrl+C: + +```bash +macaw client run record development --output recordings/test.json +macaw client run replay development --recording recordings/test.json +``` + +Watch streams the same typed entries used by recording files. Pretty output is +rendered client-side through each event type's debug formatter, uses color when +stdout is a terminal, and truncates to the terminal width. `--output jsonl` +emits one typed entry per line. `watch --headers` displays headers exposed by +the event. Traffic uses bounded, sequenced history so a watcher receives recent +events when it attaches or reconnects; a slow client receives a +`dropped_events` notification instead of blocking proxy traffic. ## Building diff --git a/lib/lib.rs b/lib/lib.rs index 8396704..ffdd70a 100644 --- a/lib/lib.rs +++ b/lib/lib.rs @@ -1,9 +1,13 @@ pub use macaw_core::prelude as core; +#[cfg(feature = "http")] pub use macaw_http::prelude as http; #[cfg(feature = "wasm")] pub use macaw_wasm::prelude as wasm; +#[cfg(feature = "ws")] pub use macaw_ws::prelude as ws; +pub mod session; + pub fn handle_app_error(error: core::AppError) { match error { core::AppError::ExitWithError(e) => { diff --git a/lib/session/manager.rs b/lib/session/manager.rs new file mode 100644 index 0000000..a5e975d --- /dev/null +++ b/lib/session/manager.rs @@ -0,0 +1,608 @@ +use super::{ + GetSessionStatus, ProfileId, ProfileSnapshot, SessionActor, SessionConfig, SessionError, + SessionErrorCode, SessionId, SessionMode, SessionName, SessionSnapshot, StartSession, + StopSession, SubscribeSessionTraffic, TrafficSubscription, +}; +use macaw_core::prelude::*; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct CreateSession { + pub id: Option, + pub name: Option, + pub profile_id: ProfileId, + pub mode: SessionMode, +} + +impl CreateSession { + pub fn new(profile_id: ProfileId, mode: SessionMode) -> Self { + Self { + id: None, + name: None, + profile_id, + mode, + } + } +} + +#[derive(Debug, Clone)] +pub struct CreateProfile { + pub id: ProfileId, + pub config: SessionConfig, +} + +#[derive(Debug, Clone)] +pub struct GetProfile { + pub id: ProfileId, +} + +#[derive(Debug)] +pub struct ListProfiles; + +#[derive(Debug, Clone)] +pub struct RemoveProfile { + pub id: ProfileId, +} + +#[derive(Debug, Clone)] +pub struct ListSessionsByProfile { + pub profile_id: ProfileId, +} + +#[derive(Debug, Clone, Copy)] +pub struct GetSession { + pub id: SessionId, +} + +#[derive(Debug, Clone)] +pub struct GetSessionByName { + pub name: SessionName, +} + +#[derive(Debug)] +pub struct ListSessions; + +#[derive(Debug, Clone, Copy)] +pub struct StopSessionById { + pub id: SessionId, +} + +#[derive(Debug, Clone, Copy)] +pub struct StartSessionById { + pub id: SessionId, +} + +#[derive(Debug, Clone, Copy)] +pub struct RemoveSession { + pub id: SessionId, +} + +#[derive(Debug)] +pub struct StopAllSessions; + +#[derive(Debug)] +pub struct BeginShutdown; + +#[derive(Debug)] +pub struct GetManagerReadiness; + +#[derive(Debug, Clone, Copy)] +pub struct SubscribeTraffic { + pub id: SessionId, + pub after: Option, +} + +#[derive(Debug)] +pub struct SessionManager { + context: ActorContext, + profiles: HashMap, + sessions: HashMap>, + accepting_sessions: bool, +} + +/// Typed façade over the session-manager actor. +/// +/// Actor transport failures are mapped to [`SessionErrorCode::ActorUnavailable`], +/// so callers see one semantic error layer instead of a nested `Result`. +#[derive(Debug, Clone)] +pub struct SessionManagerHandle { + actor: ActorHandle, +} + +impl SessionManager { + pub fn start() -> SessionManagerHandle { + let actor_system = ActorSystem::new(); + let actor = Self { + context: actor_system.actor_context("session-manager"), + profiles: HashMap::new(), + sessions: HashMap::new(), + accepting_sessions: true, + } + .run(); + SessionManagerHandle { actor } + } + + async fn snapshot(handle: &ActorHandle) -> Result { + handle + .request(GetSessionStatus) + .await + .map_err(SessionError::actor) + } + + async fn stop(handle: &ActorHandle) -> Result { + handle + .request(StopSession) + .await + .map_err(SessionError::actor)? + } + + async fn start_session( + handle: &ActorHandle, + ) -> Result { + handle + .request(StartSession) + .await + .map_err(SessionError::actor)? + } + + fn profile_snapshot(id: ProfileId, config: &SessionConfig) -> ProfileSnapshot { + ProfileSnapshot { + id, + config_root: config.root.clone(), + proxies: config + .proxies + .iter() + .map(|(name, proxy)| { + ( + name.clone(), + super::ProfileProxySnapshot { + protocol: proxy.protocol().to_owned(), + bind: proxy.bind().to_owned(), + target: proxy.target().map(str::to_owned), + overrides: proxy.overrides().map(str::to_owned), + }, + ) + }) + .collect(), + } + } +} + +impl SessionManagerHandle { + async fn request(&self, message: M) -> Result + where + SessionManager: ActorHandler, + M: ActorMessage, + R: Send + 'static, + { + self.actor + .request(message) + .await + .map_err(SessionError::actor) + } + + pub async fn record( + &self, + output: impl Into, + config: SessionConfig, + ) -> Result { + let profile_id = transient_profile_id(); + self.create_profile(profile_id.clone(), config).await?; + let snapshot = self + .request(CreateSession::new( + profile_id, + SessionMode::Record { + output: output.into(), + }, + )) + .await??; + self.start_session(snapshot.id).await + } + + pub async fn create_profile( + &self, + id: ProfileId, + config: SessionConfig, + ) -> Result { + self.request(CreateProfile { id, config }).await? + } + + pub async fn get_profile(&self, id: ProfileId) -> Result { + self.request(GetProfile { id }).await? + } + + pub async fn list_profiles(&self) -> Result, SessionError> { + self.request(ListProfiles).await + } + + pub async fn remove_profile(&self, id: ProfileId) -> Result<(), SessionError> { + self.request(RemoveProfile { id }).await? + } + + pub async fn create(&self, request: CreateSession) -> Result { + self.request(request).await? + } + + pub async fn replay( + &self, + recording: impl Into, + config: SessionConfig, + ) -> Result { + let profile_id = transient_profile_id(); + self.create_profile(profile_id.clone(), config).await?; + let snapshot = self + .request(CreateSession::new( + profile_id, + SessionMode::Replay { + recording: recording.into(), + }, + )) + .await??; + self.start_session(snapshot.id).await + } + + pub async fn get(&self, id: SessionId) -> Result { + self.request(GetSession { id }).await? + } + + pub async fn get_by_name(&self, name: SessionName) -> Result { + self.request(GetSessionByName { name }).await? + } + + pub async fn list(&self) -> Result, SessionError> { + self.request(ListSessions).await? + } + + pub async fn list_by_profile( + &self, + profile_id: ProfileId, + ) -> Result, SessionError> { + self.request(ListSessionsByProfile { profile_id }).await? + } + + pub async fn stop_session(&self, id: SessionId) -> Result { + self.request(StopSessionById { id }).await? + } + + pub async fn start_session(&self, id: SessionId) -> Result { + self.request(StartSessionById { id }).await? + } + + pub async fn remove(&self, id: SessionId) -> Result<(), SessionError> { + self.request(RemoveSession { id }).await? + } + + pub async fn stop_all( + &self, + ) -> Result>, SessionError> { + self.request(StopAllSessions).await + } + + pub async fn begin_shutdown(&self) -> Result<(), SessionError> { + self.request(BeginShutdown).await + } + + pub async fn is_ready(&self) -> Result { + self.request(GetManagerReadiness).await + } + + pub async fn subscribe_traffic( + &self, + id: SessionId, + after: Option, + ) -> Result { + self.request(SubscribeTraffic { id, after }).await? + } + + /// Gracefully stop all sessions and await manager completion. + pub async fn shutdown(&self) -> Result<(), SessionError> { + self.actor.stop(); + self.actor.wait().await.map_err(SessionError::actor) + } +} + +fn transient_profile_id() -> ProfileId { + ProfileId::new(format!("cli-{}", SessionId::new())) + .expect("generated transient profile id must be valid") +} + +impl Actor for SessionManager { + fn context(&self) -> &ActorContext { + &self.context + } + + async fn on_stop(&mut self, _reason: ActorStopReason) { + let handles = self.sessions.values().cloned().collect::>(); + for handle in handles { + let _ = Self::stop(&handle).await; + handle.stop(); + let _ = handle.wait().await; + } + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: CreateProfile) -> Self::Reply { + if !self.accepting_sessions { + return Err(SessionError::new( + SessionErrorCode::ShuttingDown, + "session manager is shutting down", + )); + } + if self.profiles.contains_key(&request.id) { + return Err(SessionError::new( + SessionErrorCode::Duplicate, + format!("profile {} already exists", request.id), + )); + } + SessionActor::validate_profile(&request.config)?; + let snapshot = Self::profile_snapshot(request.id.clone(), &request.config); + self.profiles.insert(request.id, request.config); + Ok(snapshot) + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: GetProfile) -> Self::Reply { + self.profiles + .get(&request.id) + .map(|config| Self::profile_snapshot(request.id, config)) + .ok_or_else(|| SessionError::new(SessionErrorCode::NotFound, "profile not found")) + } +} + +impl ActorHandler for SessionManager { + type Reply = Vec; + + async fn handle(&mut self, _request: ListProfiles) -> Self::Reply { + let mut profiles = self + .profiles + .iter() + .map(|(id, config)| Self::profile_snapshot(id.clone(), config)) + .collect::>(); + profiles.sort_by_key(|profile| profile.id.to_string()); + profiles + } +} + +impl ActorHandler for SessionManager { + type Reply = Result<(), SessionError>; + + async fn handle(&mut self, request: RemoveProfile) -> Self::Reply { + self.profiles + .remove(&request.id) + .map(|_| ()) + .ok_or_else(|| { + SessionError::new( + SessionErrorCode::NotFound, + format!("profile {} not found", request.id), + ) + }) + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: CreateSession) -> Self::Reply { + if !self.accepting_sessions { + return Err(SessionError::new( + SessionErrorCode::ShuttingDown, + "session manager is shutting down", + )); + } + let id = request.id.unwrap_or_default(); + if self.sessions.contains_key(&id) { + return Err(SessionError::new( + SessionErrorCode::Duplicate, + format!("session {id} already exists"), + )); + } + if let Some(name) = &request.name { + let handles = self.sessions.values().cloned().collect::>(); + for handle in handles { + if Self::snapshot(&handle).await?.name.as_ref() == Some(name) { + return Err(SessionError::new( + SessionErrorCode::Duplicate, + format!("session name {name} already exists"), + )); + } + } + } + let config = self + .profiles + .get(&request.profile_id) + .cloned() + .ok_or_else(|| { + SessionError::new( + SessionErrorCode::NotFound, + format!("profile {} not found", request.profile_id), + ) + })?; + let (handle, snapshot) = + SessionActor::prepare(id, request.name, request.profile_id, request.mode, config)?; + if self.sessions.insert(id, handle).is_some() { + return Err(SessionError::new( + SessionErrorCode::Duplicate, + format!("session {id} already exists"), + )); + } + Ok(snapshot) + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: GetSession) -> Self::Reply { + let handle = self.sessions.get(&request.id).ok_or_else(|| { + SessionError::new( + SessionErrorCode::NotFound, + format!("session {} not found", request.id), + ) + })?; + Self::snapshot(handle).await + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: GetSessionByName) -> Self::Reply { + let handles = self.sessions.values().cloned().collect::>(); + for handle in handles { + let snapshot = Self::snapshot(&handle).await?; + if snapshot.name.as_ref() == Some(&request.name) { + return Ok(snapshot); + } + } + Err(SessionError::new( + SessionErrorCode::NotFound, + format!("session {} not found", request.name), + )) + } +} + +impl ActorHandler for SessionManager { + type Reply = Result, SessionError>; + + async fn handle(&mut self, _request: ListSessions) -> Self::Reply { + let handles = self.sessions.values().cloned().collect::>(); + let mut snapshots = Vec::with_capacity(handles.len()); + for handle in handles { + snapshots.push(Self::snapshot(&handle).await?); + } + snapshots.sort_by_key(|snapshot| snapshot.id.to_string()); + Ok(snapshots) + } +} + +impl ActorHandler for SessionManager { + type Reply = Result, SessionError>; + + async fn handle(&mut self, request: ListSessionsByProfile) -> Self::Reply { + if !self.profiles.contains_key(&request.profile_id) { + return Err(SessionError::new( + SessionErrorCode::NotFound, + format!("profile {} not found", request.profile_id), + )); + } + let handles = self.sessions.values().cloned().collect::>(); + let mut snapshots = Vec::new(); + for handle in handles { + let snapshot = Self::snapshot(&handle).await?; + if snapshot.profile_id == request.profile_id { + snapshots.push(snapshot); + } + } + snapshots.sort_by_key(|snapshot| snapshot.id.to_string()); + Ok(snapshots) + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: StopSessionById) -> Self::Reply { + let handle = self.sessions.get(&request.id).ok_or_else(|| { + SessionError::new( + SessionErrorCode::NotFound, + format!("session {} not found", request.id), + ) + })?; + Self::stop(handle).await + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: StartSessionById) -> Self::Reply { + let handle = self.sessions.get(&request.id).ok_or_else(|| { + SessionError::new( + SessionErrorCode::NotFound, + format!("session {} not found", request.id), + ) + })?; + Self::start_session(handle).await + } +} + +impl ActorHandler for SessionManager { + type Reply = Result<(), SessionError>; + + async fn handle(&mut self, request: RemoveSession) -> Self::Reply { + let handle = self.sessions.get(&request.id).ok_or_else(|| { + SessionError::new( + SessionErrorCode::NotFound, + format!("session {} not found", request.id), + ) + })?; + let snapshot = Self::snapshot(handle).await?; + if !snapshot.state.is_terminal() && snapshot.state != super::SessionState::Ready { + return Err(SessionError::new( + SessionErrorCode::NotTerminal, + format!("session {} is running", request.id), + )); + } + if let Some(handle) = self.sessions.remove(&request.id) { + handle.stop(); + let _ = handle.wait().await; + } + Ok(()) + } +} + +impl ActorHandler for SessionManager { + type Reply = Vec>; + + async fn handle(&mut self, _request: StopAllSessions) -> Self::Reply { + self.accepting_sessions = false; + let handles = self.sessions.values().cloned().collect::>(); + let mut outcomes = Vec::with_capacity(handles.len()); + for handle in handles { + outcomes.push(Self::stop(&handle).await); + } + outcomes + } +} + +impl ActorHandler for SessionManager { + type Reply = (); + + async fn handle(&mut self, _request: BeginShutdown) { + self.accepting_sessions = false; + } +} + +impl ActorHandler for SessionManager { + type Reply = bool; + + async fn handle(&mut self, _request: GetManagerReadiness) -> Self::Reply { + self.accepting_sessions + } +} + +impl ActorHandler for SessionManager { + type Reply = Result; + + async fn handle(&mut self, request: SubscribeTraffic) -> Self::Reply { + let handle = self.sessions.get(&request.id).ok_or_else(|| { + SessionError::new( + SessionErrorCode::NotFound, + format!("session {} not found", request.id), + ) + })?; + handle + .request(SubscribeSessionTraffic { + after: request.after, + }) + .await + .map_err(SessionError::actor)? + } +} diff --git a/lib/session/mod.rs b/lib/session/mod.rs new file mode 100644 index 0000000..8de70a4 --- /dev/null +++ b/lib/session/mod.rs @@ -0,0 +1,11 @@ +mod manager; +#[allow(clippy::module_inception)] +mod session; +mod types; + +pub use manager::*; +pub use session::{ + GetSessionStatus, SequencedRecordedEvent, SessionActor, StartSession, StopSession, + SubscribeSessionTraffic, TrafficSubscription, +}; +pub use types::*; diff --git a/lib/session/session.rs b/lib/session/session.rs new file mode 100644 index 0000000..de78a34 --- /dev/null +++ b/lib/session/session.rs @@ -0,0 +1,509 @@ +use super::{ + ProfileId, SessionConfig, SessionEndpoint, SessionError, SessionId, SessionMode, SessionName, + SessionOutcome, SessionSnapshot, SessionState, +}; +use macaw_core::prelude::*; +use std::collections::{BTreeMap, VecDeque}; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use tokio::sync::{broadcast, watch}; + +const TRAFFIC_CHANNEL_CAPACITY: usize = 1024; +const TRAFFIC_HISTORY_CAPACITY: usize = 1024; +const DEFAULT_TRAFFIC_HISTORY: usize = 3; + +#[derive(Debug)] +pub struct GetSessionStatus; + +#[derive(Debug)] +pub struct StopSession; + +#[derive(Debug)] +pub struct StartSession; + +#[derive(Debug)] +pub struct SubscribeSessionTraffic { + pub after: Option, +} + +#[derive(Debug, Clone)] +pub struct SequencedRecordedEvent { + pub sequence: u64, + pub event: RecordedEvent, +} + +#[derive(Debug)] +pub struct TrafficSubscription { + pub history: Vec, + pub receiver: Option>, + pub dropped: u64, +} + +#[derive(Debug, Default)] +struct TrafficHistory { + next_sequence: u64, + events: VecDeque, +} + +impl TrafficHistory { + fn push(&mut self, event: RecordedEvent) -> SequencedRecordedEvent { + self.next_sequence += 1; + let event = SequencedRecordedEvent { + sequence: self.next_sequence, + event, + }; + self.events.push_back(event.clone()); + if self.events.len() > TRAFFIC_HISTORY_CAPACITY { + self.events.pop_front(); + } + event + } + + fn snapshot(&self, after: Option) -> (Vec, u64) { + match after { + Some(after) => { + let first = self.events.front().map(|event| event.sequence); + let dropped = first + .filter(|first| after.saturating_add(1) < *first) + .map(|first| first.saturating_sub(after.saturating_add(1))) + .unwrap_or(0); + ( + self.events + .iter() + .filter(|event| event.sequence > after) + .cloned() + .collect(), + dropped, + ) + } + None => ( + self.events + .iter() + .rev() + .take(DEFAULT_TRAFFIC_HISTORY) + .cloned() + .collect::>() + .into_iter() + .rev() + .collect(), + 0, + ), + } + } +} + +#[derive(Debug)] +struct SessionCompleted(SessionOutcome); + +#[derive(Debug)] +struct SessionFailed(SessionError); + +#[derive(Debug)] +pub struct SessionActor { + context: ActorContext, + id: SessionId, + name: Option, + profile_id: ProfileId, + mode: SessionMode, + state: SessionState, + endpoints: BTreeMap, + outcome: Option, + error: Option, + config: Option, + runtime_exit: Option, + completion: Option>>>, + traffic_history: Arc>, + traffic_tx: Option>, + actor_tx: ActorChannelSender, +} + +impl SessionActor { + pub(crate) fn validate_profile(config: &SessionConfig) -> Result<(), SessionError> { + validate_profile(config) + } + + pub(crate) fn prepare( + id: SessionId, + name: Option, + profile_id: ProfileId, + mode: SessionMode, + mut config: SessionConfig, + ) -> Result<(ActorHandle, SessionSnapshot), SessionError> { + config.resolve_proxy_paths(); + validate(&mode, &config)?; + let (traffic_tx, _) = broadcast::channel(TRAFFIC_CHANNEL_CAPACITY); + let actor_system = ActorSystem::new(); + let context = actor_system.actor_context(&format!("session:{id}")); + let (actor_tx, actor_rx) = actor_channel(); + + let actor = Self { + context, + id, + name, + profile_id, + mode, + state: SessionState::Ready, + endpoints: BTreeMap::new(), + outcome: None, + error: None, + config: Some(config), + runtime_exit: None, + completion: None, + traffic_history: Arc::new(Mutex::new(TrafficHistory::default())), + traffic_tx: Some(traffic_tx), + actor_tx: actor_tx.clone(), + }; + let snapshot = actor.snapshot(); + Ok((actor.run_with_channel(actor_tx, actor_rx), snapshot)) + } + + fn snapshot(&self) -> SessionSnapshot { + SessionSnapshot { + id: self.id, + name: self.name.clone(), + profile_id: self.profile_id.clone(), + mode: self.mode.clone(), + state: self.state, + endpoints: self.endpoints.clone(), + outcome: self.outcome.clone(), + error: self.error.clone(), + } + } + + fn apply_completion(&mut self, result: Result) { + if self.state.is_terminal() { + return; + } + self.traffic_tx.take(); + match result { + Ok(outcome) => { + self.state = SessionState::Stopped; + self.outcome = Some(outcome); + } + Err(error) => { + self.state = SessionState::Failed; + self.error = Some(error); + } + } + } + + async fn wait_for_completion( + &mut self, + ) -> Result, SessionError> { + let completion = self + .completion + .as_mut() + .ok_or_else(|| SessionError::runtime("session runtime was not started"))?; + loop { + if let Some(result) = completion.borrow_and_update().clone() { + return Ok(result); + } + completion + .changed() + .await + .map_err(|_| SessionError::runtime("session completion channel closed"))?; + } + } +} + +impl Actor for SessionActor { + fn context(&self) -> &ActorContext { + &self.context + } + + async fn on_stop(&mut self, _reason: ActorStopReason) { + if !self.state.is_terminal() + && let Some(runtime_exit) = &self.runtime_exit + { + runtime_exit.exit(); + } + } +} + +impl ActorHandler for SessionActor { + type Reply = SessionSnapshot; + + async fn handle(&mut self, _message: GetSessionStatus) -> Self::Reply { + let completion = self + .completion + .as_ref() + .and_then(|completion| completion.borrow().clone()); + if let Some(result) = completion { + self.apply_completion(result); + } + self.snapshot() + } +} + +impl ActorHandler for SessionActor { + type Reply = Result; + + async fn handle(&mut self, _message: StartSession) -> Self::Reply { + if self.state != SessionState::Ready { + return Err(SessionError::new( + super::SessionErrorCode::Unsupported, + format!("session cannot start from state {:?}", self.state), + )); + } + self.state = SessionState::Starting; + let mut config = self + .config + .take() + .ok_or_else(|| SessionError::startup("session configuration is unavailable"))?; + let external_debug_tx = config.debug_tx.take(); + let (debug_tx, mut debug_rx) = tokio::sync::mpsc::unbounded_channel(); + config.debug_tx = Some(debug_tx); + + let (runtime_exit, completion_future) = match start_runtime(&self.mode, &config).await { + Ok(runtime) => runtime, + Err(error) => { + self.state = SessionState::Failed; + self.error = Some(error); + self.traffic_tx.take(); + return Ok(self.snapshot()); + } + }; + self.endpoints = completion_future.endpoints; + self.runtime_exit = Some(runtime_exit); + self.state = SessionState::Running; + + let traffic_history = self.traffic_history.clone(); + let traffic_tx = self + .traffic_tx + .as_ref() + .expect("ready session must have a traffic sender") + .clone(); + let traffic_bridge = tokio::spawn(async move { + while let Some(recorded) = debug_rx.recv().await { + if let Some(ref external) = external_debug_tx { + let _ = external.send(recorded.clone()); + } + let mut history = traffic_history + .lock() + .expect("traffic history lock poisoned"); + let sequenced = history.push(recorded); + let _ = traffic_tx.send(sequenced); + drop(history); + } + }); + + let (completion_tx, completion_rx) = watch::channel(None); + self.completion = Some(completion_rx); + let actor_tx = self.actor_tx.clone(); + tokio::spawn(async move { + let result = completion_future.future.await; + let _ = traffic_bridge.await; + completion_tx.send_replace(Some(result.clone())); + match result { + Ok(outcome) => { + let _ = actor_tx.send(SessionCompleted(outcome)); + } + Err(error) => { + let _ = actor_tx.send(SessionFailed(error)); + } + } + }); + + Ok(self.snapshot()) + } +} + +impl ActorHandler for SessionActor { + type Reply = Result; + + async fn handle(&mut self, _message: StopSession) -> Self::Reply { + if self.state.is_terminal() { + return Ok(self.snapshot()); + } + if self.state == SessionState::Ready { + self.state = SessionState::Stopped; + self.config.take(); + self.traffic_tx.take(); + return Ok(self.snapshot()); + } + if self.state != SessionState::Stopping { + self.state = SessionState::Stopping; + if let Some(runtime_exit) = &self.runtime_exit { + runtime_exit.exit(); + } + } + let result = self.wait_for_completion().await?; + self.apply_completion(result); + Ok(self.snapshot()) + } +} + +impl ActorHandler for SessionActor { + type Reply = Result; + + async fn handle(&mut self, message: SubscribeSessionTraffic) -> Self::Reply { + let history = self + .traffic_history + .lock() + .expect("traffic history lock poisoned"); + let (events, dropped) = history.snapshot(message.after); + let receiver = self.traffic_tx.as_ref().map(broadcast::Sender::subscribe); + Ok(TrafficSubscription { + history: events, + receiver, + dropped, + }) + } +} + +impl ActorHandler for SessionActor { + type Reply = (); + + async fn handle(&mut self, message: SessionCompleted) { + self.apply_completion(Ok(message.0)); + } +} + +impl ActorHandler for SessionActor { + type Reply = (); + + async fn handle(&mut self, message: SessionFailed) { + self.apply_completion(Err(message.0)); + } +} + +struct RuntimeCompletion { + endpoints: BTreeMap, + future: std::pin::Pin< + Box> + Send + 'static>, + >, +} + +async fn start_runtime( + mode: &SessionMode, + config: &SessionConfig, +) -> Result<(AppExitHandle, RuntimeCompletion), SessionError> { + match mode { + SessionMode::Record { output } => { + let output = config.resolve_path(output); + let mut runtime = Macaw::::recorder_with_options(RecorderOptions { + debug_tx: config.debug_tx.clone(), + }); + let endpoints = match bind_recorder(&mut runtime, config).await { + Ok(endpoints) => endpoints, + Err(error) => { + let _ = runtime.shutdown().await; + return Err(error); + } + }; + let exit = runtime.exit_handle(); + let future = Box::pin(async move { + let outcome = runtime + .record_when_exit(output) + .await + .map_err(|error| SessionError::runtime(error.to_string()))?; + Ok(SessionOutcome::Record { + recording_path: outcome.recording_path, + total_events: outcome.total_events, + total_bytes: outcome.total_bytes, + total_time_millis: outcome + .total_time + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64), + }) + }); + Ok((exit, RuntimeCompletion { endpoints, future })) + } + SessionMode::Replay { recording } => { + let recording = config.resolve_path(recording); + let mut runtime = Macaw::::replayer_with_options( + recording, + ReplayerOptions { + debug_tx: config.debug_tx.clone(), + }, + ) + .map_err(|error| SessionError::startup(error.to_string()))?; + let endpoints = match bind_replayer(&mut runtime, config).await { + Ok(endpoints) => endpoints, + Err(error) => { + let _ = runtime.shutdown().await; + return Err(error); + } + }; + runtime + .play() + .map_err(|error| SessionError::startup(error.to_string()))?; + let exit = runtime.exit_handle(); + let future = Box::pin(async move { + runtime + .wait_until_stopped() + .await + .map_err(|error| SessionError::runtime(error.to_string()))?; + Ok(SessionOutcome::Replay) + }); + Ok((exit, RuntimeCompletion { endpoints, future })) + } + } +} + +fn validate(mode: &SessionMode, config: &SessionConfig) -> Result<(), SessionError> { + validate_profile(config)?; + for (name, proxy) in &config.proxies { + proxy + .validate(matches!(mode, SessionMode::Record { .. })) + .map_err(|message| SessionError::invalid(format!("proxy {name}: {message}")))?; + } + Ok(()) +} + +fn validate_profile(config: &SessionConfig) -> Result<(), SessionError> { + if config.proxies.is_empty() { + return Err(SessionError::invalid("at least one proxy is required")); + } + for (name, proxy) in &config.proxies { + name.parse::() + .map_err(|_| SessionError::invalid(format!("invalid proxy id: {name}")))?; + proxy + .bind() + .parse::() + .map_err(|_| SessionError::invalid(format!("invalid bind address for proxy {name}")))?; + proxy + .validate(false) + .map_err(|message| SessionError::invalid(format!("proxy {name}: {message}")))?; + } + Ok(()) +} + +async fn bind_recorder( + runtime: &mut Macaw, + config: &SessionConfig, +) -> Result, SessionError> { + let mut endpoints = BTreeMap::new(); + for (name, proxy) in &config.proxies { + let endpoint = proxy + .bind_to_recorder(name, runtime) + .await + .map_err(|error| { + SessionError::startup(format!("failed to bind proxy {name}: {error}")) + })?; + endpoints.insert( + name.clone(), + SessionEndpoint::new(proxy.protocol(), endpoint), + ); + } + Ok(endpoints) +} + +async fn bind_replayer( + runtime: &mut Macaw, + config: &SessionConfig, +) -> Result, SessionError> { + let mut endpoints = BTreeMap::new(); + for (name, proxy) in &config.proxies { + let endpoint = proxy + .bind_to_replayer(name, runtime) + .await + .map_err(|error| { + SessionError::startup(format!("failed to bind proxy {name}: {error}")) + })?; + endpoints.insert( + name.clone(), + SessionEndpoint::new(proxy.protocol(), endpoint), + ); + } + Ok(endpoints) +} diff --git a/lib/session/types.rs b/lib/session/types.rs new file mode 100644 index 0000000..81ead5f --- /dev/null +++ b/lib/session/types.rs @@ -0,0 +1,319 @@ +use macaw_core::prelude::ProxyConfig; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ProfileId(String); + +impl ProfileId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.len() > 64 { + return Err("profile id must contain between 1 and 64 characters".to_owned()); + } + if matches!(value.as_str(), "." | "..") { + return Err("profile id must not be '.' or '..'".to_owned()); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err( + "profile id may contain only ASCII letters, digits, '-', '_', and '.'".to_owned(), + ); + } + Ok(Self(value)) + } +} + +impl fmt::Display for ProfileId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for ProfileId { + type Err = String; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileSnapshot { + pub id: ProfileId, + pub config_root: PathBuf, + pub proxies: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProfileProxySnapshot { + pub protocol: String, + pub bind: String, + pub target: Option, + pub overrides: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SessionId(Uuid); + +impl SessionId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } +} + +impl Default for SessionId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for SessionId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for SessionId { + type Err = uuid::Error; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SessionName(String); + +impl SessionName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.len() > 64 { + return Err("session name must contain between 1 and 64 characters".to_owned()); + } + if Uuid::parse_str(&value).is_ok() { + return Err("session name must not be a UUID".to_owned()); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err( + "session name may contain only ASCII letters, digits, '-', '_', and '.'".to_owned(), + ); + } + Ok(Self(value)) + } +} + +impl fmt::Display for SessionName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for SessionName { + type Err = String; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum SessionMode { + Record { output: PathBuf }, + Replay { recording: PathBuf }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + #[serde(default)] + pub root: PathBuf, + #[serde(default)] + pub proxies: BTreeMap>, + #[serde(skip)] + pub debug_tx: Option>, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + root: PathBuf::new(), + proxies: BTreeMap::new(), + debug_tx: None, + } + } +} + +impl SessionConfig { + pub fn resolve_path(&self, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + self.root.join(path) + } + } + + pub(crate) fn resolve_proxy_paths(&mut self) { + for proxy in self.proxies.values_mut() { + proxy.set_root_path(&self.root); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionState { + Starting, + Ready, + Running, + Stopping, + Stopped, + Failed, +} + +impl SessionState { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Stopped | Self::Failed) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionEndpoint { + pub protocol: String, + pub address: SocketAddr, + pub url: String, +} + +impl SessionEndpoint { + pub(crate) fn new(protocol: impl Into, address: SocketAddr) -> Self { + let protocol = protocol.into(); + let url = format!("{protocol}://{address}"); + Self { + protocol, + address, + url, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionSnapshot { + pub id: SessionId, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub profile_id: ProfileId, + pub mode: SessionMode, + pub state: SessionState, + pub endpoints: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub outcome: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum SessionOutcome { + Record { + recording_path: PathBuf, + total_events: usize, + total_bytes: Option, + total_time_millis: Option, + }, + Replay, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionErrorCode { + InvalidConfig, + StartupFailed, + RuntimeFailed, + NotFound, + Duplicate, + NotTerminal, + ActorUnavailable, + Unsupported, + ShuttingDown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[error("{code:?}: {message}")] +pub struct SessionError { + pub code: SessionErrorCode, + pub message: String, +} + +impl SessionError { + pub(crate) fn new(code: SessionErrorCode, message: impl Into) -> Self { + Self { + code, + message: sanitize(message.into()), + } + } + + pub(crate) fn invalid(message: impl Into) -> Self { + Self::new(SessionErrorCode::InvalidConfig, message) + } + + pub(crate) fn startup(message: impl Into) -> Self { + Self::new(SessionErrorCode::StartupFailed, message) + } + + pub(crate) fn runtime(message: impl Into) -> Self { + Self::new(SessionErrorCode::RuntimeFailed, message) + } + + pub(crate) fn actor(error: anyhow::Error) -> Self { + Self::new(SessionErrorCode::ActorUnavailable, error.to_string()) + } +} + +fn sanitize(message: String) -> String { + const MAX_ERROR_LENGTH: usize = 512; + let normalized = message.replace(['\r', '\n'], " "); + normalized.chars().take(MAX_ERROR_LENGTH).collect() +} + +#[cfg(test)] +mod tests { + use super::{ProfileId, SessionName}; + + #[test] + fn profile_ids_are_safe_for_urls_and_filenames() { + for valid in ["local", "payment-api", "staging_v2", "api.example"] { + assert!(ProfileId::new(valid).is_ok()); + } + for invalid in ["", ".", "..", "contains space", "contains/slash"] { + assert!(ProfileId::new(invalid).is_err()); + } + } + + #[test] + fn session_names_are_short_and_cli_safe() { + for valid in ["checkout", "record-1", "replay_test", "api.example"] { + assert!(SessionName::new(valid).is_ok()); + } + for invalid in [ + "", + "contains space", + "contains/slash", + "018f5f6d-f8d8-7d42-8e90-4ab8f55189ef", + ] { + assert!(SessionName::new(invalid).is_err()); + } + } +} diff --git a/macaw-core/src/actor/messaging.rs b/macaw-core/src/actor/messaging.rs index 42170c8..8db97c4 100644 --- a/macaw-core/src/actor/messaging.rs +++ b/macaw-core/src/actor/messaging.rs @@ -89,7 +89,9 @@ where match self.reply { Some(reply) => { let outcome = >::handle(actor, self.msg).await; - reply.send(outcome)?; + // The requester may be cancelled while the actor is handling the message. + // A dropped reply receiver does not make successful actor work fail. + let _ = reply.send(outcome); Ok(()) } None => { @@ -118,6 +120,8 @@ pub fn actor_channel() -> (ActorChannelSender, ActorChannelReceiver) where A: Actor, { + // Actor mailboxes are deliberately unbounded. Actors process one envelope at a + // time, preserving send order for each sender. let (tx, rx) = mpsc::unbounded_channel(); (ActorChannelSender(tx), ActorChannelReceiver(rx)) } diff --git a/macaw-core/src/actor/terminator.rs b/macaw-core/src/actor/terminator.rs index de6542d..98c2def 100644 --- a/macaw-core/src/actor/terminator.rs +++ b/macaw-core/src/actor/terminator.rs @@ -1,4 +1,5 @@ use crate::lib::*; +use std::sync::atomic::{AtomicBool, Ordering}; #[derive(Debug, Error)] pub enum AppError { @@ -13,28 +14,28 @@ pub enum AppError { pub struct AppTerminator { tx: mpsc::Sender>, rx: mpsc::Receiver>, - exit_notifier: Arc, + exit_notifier: watch::Sender, + exited: Arc, } impl AppTerminator { pub fn new() -> Self { let (tx, rx) = mpsc::channel(1); - let exit_notifier = Arc::new(tokio::sync::Notify::new()); + let (exit_notifier, _) = watch::channel(false); Self { tx, rx, exit_notifier, + exited: Arc::new(AtomicBool::new(false)), } } pub fn exit(&self) { - self.tx.try_send(Ok(())).ok(); - self.exit_notifier.notify_waiters(); + self.exit_handle().exit(); } pub fn exit_with_error(&self, error: anyhow::Error) { - self.tx.try_send(Err(error.into())).ok(); - self.exit_notifier.notify_waiters(); + self.exit_handle().exit_with_error(error); } pub(crate) fn exit_handle(&self) -> AppExitHandle { @@ -59,25 +60,54 @@ impl Default for AppTerminator { /// App exit handle to signal and observe application exit events. #[derive(Debug, Clone)] pub struct AppExitHandle { - notifier: Arc, + notifier_tx: watch::Sender, + notifier_rx: watch::Receiver, tx: mpsc::Sender>, + exited: Arc, } impl AppExitHandle { fn new(app_terminator: &AppTerminator) -> Self { - let notifier = Arc::clone(&app_terminator.exit_notifier); + let notifier_tx = app_terminator.exit_notifier.clone(); + let notifier_rx = app_terminator.exit_notifier.subscribe(); let tx = app_terminator.tx.clone(); - Self { notifier, tx } + let exited = Arc::clone(&app_terminator.exited); + Self { + notifier_tx, + notifier_rx, + tx, + exited, + } } pub fn exit(&self) { - self.tx.try_send(Ok(())).ok(); + self.publish(Ok(())); } pub fn exit_with_error(&self, error: anyhow::Error) { - self.tx.try_send(Err(error.into())).ok(); + self.publish(Err(error.into())); } - pub(crate) async fn notified(&self) { - self.notifier.notified().await + + fn publish(&self, result: Result<(), AppError>) { + if self + .exited + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.tx.try_send(result).ok(); + // `send_replace` latches the notification even when no receiver is waiting. + self.notifier_tx.send_replace(true); + } + } + + pub(crate) async fn notified(&mut self) { + if *self.notifier_rx.borrow_and_update() { + return; + } + while self.notifier_rx.changed().await.is_ok() { + if *self.notifier_rx.borrow_and_update() { + return; + } + } } } diff --git a/macaw-core/src/actor/tests/actor.rs b/macaw-core/src/actor/tests/actor.rs index 1a8c684..f9a39b0 100644 --- a/macaw-core/src/actor/tests/actor.rs +++ b/macaw-core/src/actor/tests/actor.rs @@ -221,6 +221,77 @@ async fn test_actor_request_error_actor_stopped() { assert_eq!(error.to_string(), "Failed to send request: channel closed"); } +#[tokio::test] +async fn test_exit_is_latched_before_actor_waits() { + let app_context = AppContext::new(); + let actor_context = app_context.actor_context("latched"); + app_context.exit(); + + let handle = TestActor::new(actor_context).run(); + handle.wait().await.unwrap(); + + let result = app_context.wait_until_exit().await; + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_repeated_exit_keeps_first_result() { + let app_context = AppContext::new(); + let exit = app_context.exit_handle(); + exit.exit_with_error(anyhow::anyhow!("first")); + exit.exit(); + exit.exit_with_error(anyhow::anyhow!("last")); + + match app_context.wait_until_exit().await.unwrap_err() { + AppError::ExitWithError(error) => assert_eq!(error.to_string(), "first"), + error => panic!("unexpected error: {error}"), + } +} + +#[tokio::test] +async fn test_actor_completion_has_multiple_waiters() { + let app_context = AppContext::new(); + let handle = TestActor::new(app_context.actor_context("completion")).run(); + let first = handle.completion(); + let second = handle.completion(); + handle.stop(); + + let (first, second) = tokio::join!(first.wait(), second.wait()); + first.unwrap(); + second.unwrap(); +} + +#[tokio::test] +async fn test_stopping_one_actor_does_not_stop_sibling() { + let app_context = AppContext::new(); + let first = TestActor::new(app_context.actor_context("first")).run(); + let second = TestActor::new(app_context.actor_context("second")).run(); + + first.stop(); + first.wait().await.unwrap(); + + assert_eq!(second.request(TestRequest { value: 4 }).await.unwrap(), 8); + second.stop(); + second.wait().await.unwrap(); +} + +#[tokio::test] +async fn test_cancelled_request_does_not_stop_actor() { + let app_context = AppContext::new(); + let handle = TestActor::new(app_context.actor_context("cancelled-request")).run(); + let requester = { + let handle = handle.clone(); + tokio::spawn(async move { handle.request(SlowRequest).await }) + }; + tokio::task::yield_now().await; + requester.abort(); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + + assert_eq!(handle.request(TestRequest { value: 3 }).await.unwrap(), 6); + handle.stop(); + handle.wait().await.unwrap(); +} + #[tokio::test] async fn test_actor_stopped_due_to_channel_closed() { let app_context = AppContext::new(); @@ -274,6 +345,9 @@ struct TestRequest { value: u32, } +#[derive(Debug)] +struct SlowRequest; + #[derive(Debug, Clone, PartialEq, Eq)] struct TestActorStateInner { messages_received: Vec, @@ -389,3 +463,11 @@ impl ActorHandler for TestActor { message.value * 2 } } + +impl ActorHandler for TestActor { + type Reply = (); + + async fn handle(&mut self, _message: SlowRequest) { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } +} diff --git a/macaw-core/src/actor/traits.rs b/macaw-core/src/actor/traits.rs index 0115245..50e2d53 100644 --- a/macaw-core/src/actor/traits.rs +++ b/macaw-core/src/actor/traits.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::task::JoinHandle; use crate::lib::*; @@ -31,11 +32,21 @@ pub trait Actor: Send + Sized + 'static { rx: ActorChannelReceiver, ) -> ActorHandle { let context = self.context().clone(); - let _task = tokio::task::Builder::new() + let task = tokio::task::Builder::new() .name(&context.name) - .spawn(run_actor(self, rx)); + .spawn(run_actor(self, rx)) + .expect("failed to spawn actor task"); + let (completion_tx, completion_rx) = watch::channel(ActorTaskStatus::Running); + tokio::spawn(async move { + let status = match task.await { + Ok(Ok(())) => ActorTaskStatus::Stopped, + Ok(Err(error)) => ActorTaskStatus::Failed(Arc::from(error.to_string())), + Err(error) => ActorTaskStatus::Failed(Arc::from(error.to_string())), + }; + completion_tx.send_replace(status); + }); - ActorHandle::new(tx, context) + ActorHandle::new(tx, context, completion_rx) } fn run(self) -> ActorHandle { @@ -56,41 +67,44 @@ where // ------------------------------------------------------------ #[derive(Debug)] -pub(crate) struct AppContext { +pub struct AppContext { terminate: AppTerminator, } impl AppContext { - pub(crate) fn new() -> Self { + pub fn new() -> Self { Self { terminate: AppTerminator::new(), } } - pub(crate) fn actor_context(&self, name: &str) -> ActorContext { + pub fn actor_context(&self, name: &str) -> ActorContext { let notifier = self.terminate.exit_handle(); ActorContext::new(name, notifier) } - pub(crate) fn exit_handle(&self) -> AppExitHandle { + pub fn exit_handle(&self) -> AppExitHandle { self.terminate.exit_handle() } #[allow(unused)] - pub(crate) fn exit(&self) { + pub fn exit(&self) { self.terminate.exit(); } #[allow(unused)] - pub(crate) fn exit_with_error(&self, error: anyhow::Error) { + pub fn exit_with_error(&self, error: anyhow::Error) { self.terminate.exit_with_error(error); } - pub(crate) async fn wait_until_exit(self) -> Result<(), AppError> { + pub async fn wait_until_exit(self) -> Result<(), AppError> { self.terminate.wait_until_exit().await } } +/// Public factory and lifecycle domain for a related group of actors. +pub type ActorSystem = AppContext; + impl Default for AppContext { fn default() -> Self { Self::new() @@ -104,6 +118,7 @@ pub struct ActorContext { name: String, exit_notifier: AppExitHandle, task_terminator: TaskTerminator, + tasks: Arc, } impl ActorContext { @@ -112,6 +127,7 @@ impl ActorContext { name: name.to_string(), exit_notifier, task_terminator: TaskTerminator::new(), + tasks: Arc::new(ActorTaskTracker::default()), } } @@ -124,6 +140,7 @@ impl ActorContext { name: self.name.clone() + ":" + name, exit_notifier: self.exit_notifier.clone(), task_terminator: TaskTerminator::new(), + tasks: Arc::new(ActorTaskTracker::default()), } } @@ -149,12 +166,64 @@ impl ActorContext { R: Send + 'static, { let name = self.name.clone() + ":" + name; + self.tasks.started(); + let tasks = Arc::clone(&self.tasks); let handle = tokio::task::Builder::new().name(&name).spawn({ let mut context = self.clone(); - async move { terminatable_future(&mut context, future).await } - })?; + async move { + let _guard = ActorTaskGuard(tasks); + terminatable_future(&mut context, future).await + } + }); + if handle.is_err() { + self.tasks.finished(); + } + let handle = handle?; Ok(handle) } + + async fn stop_and_join_children(&self, stop_tasks: bool) { + if stop_tasks { + self.task_terminator.stop(); + } + self.tasks.wait().await; + } +} + +struct ActorTaskGuard(Arc); + +impl Drop for ActorTaskGuard { + fn drop(&mut self) { + self.0.finished(); + } +} + +#[derive(Debug, Default)] +struct ActorTaskTracker { + active: AtomicUsize, + notify: tokio::sync::Notify, +} + +impl ActorTaskTracker { + fn started(&self) { + self.active.fetch_add(1, Ordering::AcqRel); + } + + fn finished(&self) { + if self.active.fetch_sub(1, Ordering::AcqRel) == 1 { + self.notify.notify_waiters(); + } + } + + async fn wait(&self) { + loop { + let notified = self.notify.notified(); + if self.active.load(Ordering::Acquire) == 0 { + return; + } + notified.await; + } + } } // ------------------------------------------------------------ @@ -165,6 +234,8 @@ pub trait ErasedActorHandle: Send { fn exit(&self); fn exit_with_error(&self, error: anyhow::Error); + + fn completion(&self) -> ActorCompletion; } impl fmt::Debug for Box { @@ -185,9 +256,17 @@ impl ActorHandle where A: Actor, { - pub fn new(tx: ActorChannelSender, context: ActorContext) -> Self { + pub fn new( + tx: ActorChannelSender, + context: ActorContext, + completion: watch::Receiver, + ) -> Self { Self { - inner: Arc::new(ActorHandleInner { tx, context }), + inner: Arc::new(ActorHandleInner { + tx, + context, + completion, + }), } } @@ -211,6 +290,31 @@ where { self.inner.request(message).await } + + /// Stop only this actor, leaving other actors in its system running. + pub fn stop(&self) { + self.inner.context.task_terminator.stop(); + } + + /// Request exit for the actor's whole application lifecycle. + pub fn exit(&self) { + self.inner.context.exit(); + } + + pub fn exit_with_error(&self, error: anyhow::Error) { + self.inner.context.exit_with_error(error); + } + + /// Return a cloneable completion observer. Every observer sees the same result. + pub fn completion(&self) -> ActorCompletion { + ActorCompletion { + receiver: self.inner.completion.clone(), + } + } + + pub async fn wait(&self) -> Result<(), anyhow::Error> { + self.completion().wait().await + } } impl Clone for ActorHandle @@ -231,6 +335,37 @@ where { tx: ActorChannelSender, context: ActorContext, + completion: watch::Receiver, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ActorTaskStatus { + Running, + Stopped, + Failed(Arc), +} + +#[derive(Debug, Clone)] +pub struct ActorCompletion { + receiver: watch::Receiver, +} + +impl ActorCompletion { + pub async fn wait(mut self) -> Result<(), anyhow::Error> { + loop { + match self.receiver.borrow_and_update().clone() { + ActorTaskStatus::Running => {} + ActorTaskStatus::Stopped => return Ok(()), + ActorTaskStatus::Failed(error) => { + return Err(anyhow::anyhow!(error.to_string())); + } + } + self.receiver + .changed() + .await + .map_err(|_| anyhow::anyhow!("actor completion channel closed"))?; + } + } } impl ActorHandleInner @@ -271,6 +406,10 @@ impl ErasedActorHandle for ActorHandle { fn exit_with_error(&self, error: anyhow::Error) { self.inner.context.exit_with_error(error); } + + fn completion(&self) -> ActorCompletion { + ActorHandle::completion(self) + } } // ------------------------------------------------------------ @@ -330,7 +469,9 @@ where } } } + let stop_tasks = stop_reason != ActorStopReason::ExitNotificationReceived; actor.on_stop(stop_reason).await; + context.stop_and_join_children(stop_tasks).await; Ok(()) } diff --git a/macaw-core/src/io/fs/storage.rs b/macaw-core/src/io/fs/storage.rs index 3444de8..9ccb2ec 100644 --- a/macaw-core/src/io/fs/storage.rs +++ b/macaw-core/src/io/fs/storage.rs @@ -7,7 +7,7 @@ use tokio::io::AsyncWriteExt; #[derive(Debug, Serialize, Deserialize)] pub struct RecordFile { pub(crate) header: RecordHeader, - pub(crate) events: Vec>>, + pub(crate) events: Vec, } impl RecordFile { @@ -19,7 +19,7 @@ impl RecordFile { #[derive(Debug)] pub(crate) struct EventStore { header: RecordHeader, - events: Vec>>, + events: Vec, } impl EventStore { @@ -91,11 +91,11 @@ impl EventStore { } } - pub(crate) fn push(&mut self, id: ProxyId, event: Box) { - self.events.push(Event::new(id, event)); + pub(crate) fn push(&mut self, event: RecordedEvent) { + self.events.push(event); } - pub(crate) fn iter(&self) -> impl Iterator>> { + pub(crate) fn iter(&self) -> impl Iterator { self.events.iter().cloned() } } diff --git a/macaw-core/src/macaw.rs b/macaw-core/src/macaw.rs index 782c4bd..757795a 100644 --- a/macaw-core/src/macaw.rs +++ b/macaw-core/src/macaw.rs @@ -8,7 +8,10 @@ pub trait ProxyActor: Actor { #[derive(Debug)] pub struct Macaw { + /// Lifecycle observed by callers. It is deliberately separate from the actor + /// system so a graceful stop can drain proxies before stopping the processor. context: AppContext, + actors: AppContext, processor: ActorHandle

, proxies: ProxyHandles, } @@ -18,8 +21,29 @@ impl Macaw

{ self.context.exit_handle() } - pub async fn wait_until_stopped(self) -> Result<(), AppError> { - self.context.wait_until_exit().await + async fn wait_for_stop_request( + context: AppContext, + actors: AppContext, + ) -> Result<(), AppError> { + tokio::select! { + result = context.wait_until_exit() => result, + result = actors.wait_until_exit() => result, + } + } + + /// Stop all runtime actors without waiting for an application exit request. + /// This is primarily used to roll back partially completed startup. + pub async fn shutdown(self) -> Result<(), AppError> { + let Self { + context: _, + actors: _, + processor, + mut proxies, + } = self; + proxies.stop_and_wait().await?; + processor.stop(); + processor.wait().await?; + Ok(()) } } @@ -30,11 +54,13 @@ impl Macaw { pub fn recorder_with_options(options: RecorderOptions) -> Self { let context = AppContext::new(); - let actor_context = context.actor_context("recorder"); + let actors = AppContext::new(); + let actor_context = actors.actor_context("recorder"); let processor = Recorder::new(actor_context, options); let handle = processor.run(); Self { context, + actors, processor: handle, proxies: ProxyHandles::new(), } @@ -44,11 +70,32 @@ impl Macaw { self, path: P, ) -> Result { - self.context.wait_until_exit().await?; - let stats = self - .processor + let Self { + context, + actors, + processor, + mut proxies, + } = self; + let stop_result = Self::wait_for_stop_request(context, actors).await; + + // Stop listeners first. Once their actor mailboxes are closed, every + // previously accepted event has either reached the recorder mailbox or + // can no longer be produced. + proxies.stop_and_wait().await?; + + if let Err(error) = stop_result { + processor.stop(); + processor.wait().await?; + return Err(error); + } + + // Actor mailboxes are FIFO, so this request is handled after all events + // already sent by the stopped proxies. This is the recording drain point. + let stats = processor .request(RecorderCommand::WriteToFile(path.as_ref().to_path_buf())) - .await?; + .await??; + processor.stop(); + processor.wait().await?; Ok(stats) } @@ -77,11 +124,13 @@ impl Macaw { options: ReplayerOptions, ) -> Result { let context = AppContext::new(); - let actor_context = context.actor_context("replayer"); + let actors = AppContext::new(); + let actor_context = actors.actor_context("replayer"); let processor = Replayer::new(actor_context, path, options)?; let handle = processor.run(); Ok(Self { context, + actors, processor: handle, proxies: ProxyHandles::new(), }) @@ -91,6 +140,20 @@ impl Macaw { self.processor.send(ReplayerCommand::Play) } + pub async fn wait_until_stopped(self) -> Result<(), AppError> { + let Self { + context, + actors, + processor, + mut proxies, + } = self; + let stop_result = Self::wait_for_stop_request(context, actors).await; + proxies.stop_and_wait().await?; + processor.stop(); + processor.wait().await?; + stop_result + } + pub async fn add_proxy( &mut self, f: F, @@ -126,6 +189,22 @@ impl ProxyHandles { fn insert(&mut self, proxy_id: ProxyId, handle: H) { self.0.insert(proxy_id, Box::new(handle)); } + + async fn stop_and_wait(&mut self) -> Result<(), anyhow::Error> { + for handle in self.0.values() { + handle.stop(); + } + let completions = self + .0 + .values() + .map(|handle| handle.completion()) + .collect::>(); + for completion in completions { + completion.wait().await?; + } + self.0.clear(); + Ok(()) + } } impl Default for ProxyHandles { diff --git a/macaw-core/src/model/config.rs b/macaw-core/src/model/config.rs index e909dce..c725258 100644 --- a/macaw-core/src/model/config.rs +++ b/macaw-core/src/model/config.rs @@ -2,11 +2,22 @@ use std::net::SocketAddr; use crate::lib::*; +#[dyn_clonable::clonable] #[typetag::serde(tag = "type")] -pub trait ProxyConfig { +pub trait ProxyConfig: Clone + Send + Sync { + /// Protocol exposed by this proxy (for example `http` or `ws`). + fn protocol(&self) -> &'static str; + + fn validate(&self, recording: bool) -> Result<(), String> { + if recording && self.target().is_none_or(str::is_empty) { + return Err("recording proxy requires a target".to_owned()); + } + Ok(()) + } + fn bind(&self) -> &str; - fn target(&self) -> &str; + fn target(&self) -> Option<&str>; fn overrides(&self) -> Option<&str>; @@ -16,13 +27,13 @@ pub trait ProxyConfig { &'a self, proxy_id: &'b str, macaw: &'b mut Macaw, - ) -> Pin> + 'b>>; + ) -> Pin> + Send + 'b>>; fn bind_to_replayer<'b, 'a: 'b>( &'a self, proxy_id: &'b str, macaw: &'b mut Macaw, - ) -> Pin> + 'b>>; + ) -> Pin> + Send + 'b>>; } impl fmt::Debug for Box { diff --git a/macaw-core/src/model/event.rs b/macaw-core/src/model/event.rs index 3637fd0..4e78062 100644 --- a/macaw-core/src/model/event.rs +++ b/macaw-core/src/model/event.rs @@ -2,10 +2,13 @@ use crate::lib::*; use base64::{Engine, prelude::BASE64_STANDARD}; use std::any::Any; -/// Message wrapping a `RecordEvent` associated with a specific proxy. -#[derive(Debug)] +/// A timestamped recording entry associated with a specific proxy. +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecordedEvent { + #[serde(rename = "proxy")] pub proxy_id: ProxyId, + pub timestamp: DateTime, + #[serde(flatten)] pub event: Box, } @@ -13,6 +16,7 @@ impl RecordedEvent { pub fn new(proxy_id: ProxyId, event: E) -> Self { Self { proxy_id, + timestamp: Utc::now(), event: Box::new(event), } } @@ -136,6 +140,11 @@ pub trait RecordEvent: Send + Sync + Any + fmt::Debug + Clone + 'static { vec![RecordPart::Content(format!("{:?}", self))], ) } + + /// Return headers that are safe to expose in human-facing diagnostics. + fn debug_headers(&self) -> BTreeMap { + BTreeMap::new() + } } impl dyn RecordEvent { @@ -175,25 +184,6 @@ impl RecordHeader { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub(crate) struct Event { - #[serde(rename = "proxy")] - pub(crate) proxy_id: ProxyId, - pub(crate) timestamp: DateTime, - #[serde(flatten)] - pub(crate) data: D, -} - -impl Event { - pub(crate) fn new(proxy_id: ProxyId, data: D) -> Self { - Self { - proxy_id, - timestamp: Utc::now(), - data, - } - } -} - // ---------------------------------------- #[derive(Debug, Clone)] diff --git a/macaw-core/src/processor/recorder.rs b/macaw-core/src/processor/recorder.rs index d835bac..0064150 100644 --- a/macaw-core/src/processor/recorder.rs +++ b/macaw-core/src/processor/recorder.rs @@ -46,21 +46,19 @@ impl Actor for Recorder { } impl ActorHandler for Recorder { - type Reply = RecorderOutcome; + type Reply = Result; - async fn handle(&mut self, request: RecorderCommand) -> RecorderOutcome { + async fn handle(&mut self, request: RecorderCommand) -> Self::Reply { match request { RecorderCommand::WriteToFile(path) => { - if let Err(e) = self.events.save_file(&path).await { - self.context.exit_with_error(e); - } + self.events.save_file(&path).await?; let total_bytes = path.metadata().ok().map(|m| m.len() as usize); - RecorderOutcome { + Ok(RecorderOutcome { recording_path: path, total_bytes, total_events: self.events.events_count(), total_time: self.events.duration(), - } + }) } } } @@ -71,11 +69,8 @@ impl ActorHandler for Recorder { async fn handle(&mut self, message: RecordedEvent) { if let Some(ref tx) = self.debug_tx { - let _ = tx.send(RecordedEvent { - proxy_id: message.proxy_id, - event: message.event.clone(), - }); + let _ = tx.send(message.clone()); } - self.events.push(message.proxy_id, message.event); + self.events.push(message); } } diff --git a/macaw-core/src/processor/replayer.rs b/macaw-core/src/processor/replayer.rs index e7f5a1b..15f9600 100644 --- a/macaw-core/src/processor/replayer.rs +++ b/macaw-core/src/processor/replayer.rs @@ -67,11 +67,16 @@ impl ActorHandler for Replayer { impl Replayer { async fn play(&mut self) -> Result<(), anyhow::Error> { for event in self.events.iter() { - let Event { proxy_id, data, .. } = event; + let RecordedEvent { + proxy_id, + timestamp, + event, + } = event; if let Some(ref tx) = self.debug_tx { let _ = tx.send(RecordedEvent { proxy_id, - event: data.clone(), + timestamp, + event: event.clone(), }); } let (replay_lock_holder, replay_lock) = lock_channel(); @@ -82,7 +87,7 @@ impl Replayer { ))?; proxy.send(RecordedEventWithLock { proxy_id, - event: data, + event, replay_lock: replay_lock_holder, })?; replay_lock.wait().await; diff --git a/macaw-http/Cargo.toml b/macaw-http/Cargo.toml index 6ae97b0..960a247 100644 --- a/macaw-http/Cargo.toml +++ b/macaw-http/Cargo.toml @@ -33,6 +33,7 @@ serde_json.workspace = true tokio.workspace = true tracing.workspace = true typetag.workspace = true +url.workspace = true uuid.workspace = true pin-project-lite.workspace = true take_mut.workspace = true diff --git a/macaw-http/src/model/event.rs b/macaw-http/src/model/event.rs index f5af906..673df2d 100644 --- a/macaw-http/src/model/event.rs +++ b/macaw-http/src/model/event.rs @@ -90,6 +90,10 @@ impl RecordEvent for HttpRequestEvent { ], ) } + + fn debug_headers(&self) -> BTreeMap { + self.headers.clone() + } } #[derive(thiserror::Error, Debug)] @@ -206,4 +210,8 @@ impl RecordEvent for HttpResponseEvent { ], ) } + + fn debug_headers(&self) -> BTreeMap { + self.headers.clone() + } } diff --git a/macaw-http/src/proxy/config.rs b/macaw-http/src/proxy/config.rs index 07ab2f0..06ff861 100644 --- a/macaw-http/src/proxy/config.rs +++ b/macaw-http/src/proxy/config.rs @@ -2,20 +2,59 @@ use std::pin::Pin; use crate::lib::*; -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct HttpProxyConfig { +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HttpProxyConfig { + #[serde(default = "default_bind")] bind: String, - target: String, + #[serde(default)] + target: Option, + #[serde(default)] overrides: Option, } +impl HttpProxyConfig { + pub fn new(bind: impl Into, target: impl Into) -> Self { + Self { + bind: bind.into(), + target: Some(target.into()), + overrides: None, + } + } + + pub fn replay(bind: impl Into) -> Self { + Self { + bind: bind.into(), + target: None, + overrides: None, + } + } + + pub fn with_overrides(mut self, path: impl Into) -> Self { + self.overrides = Some(path.into()); + self + } +} + +fn default_bind() -> String { + "127.0.0.1:0".to_string() +} + #[typetag::serde(name = "http")] impl ProxyConfig for HttpProxyConfig { + fn protocol(&self) -> &'static str { + "http" + } + + fn validate(&self, recording: bool) -> Result<(), String> { + validate_target(self.target.as_deref(), recording, &["http", "https"]) + } + fn bind(&self) -> &str { &self.bind } - fn target(&self) -> &str { - &self.target + fn target(&self) -> Option<&str> { + self.target.as_deref() } fn overrides(&self) -> Option<&str> { self.overrides.as_deref() @@ -31,7 +70,7 @@ impl ProxyConfig for HttpProxyConfig { &'a self, proxy_id: &'b str, macaw: &'b mut Macaw, - ) -> Pin> + 'b>> { + ) -> Pin> + Send + 'b>> { let overrides = load_overrides(self.overrides.as_deref()); Box::pin(async move { let options = HttpProxyOptions { @@ -40,7 +79,12 @@ impl ProxyConfig for HttpProxyConfig { overrides: overrides?, }; let addr = macaw - .add_http_proxy(proxy_id, &self.bind, &self.target, options) + .add_http_proxy( + proxy_id, + &self.bind, + self.target.as_deref().unwrap_or_default(), + options, + ) .await?; Ok(addr) }) @@ -50,7 +94,7 @@ impl ProxyConfig for HttpProxyConfig { &'a self, proxy_id: &'b str, macaw: &'b mut Macaw, - ) -> Pin> + 'b>> { + ) -> Pin> + Send + 'b>> { let overrides = load_overrides(self.overrides.as_deref()); Box::pin(async move { let options = HttpProxyOptions { @@ -64,6 +108,25 @@ impl ProxyConfig for HttpProxyConfig { } } +fn validate_target( + target: Option<&str>, + required: bool, + allowed_schemes: &[&str], +) -> Result<(), String> { + let Some(target) = target.filter(|target| !target.is_empty()) else { + return if required { + Err("recording proxy requires a target".to_owned()) + } else { + Ok(()) + }; + }; + let target = url::Url::parse(target).map_err(|_| "invalid HTTP proxy target".to_owned())?; + if !allowed_schemes.contains(&target.scheme()) || target.host_str().is_none() { + return Err("invalid HTTP proxy target".to_owned()); + } + Ok(()) +} + fn load_overrides(path: Option<&str>) -> Result, anyhow::Error> { match path { Some(p) => { diff --git a/macaw-http/src/proxy/mod.rs b/macaw-http/src/proxy/mod.rs index 463390e..0ca3134 100644 --- a/macaw-http/src/proxy/mod.rs +++ b/macaw-http/src/proxy/mod.rs @@ -5,6 +5,7 @@ mod replayer; mod sender; mod transform; +pub use config::*; pub use options::*; pub use recorder::*; pub use replayer::*; diff --git a/macaw-wasm/Cargo.toml b/macaw-wasm/Cargo.toml index b1a6b81..e398826 100644 --- a/macaw-wasm/Cargo.toml +++ b/macaw-wasm/Cargo.toml @@ -18,6 +18,10 @@ http.workspace = true macaw-core = { path = "../macaw-core" } macaw-http = { path = "../macaw-http" } serde_json.workspace = true +serde.workspace = true +typetag.workspace = true +url.workspace = true +dyn-clonable.workspace = true uuid.workspace = true wasmtime.workspace = true wasmtime-wasi.workspace = true diff --git a/macaw-wasm/src/lib.rs b/macaw-wasm/src/lib.rs index e9ec962..24f6c21 100644 --- a/macaw-wasm/src/lib.rs +++ b/macaw-wasm/src/lib.rs @@ -1,5 +1,7 @@ pub mod http; +mod proxy_config; pub mod prelude { pub use crate::http::*; + pub use crate::proxy_config::*; } diff --git a/macaw-wasm/src/proxy_config.rs b/macaw-wasm/src/proxy_config.rs new file mode 100644 index 0000000..27f4133 --- /dev/null +++ b/macaw-wasm/src/proxy_config.rs @@ -0,0 +1,149 @@ +use std::future::Future; +use std::net::SocketAddr; +use std::path::Path; +use std::pin::Pin; + +use macaw_core::prelude::{Macaw, ProxyConfig, Recorder, Replayer}; +use macaw_http::prelude::{ + HttpOverride, HttpOverrideRules, HttpProxyOptions, MacawHttpRecorderSetup, + MacawHttpReplayerSetup, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::http::WasmHttpPlugin; + +/// HTTP proxy configuration backed by an isolated WASM component instance. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WasmHttpProxyConfig { + #[serde(default = "default_bind")] + bind: String, + #[serde(default)] + target: Option, + #[serde(default)] + overrides: Option, + component: String, + #[serde(default)] + config: Value, +} + +impl WasmHttpProxyConfig { + pub fn new( + bind: impl Into, + target: impl Into, + component: impl Into, + config: Value, + ) -> Self { + Self { + bind: bind.into(), + target: Some(target.into()), + overrides: None, + component: component.into(), + config, + } + } + + pub fn replay(bind: impl Into, component: impl Into, config: Value) -> Self { + Self { + bind: bind.into(), + target: None, + overrides: None, + component: component.into(), + config, + } + } + + pub fn with_overrides(mut self, path: impl Into) -> Self { + self.overrides = Some(path.into()); + self + } + + fn options(&self) -> Result { + let plugin = WasmHttpPlugin::from_file(&self.component, self.config.clone())?; + Ok(HttpProxyOptions { + redact: Box::new(plugin.redact()), + transform: Box::new(plugin.transform()), + overrides: load_overrides(self.overrides.as_deref())?, + }) + } +} + +fn default_bind() -> String { + "127.0.0.1:0".to_string() +} + +#[typetag::serde(name = "wasm_http")] +impl ProxyConfig for WasmHttpProxyConfig { + fn protocol(&self) -> &'static str { + "http" + } + + fn validate(&self, recording: bool) -> Result<(), String> { + let Some(target) = self.target.as_deref().filter(|target| !target.is_empty()) else { + return if recording { + Err("recording proxy requires a target".to_owned()) + } else { + Ok(()) + }; + }; + let target = url::Url::parse(target).map_err(|_| "invalid HTTP proxy target".to_owned())?; + if !matches!(target.scheme(), "http" | "https") || target.host_str().is_none() { + return Err("invalid HTTP proxy target".to_owned()); + } + Ok(()) + } + + fn bind(&self) -> &str { + &self.bind + } + + fn target(&self) -> Option<&str> { + self.target.as_deref() + } + + fn overrides(&self) -> Option<&str> { + self.overrides.as_deref() + } + + fn set_root_path(&mut self, path: &Path) { + if let Some(overrides) = &self.overrides { + self.overrides = Some(path.join(overrides).to_string_lossy().into_owned()); + } + self.component = path.join(&self.component).to_string_lossy().into_owned(); + } + + fn bind_to_recorder<'b, 'a: 'b>( + &'a self, + proxy_id: &'b str, + macaw: &'b mut Macaw, + ) -> Pin> + Send + 'b>> { + let options = self.options(); + Box::pin(async move { + macaw + .add_http_proxy( + proxy_id, + &self.bind, + self.target.as_deref().unwrap_or_default(), + options?, + ) + .await + }) + } + + fn bind_to_replayer<'b, 'a: 'b>( + &'a self, + proxy_id: &'b str, + macaw: &'b mut Macaw, + ) -> Pin> + Send + 'b>> { + let options = self.options(); + Box::pin(async move { macaw.add_http_proxy(proxy_id, &self.bind, options?).await }) + } +} + +fn load_overrides(path: Option<&str>) -> Result, anyhow::Error> { + match path { + Some(path) => Ok(Box::new(HttpOverrideRules::from_file(Path::new(path))?)), + None => Ok(Default::default()), + } +} diff --git a/macaw-ws/Cargo.toml b/macaw-ws/Cargo.toml index 7db1a6a..9960465 100644 --- a/macaw-ws/Cargo.toml +++ b/macaw-ws/Cargo.toml @@ -22,6 +22,7 @@ serde_json.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true typetag.workspace = true +url.workspace = true http.workspace = true itertools.workspace = true tracing.workspace = true diff --git a/macaw-ws/src/proxy/config.rs b/macaw-ws/src/proxy/config.rs index 66b839b..e226e01 100644 --- a/macaw-ws/src/proxy/config.rs +++ b/macaw-ws/src/proxy/config.rs @@ -2,20 +2,71 @@ use std::pin::Pin; use crate::lib::*; -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct WsProxyConfig { +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WsProxyConfig { + #[serde(default = "default_bind")] bind: String, - target: String, + #[serde(default)] + target: Option, + #[serde(default)] overrides: Option, } +impl WsProxyConfig { + pub fn new(bind: impl Into, target: impl Into) -> Self { + Self { + bind: bind.into(), + target: Some(target.into()), + overrides: None, + } + } + + pub fn replay(bind: impl Into) -> Self { + Self { + bind: bind.into(), + target: None, + overrides: None, + } + } + + pub fn with_overrides(mut self, path: impl Into) -> Self { + self.overrides = Some(path.into()); + self + } +} + +fn default_bind() -> String { + "127.0.0.1:0".to_string() +} + #[typetag::serde(name = "ws")] impl ProxyConfig for WsProxyConfig { + fn protocol(&self) -> &'static str { + "ws" + } + + fn validate(&self, recording: bool) -> Result<(), String> { + let Some(target) = self.target.as_deref().filter(|target| !target.is_empty()) else { + return if recording { + Err("recording proxy requires a target".to_owned()) + } else { + Ok(()) + }; + }; + let target = + url::Url::parse(target).map_err(|_| "invalid WebSocket proxy target".to_owned())?; + if !matches!(target.scheme(), "ws" | "wss") || target.host_str().is_none() { + return Err("invalid WebSocket proxy target".to_owned()); + } + Ok(()) + } + fn bind(&self) -> &str { &self.bind } - fn target(&self) -> &str { - &self.target + fn target(&self) -> Option<&str> { + self.target.as_deref() } fn overrides(&self) -> Option<&str> { self.overrides.as_deref() @@ -31,7 +82,7 @@ impl ProxyConfig for WsProxyConfig { &'a self, proxy_id: &'b str, macaw: &'b mut Macaw, - ) -> Pin> + 'b>> { + ) -> Pin> + Send + 'b>> { let overrides = load_overrides(self.overrides.as_deref()); Box::pin(async move { let options = WsProxyOptions { @@ -40,7 +91,12 @@ impl ProxyConfig for WsProxyConfig { overrides: overrides?, }; let addr = macaw - .add_ws_proxy(proxy_id, &self.bind, &self.target, options) + .add_ws_proxy( + proxy_id, + &self.bind, + self.target.as_deref().unwrap_or_default(), + options, + ) .await?; Ok(addr) }) @@ -50,7 +106,7 @@ impl ProxyConfig for WsProxyConfig { &'a self, proxy_id: &'b str, macaw: &'b mut Macaw, - ) -> Pin> + 'b>> { + ) -> Pin> + Send + 'b>> { let overrides = load_overrides(self.overrides.as_deref()); Box::pin(async move { let options = WsProxyOptions { diff --git a/macaw-ws/src/proxy/mod.rs b/macaw-ws/src/proxy/mod.rs index 344f7d1..ca720cc 100644 --- a/macaw-ws/src/proxy/mod.rs +++ b/macaw-ws/src/proxy/mod.rs @@ -5,6 +5,7 @@ mod replayer; mod sender; mod transform; +pub use config::*; pub use options::*; pub use recorder::*; pub use replayer::*; diff --git a/src/client/mod.rs b/src/client/mod.rs new file mode 100644 index 0000000..5bab46f --- /dev/null +++ b/src/client/mod.rs @@ -0,0 +1,1094 @@ +mod model; +mod transport; + +use anyhow::{Context, Result, bail}; +use clap::{Args, Subcommand, ValueEnum}; +use colored::Colorize; +use futures::StreamExt; +use macaw::core::{DebugDirection, RecordPart, RecordedEvent}; +use macaw::session::SessionState; +use model::{HealthResponse, ProfileResponse, SessionResponse, TrafficStreamEvent}; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use std::hash::{Hash, Hasher}; +use std::io::{IsTerminal, Read, Write}; +use std::path::PathBuf; +use std::time::Duration; +use transport::ControlClient; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +const DEFAULT_CONTROL_URL: &str = "http://127.0.0.1:8080"; + +#[derive(Debug, Args)] +pub struct ClientArgs { + /// HTTP control server URL + #[arg(long, value_name = "URL", conflicts_with = "unix")] + url: Option, + + /// Connect through a Unix domain socket + #[arg(long, value_name = "PATH", conflicts_with = "url")] + unix: Option, + + /// Request timeout in seconds + #[arg(long, default_value_t = 30)] + timeout: u64, + + /// Output format + #[arg(short, long, value_enum, default_value_t = OutputFormat::Human)] + output: OutputFormat, + + /// Disable terminal colors + #[arg(long)] + no_color: bool, + + #[command(subcommand)] + command: ClientCommand, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum OutputFormat { + Human, + Jsonl, +} + +#[derive(Debug, Subcommand)] +enum ClientCommand { + /// Check control server readiness + Health, + /// Manage reusable proxy profiles + Profile { + #[command(subcommand)] + command: ProfileCommand, + }, + /// Manage detached sessions + Session { + #[command(subcommand)] + command: SessionCommand, + }, + /// Stream traffic from a running session + Watch(WatchArgs), + /// Create and supervise a foreground session + Run { + #[command(subcommand)] + command: RunCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum ProfileCommand { + /// Create a profile from a TOML configuration + Create { + profile: String, + #[arg(long, value_name = "PATH")] + file: String, + #[arg(long, value_name = "PATH", default_value = ".")] + root: PathBuf, + }, + /// List profiles + List, + /// Get a profile + Get { profile: String }, + /// Delete a profile + Delete { profile: String }, +} + +#[derive(Debug, Subcommand)] +enum SessionCommand { + /// Start a detached recording session + Record { + profile: String, + #[arg(long)] + name: Option, + #[arg(long, value_name = "PATH")] + output: PathBuf, + }, + /// Start a detached replay session + Replay { + profile: String, + #[arg(long)] + name: Option, + #[arg(long, value_name = "PATH")] + recording: PathBuf, + }, + /// List sessions + List { + #[arg(long)] + profile: Option, + #[arg(long, value_enum)] + state: Option, + }, + /// Get a session + Get { + #[arg(value_name = "SESSION-ID|SESSION-NAME")] + session: String, + }, + /// Stop a session and wait for completion + Stop { + #[arg(value_name = "SESSION-ID|SESSION-NAME")] + session: String, + }, + /// Delete a ready or terminal session + Delete { + #[arg(value_name = "SESSION-ID|SESSION-NAME")] + session: String, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum StateFilter { + Starting, + Ready, + Running, + Stopping, + Stopped, + Failed, +} + +impl From for SessionState { + fn from(value: StateFilter) -> Self { + match value { + StateFilter::Starting => Self::Starting, + StateFilter::Ready => Self::Ready, + StateFilter::Running => Self::Running, + StateFilter::Stopping => Self::Stopping, + StateFilter::Stopped => Self::Stopped, + StateFilter::Failed => Self::Failed, + } + } +} + +#[derive(Debug, Args)] +struct WatchArgs { + #[arg(value_name = "SESSION-ID|SESSION-NAME")] + session: String, + #[command(flatten)] + options: WatchOptions, +} + +#[derive(Debug, Clone, Args)] +struct WatchOptions { + /// Include only these proxies + #[arg(long, value_name = "NAME")] + proxy: Vec, + /// Include one traffic direction + #[arg(long, value_enum, default_value_t = DirectionFilter::Both)] + direction: DirectionFilter, + /// Include only these protocols + #[arg(long, value_name = "PROTOCOL")] + protocol: Vec, + /// Body display policy + #[arg(long, value_enum, default_value_t = BodyDisplay::Preview)] + body: BodyDisplay, + /// Display headers exposed by the recorded event + #[arg(long)] + headers: bool, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum DirectionFilter { + Request, + Response, + Both, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum BodyDisplay { + None, + Preview, +} + +#[derive(Debug, Subcommand)] +enum RunCommand { + /// Record until interrupted + Record { + profile: String, + #[arg(long)] + name: Option, + #[arg(long, value_name = "PATH")] + output: PathBuf, + #[command(flatten)] + options: RunOptions, + }, + /// Replay under foreground supervision + Replay { + profile: String, + #[arg(long)] + name: Option, + #[arg(long, value_name = "PATH")] + recording: PathBuf, + #[command(flatten)] + options: RunOptions, + }, +} + +#[derive(Debug, Args)] +struct RunOptions { + /// Do not display live traffic + #[arg(long)] + no_watch: bool, + /// Leave the session running when interrupted + #[arg(long)] + keep_running: bool, + #[command(flatten)] + watch: WatchOptions, +} + +pub async fn run(args: ClientArgs) -> Result<()> { + let explicit_url = args.url.is_some(); + let unix = args.unix.or_else(|| { + (!explicit_url) + .then(|| std::env::var_os("MACAW_CONTROL_UNIX")) + .flatten() + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + }); + let url = args + .url + .or_else(|| std::env::var("MACAW_CONTROL_URL").ok()) + .unwrap_or_else(|| DEFAULT_CONTROL_URL.to_owned()); + let client = ControlClient::new(&url, unix.as_deref(), Duration::from_secs(args.timeout))?; + let use_color = + !args.no_color && std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal(); + + match args.command { + ClientCommand::Health => { + let health = client.get::("/v1/health").await?; + match args.output { + OutputFormat::Human => println!( + "Macaw {} (API {}) — {}", + health.package_version, + health.api_version, + if health.ready { "ready" } else { "not ready" } + ), + format => print_serialized(&health, format)?, + } + } + ClientCommand::Profile { command } => run_profile(&client, command, args.output).await?, + ClientCommand::Session { command } => run_session(&client, command, args.output).await?, + ClientCommand::Watch(watch) => { + let session = client + .get::(&format!("/v1/sessions/{}", watch.session)) + .await?; + let proxy_width = if session.proxies.is_empty() { + let profile = client + .get::(&format!("/v1/profiles/{}", session.profile_id)) + .await?; + profile + .proxies + .keys() + .map(|name| UnicodeWidthStr::width(name.as_str())) + .max() + .unwrap_or(0) + .min(12) + } else { + proxy_width(&session) + }; + let final_session = tokio::select! { + result = watch_until_closed( + &client, + &watch.session, + &watch.options, + args.output, + use_color, + proxy_width, + None, + ) => Some(result?), + result = tokio::signal::ctrl_c() => { + result?; + None + }, + }; + if let Some(session) = final_session { + print_session(&session, args.output)?; + } + } + ClientCommand::Run { command } => { + run_foreground(&client, command, args.output, use_color).await? + } + } + Ok(()) +} + +async fn run_profile( + client: &ControlClient, + command: ProfileCommand, + output: OutputFormat, +) -> Result<()> { + match command { + ProfileCommand::Create { + profile, + file, + root, + } => { + let body = profile_request(&profile, &file, root)?; + let profile = client + .post::<_, ProfileResponse>("/v1/profiles", Some(&body)) + .await?; + print_profile(&profile, output)?; + } + ProfileCommand::List => { + let profiles = client.get::>("/v1/profiles").await?; + match output { + OutputFormat::Human => { + for profile in profiles { + println!("{}", profile.id); + } + } + OutputFormat::Jsonl => { + for profile in profiles { + print_serialized(&profile, output)?; + } + } + } + } + ProfileCommand::Get { profile } => { + let profile = client + .get::(&format!("/v1/profiles/{profile}")) + .await?; + print_profile(&profile, output)?; + } + ProfileCommand::Delete { profile } => { + client.delete(&format!("/v1/profiles/{profile}")).await?; + if matches!(output, OutputFormat::Human) { + println!("Profile {profile} deleted"); + } + } + } + Ok(()) +} + +async fn run_session( + client: &ControlClient, + command: SessionCommand, + output: OutputFormat, +) -> Result<()> { + match command { + SessionCommand::Record { + profile, + name, + output: path, + } => { + let body = json!({"name": name, "mode": {"type": "record", "output": path}}); + let session = create_session(client, &profile, &body).await?; + let session = start_session(client, &session.id.to_string()).await?; + print_session(&session, output)?; + } + SessionCommand::Replay { + profile, + name, + recording, + } => { + let body = json!({"name": name, "mode": {"type": "replay", "recording": recording}}); + let session = create_session(client, &profile, &body).await?; + let session = start_session(client, &session.id.to_string()).await?; + print_session(&session, output)?; + } + SessionCommand::List { profile, state } => { + let path = profile + .map(|profile| format!("/v1/profiles/{profile}/sessions")) + .unwrap_or_else(|| "/v1/sessions".to_owned()); + let mut sessions = client.get::>(&path).await?; + if let Some(state) = state { + let state = SessionState::from(state); + sessions.retain(|session| session.state == state); + } + match output { + OutputFormat::Human => { + for session in sessions { + println!( + "{} {:<20} {:<8} {}", + session.id, + session + .name + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "-".to_owned()), + state_name(session.state), + session.profile_id + ); + } + } + OutputFormat::Jsonl => { + for session in sessions { + print_serialized(&session, output)?; + } + } + } + } + SessionCommand::Get { session } => { + let session = client + .get::(&format!("/v1/sessions/{session}")) + .await?; + print_session(&session, output)?; + } + SessionCommand::Stop { session } => { + let session = client + .post::<(), SessionResponse>(&format!("/v1/sessions/{session}/stop"), None) + .await?; + print_session(&session, output)?; + } + SessionCommand::Delete { session } => { + client.delete(&format!("/v1/sessions/{session}")).await?; + if matches!(output, OutputFormat::Human) { + println!("Session {session} deleted"); + } + } + } + Ok(()) +} + +async fn run_foreground( + client: &ControlClient, + command: RunCommand, + output: OutputFormat, + use_color: bool, +) -> Result<()> { + let (profile, body, options) = match command { + RunCommand::Record { + profile, + name, + output, + options, + } => ( + profile, + json!({"name": name, "mode": {"type": "record", "output": output}}), + options, + ), + RunCommand::Replay { + profile, + name, + recording, + options, + } => ( + profile, + json!({"name": name, "mode": {"type": "replay", "recording": recording}}), + options, + ), + }; + let session = create_session(client, &profile, &body).await?; + let id = session.id.to_string(); + let response = if options.no_watch { + None + } else { + match client.stream(&format!("/v1/sessions/{id}/events")).await { + Ok(response) => Some(response), + Err(error) => { + let _ = client + .post::<(), SessionResponse>(&format!("/v1/sessions/{id}/stop"), None) + .await; + return Err(error); + } + } + }; + let session = start_session(client, &id).await?; + print_session(&session, output)?; + let proxy_width = proxy_width(&session); + + let observed_final = if options.no_watch { + tokio::signal::ctrl_c().await?; + None + } else { + tokio::select! { + result = watch_until_closed( + client, + &id, + &options.watch, + output, + use_color, + proxy_width, + response, + ) => { + Some(result?) + }, + result = tokio::signal::ctrl_c() => { + result?; + None + }, + } + }; + + if options.keep_running { + if let Some(session) = observed_final { + print_session(&session, output)?; + } else { + eprintln!("Session {id} left running"); + } + return Ok(()); + } + let final_session = match observed_final { + Some(session) => session, + None => { + client + .post::<(), SessionResponse>(&format!("/v1/sessions/{id}/stop"), None) + .await? + } + }; + print_session(&final_session, output) +} + +async fn start_session(client: &ControlClient, id: &str) -> Result { + let session = client + .post::<(), SessionResponse>(&format!("/v1/sessions/{id}/start"), None) + .await?; + if session.state == SessionState::Failed { + if let Some(error) = &session.error { + bail!("{:?}: {}", error.code, error.message); + } + bail!("session failed to start"); + } + Ok(session) +} + +async fn create_session( + client: &ControlClient, + profile: &str, + body: &Value, +) -> Result { + client + .post(&format!("/v1/profiles/{profile}/sessions"), Some(body)) + .await +} + +fn profile_request(profile: &str, file: &str, root: PathBuf) -> Result { + let content = if file == "-" { + let mut content = String::new(); + std::io::stdin().read_to_string(&mut content)?; + content + } else { + std::fs::read_to_string(file) + .with_context(|| format!("failed to read profile configuration {file}"))? + }; + let value: toml::Value = toml::from_str(&content) + .with_context(|| format!("failed to parse profile configuration {file}"))?; + let proxies = value + .get("proxies") + .and_then(toml::Value::as_table) + .context("profile configuration must contain a [proxies] table")?; + let mut wire_proxies = Map::new(); + for (name, proxy) in proxies { + let mut config = serde_json::to_value(proxy)? + .as_object() + .cloned() + .context("each proxy must be a TOML table")?; + let implementation = config + .remove("type") + .and_then(|value| value.as_str().map(str::to_owned)) + .with_context(|| format!("proxy {name} must define a string type"))?; + wire_proxies.insert( + name.clone(), + json!({"type": implementation, "config": config}), + ); + } + Ok(json!({ + "id": profile, + "config_root": root, + "proxies": wire_proxies, + })) +} + +async fn watch_until_closed( + client: &ControlClient, + session: &str, + options: &WatchOptions, + output: OutputFormat, + use_color: bool, + proxy_width: usize, + initial_response: Option, +) -> Result { + let mut after = None; + let mut response = initial_response; + loop { + let response = match response.take() { + Some(response) => response, + None => { + let cursor = after + .map(|sequence| format!("?after={sequence}")) + .unwrap_or_default(); + match client + .stream(&format!("/v1/sessions/{session}/events{cursor}")) + .await + { + Ok(response) => response, + Err(error) => { + let current = client + .get::(&format!("/v1/sessions/{session}")) + .await?; + if current.state.is_terminal() { + return Ok(current); + } + tracing::debug!("traffic stream reconnect failed: {error}"); + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + } + } + }; + consume_traffic_stream( + response, + options, + output, + use_color, + proxy_width, + &mut after, + ) + .await?; + + let current = client + .get::(&format!("/v1/sessions/{session}")) + .await?; + if current.state.is_terminal() { + return Ok(current); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn consume_traffic_stream( + response: reqwest::Response, + options: &WatchOptions, + output: OutputFormat, + use_color: bool, + proxy_width: usize, + after: &mut Option, +) -> Result<()> { + let mut chunks = response.bytes_stream(); + let mut buffer = Vec::new(); + while let Some(chunk) = chunks.next().await { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(error) => { + tracing::debug!("traffic stream disconnected: {error}"); + return Ok(()); + } + }; + buffer.extend_from_slice(&chunk); + while let Some((boundary, delimiter_len)) = sse_frame_boundary(&buffer) { + let frame = buffer.drain(..boundary).collect::>(); + buffer.drain(..delimiter_len); + let frame = + std::str::from_utf8(&frame).context("traffic stream was not valid UTF-8")?; + let data = frame + .lines() + .map(|line| line.strip_suffix('\r').unwrap_or(line)) + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim_start) + .collect::>() + .join("\n"); + if data.is_empty() { + continue; + } + let event: TrafficStreamEvent = + serde_json::from_str(&data).context("invalid traffic stream event")?; + print_traffic_event(event, options, output, use_color, proxy_width, after)?; + } + } + Ok(()) +} + +fn sse_frame_boundary(buffer: &[u8]) -> Option<(usize, usize)> { + buffer + .windows(2) + .position(|window| window == b"\n\n") + .map(|position| (position, 2)) + .or_else(|| { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| (position, 4)) + }) +} + +fn print_traffic_event( + event: TrafficStreamEvent, + options: &WatchOptions, + output: OutputFormat, + use_color: bool, + proxy_width: usize, + after: &mut Option, +) -> Result<()> { + match event { + TrafficStreamEvent::DroppedEvents { count } => { + if matches!(output, OutputFormat::Jsonl) { + println!( + "{}", + serde_json::to_string(&json!({ + "type": "dropped_events", + "count": count, + }))? + ); + } else { + eprintln!("warning: {count} traffic events were dropped"); + } + } + TrafficStreamEvent::Traffic { sequence, event } => { + *after = Some(sequence); + if !traffic_matches(&event, options) { + return Ok(()); + } + if matches!(output, OutputFormat::Jsonl) { + println!( + "{}", + serde_json::to_string(&json!({ + "type": "traffic", + "sequence": sequence, + "event": event, + }))? + ); + } else { + print_pretty_traffic( + &event, + options.body, + options.headers, + use_color, + proxy_width, + ); + } + } + } + std::io::stdout().flush()?; + Ok(()) +} + +fn traffic_matches(event: &RecordedEvent, options: &WatchOptions) -> bool { + let proxy_matches = options.proxy.is_empty() + || options + .proxy + .iter() + .any(|proxy| proxy == event.proxy_id.as_str()); + let formatter = event.event.format_debug(); + let direction_matches = match options.direction { + DirectionFilter::Both => true, + DirectionFilter::Request => formatter.direction() == DebugDirection::DownstreamToUpstream, + DirectionFilter::Response => formatter.direction() == DebugDirection::UpstreamToDownstream, + }; + let protocol_matches = options.protocol.is_empty() + || formatter.parts().iter().any(|part| { + matches!( + part, + RecordPart::StreamType(protocol) + if options + .protocol + .iter() + .any(|filter| filter.eq_ignore_ascii_case(protocol)) + ) + }); + proxy_matches && direction_matches && protocol_matches +} + +const PROXY_COLORS: [colored::Color; 6] = [ + colored::Color::Red, + colored::Color::Green, + colored::Color::Yellow, + colored::Color::Blue, + colored::Color::Magenta, + colored::Color::Cyan, +]; + +fn proxy_width(session: &SessionResponse) -> usize { + session + .proxies + .keys() + .map(|name| UnicodeWidthStr::width(name.as_str())) + .max() + .unwrap_or(0) + .min(12) +} + +fn proxy_color(proxy_id: &str) -> colored::Color { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + proxy_id.hash(&mut hasher); + PROXY_COLORS[hasher.finish() as usize % PROXY_COLORS.len()] +} + +fn truncate_text(text: &str, width: usize) -> String { + if UnicodeWidthStr::width(text) <= width { + return text.to_owned(); + } + if width <= 3 { + return ".".repeat(width); + } + let mut used = 0; + let content_width = width - 3; + let mut output = String::new(); + for character in text.chars() { + let character_width = UnicodeWidthChar::width(character).unwrap_or(0); + if used + character_width > content_width { + break; + } + used += character_width; + output.push(character); + } + output.push_str("..."); + output +} + +fn format_proxy_id(proxy_id: &str, width: usize) -> String { + let proxy_id = truncate_text(proxy_id, width); + let padding = width.saturating_sub(UnicodeWidthStr::width(proxy_id.as_str())); + format!("[{proxy_id}{}]", " ".repeat(padding)) +} + +fn shorten_ids(parts: &[RecordPart]) -> Vec { + parts + .iter() + .map(|part| match part { + RecordPart::Id(id) => part.replace(&id.chars().take(8).collect::()), + _ => part.clone(), + }) + .collect() +} + +fn truncate_parts(parts: &[RecordPart], max_width: usize) -> Vec { + let parts = shorten_ids(parts); + let mut used = 0; + let mut output = Vec::new(); + for part in parts { + let separator = usize::from(!output.is_empty()); + let available = max_width.saturating_sub(used + separator); + if available == 0 { + break; + } + let text = part.to_string(); + let width = UnicodeWidthStr::width(text.as_str()); + if width > available { + output.push(part.replace(&truncate_text(&text, available))); + break; + } + used += separator + width; + output.push(part); + } + output +} + +fn style_parts(parts: &[RecordPart]) -> String { + parts + .iter() + .map(|part| match part { + RecordPart::StreamType(value) => value.cyan().to_string(), + RecordPart::Id(value) => value.yellow().to_string(), + RecordPart::Meta(value) => value.green().to_string(), + RecordPart::Content(value) => value.dimmed().to_string(), + }) + .collect::>() + .join(" ") +} + +fn style_arrow(arrow: &str) -> String { + match arrow { + "→" => arrow.blue().to_string(), + "←" => arrow.green().to_string(), + _ => arrow.dimmed().to_string(), + } +} + +fn print_pretty_traffic( + event: &RecordedEvent, + body: BodyDisplay, + headers: bool, + use_color: bool, + proxy_width: usize, +) { + let formatter = event.event.format_debug(); + let parts = formatter + .parts() + .iter() + .filter(|part| { + !matches!(body, BodyDisplay::None) || !matches!(part, RecordPart::Content(_)) + }) + .cloned() + .collect::>(); + let arrow = formatter.direction().arrow(); + let proxy = format_proxy_id(event.proxy_id.as_str(), proxy_width); + let terminal_width = std::io::stdout() + .is_terminal() + .then(terminal_size::terminal_size) + .flatten() + .map(|(width, _)| width.0 as usize); + let prefix_width = + UnicodeWidthStr::width(arrow) + 1 + UnicodeWidthStr::width(proxy.as_str()) + 1; + let parts = terminal_width + .map(|width| truncate_parts(&parts, width.saturating_sub(prefix_width))) + .unwrap_or_else(|| shorten_ids(&parts)); + if use_color { + println!( + "{} {} {}", + style_arrow(arrow), + proxy.color(proxy_color(event.proxy_id.as_str())), + style_parts(&parts) + ); + } else { + println!( + "{} {} {}", + arrow, + proxy, + parts + .iter() + .map(ToString::to_string) + .collect::>() + .join(" ") + ); + } + if headers { + for (name, value) in event.event.debug_headers() { + println!(" {name}: {value}"); + } + } +} + +fn print_profile(profile: &ProfileResponse, output: OutputFormat) -> Result<()> { + match output { + OutputFormat::Human => { + println!("Profile {}", profile.id); + println!("Root: {}", profile.config_root.display()); + for (name, proxy) in &profile.proxies { + println!( + "{name:<12} {} {}{}{}", + proxy.protocol, + proxy.bind, + proxy + .target + .as_deref() + .map(|target| format!(" → {target}")) + .unwrap_or_default(), + proxy + .overrides + .as_deref() + .map(|path| format!(" (overrides: {path})")) + .unwrap_or_default(), + ); + } + } + format => print_serialized(profile, format)?, + } + Ok(()) +} + +fn print_session(session: &SessionResponse, output: OutputFormat) -> Result<()> { + match output { + OutputFormat::Human => { + println!( + "Session {}{} {}", + session.id, + session + .name + .as_ref() + .map(|name| format!(" ({name})")) + .unwrap_or_default(), + state_name(session.state) + ); + println!("Profile: {}", session.profile_id); + for (name, endpoint) in &session.proxies { + println!("{name:<12} {}", endpoint.url); + } + if let Some(outcome) = &session.outcome { + println!("Outcome: {}", serde_json::to_string(outcome)?); + } + if let Some(error) = &session.error { + println!("Error: {:?}: {}", error.code, error.message); + } + } + format => print_serialized(session, format)?, + } + Ok(()) +} + +fn print_serialized(value: &impl Serialize, output: OutputFormat) -> Result<()> { + match output { + OutputFormat::Human => { + println!("{}", serde_json::to_string_pretty(value)?) + } + OutputFormat::Jsonl => println!("{}", serde_json::to_string(value)?), + } + Ok(()) +} + +fn state_name(state: SessionState) -> &'static str { + match state { + SessionState::Starting => "starting", + SessionState::Ready => "ready", + SessionState::Running => "running", + SessionState::Stopping => "stopping", + SessionState::Stopped => "stopped", + SessionState::Failed => "failed", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_toml_is_converted_to_the_wire_envelope() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("profile.toml"); + std::fs::write( + &path, + r#" + [proxies.api] + type = "http" + bind = "127.0.0.1:0" + target = "https://example.com" + "#, + ) + .unwrap(); + + let request = profile_request( + "development", + path.to_str().unwrap(), + PathBuf::from("/srv/app"), + ) + .unwrap(); + assert_eq!(request["id"], "development"); + assert_eq!(request["config_root"], "/srv/app"); + assert_eq!(request["proxies"]["api"]["type"], "http"); + assert_eq!( + request["proxies"]["api"]["config"]["target"], + "https://example.com" + ); + assert!(request["proxies"]["api"]["config"].get("type").is_none()); + } + + #[cfg(feature = "http")] + #[test] + fn traffic_filters_are_combined() { + let event = RecordedEvent::new( + "api".parse().unwrap(), + macaw::http::HttpRequestEvent { + request_id: uuid::Uuid::new_v4(), + method: hyper::Method::GET, + uri: "/test".parse().unwrap(), + version: hyper::Version::HTTP_11, + headers: Default::default(), + body: macaw::core::Content::Empty, + }, + ); + let options = WatchOptions { + proxy: vec!["api".to_owned()], + direction: DirectionFilter::Request, + protocol: vec!["http".to_owned()], + body: BodyDisplay::None, + headers: false, + }; + assert!(traffic_matches(&event, &options)); + + let mut mismatch = options.clone(); + mismatch.direction = DirectionFilter::Response; + assert!(!traffic_matches(&event, &mismatch)); + } + + #[test] + fn terminal_truncation_uses_display_width() { + assert_eq!(truncate_text("ab界cd", 6), "ab界cd"); + assert_eq!(truncate_text("ab界cd", 5), "ab..."); + assert_eq!( + UnicodeWidthStr::width(truncate_text("ab界cd", 5).as_str()), + 5 + ); + } + + #[test] + fn sse_frames_support_lf_and_crlf_boundaries() { + assert_eq!(sse_frame_boundary(b"data: one\n\ndata:"), Some((9, 2))); + assert_eq!(sse_frame_boundary(b"data: two\r\n\r\ndata:"), Some((9, 4))); + } +} diff --git a/src/client/model.rs b/src/client/model.rs new file mode 100644 index 0000000..53321ab --- /dev/null +++ b/src/client/model.rs @@ -0,0 +1,58 @@ +use macaw::core::RecordedEvent; +use macaw::session::{ + ProfileId, ProfileProxySnapshot, SessionEndpoint, SessionErrorCode, SessionId, SessionName, + SessionState, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; + +#[derive(Debug, Deserialize, Serialize)] +pub struct HealthResponse { + pub api_version: String, + pub package_version: String, + pub ready: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProfileResponse { + pub id: ProfileId, + pub config_root: std::path::PathBuf, + pub proxies: BTreeMap, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SessionResponse { + pub id: SessionId, + pub name: Option, + pub profile_id: ProfileId, + pub mode: Value, + pub state: SessionState, + pub proxies: BTreeMap, + pub outcome: Option, + pub error: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ErrorResponse { + pub error: ErrorDetail, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ErrorDetail { + pub code: SessionErrorCode, + pub message: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TrafficStreamEvent { + Traffic { + sequence: u64, + #[serde(flatten)] + event: RecordedEvent, + }, + DroppedEvents { + count: u64, + }, +} diff --git a/src/client/transport.rs b/src/client/transport.rs new file mode 100644 index 0000000..6458a40 --- /dev/null +++ b/src/client/transport.rs @@ -0,0 +1,146 @@ +use super::model::ErrorResponse; +use anyhow::{Context, Result, bail}; +use reqwest::{Method, Response}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::path::Path; +use std::time::Duration; + +#[derive(Clone)] +pub struct ControlClient { + client: reqwest::Client, + stream_client: reqwest::Client, + base_url: String, +} + +impl ControlClient { + pub fn new(url: &str, unix: Option<&Path>, timeout: Duration) -> Result { + let mut builder = reqwest::Client::builder().timeout(timeout); + let mut stream_builder = reqwest::Client::builder(); + #[cfg(unix)] + if let Some(path) = unix { + builder = builder.unix_socket(path); + stream_builder = stream_builder.unix_socket(path); + } + #[cfg(not(unix))] + if unix.is_some() { + bail!("Unix control sockets are not supported on this platform"); + } + let base_url = if unix.is_some() { + "http://localhost".to_owned() + } else { + url.trim_end_matches('/').to_owned() + }; + Ok(Self { + client: builder.build().context("failed to create HTTP client")?, + stream_client: stream_builder + .build() + .context("failed to create streaming HTTP client")?, + base_url, + }) + } + + pub async fn get(&self, path: &str) -> Result { + self.send_json::<(), T>(Method::GET, path, None).await + } + + pub async fn post( + &self, + path: &str, + body: Option<&B>, + ) -> Result { + self.send_json(Method::POST, path, body).await + } + + pub async fn delete(&self, path: &str) -> Result<()> { + let response = self.request(Method::DELETE, path).send().await?; + checked(response).await?; + Ok(()) + } + + pub async fn stream(&self, path: &str) -> Result { + checked( + self.stream_client + .get(format!("{}{}", self.base_url, path)) + .send() + .await?, + ) + .await + } + + async fn send_json( + &self, + method: Method, + path: &str, + body: Option<&B>, + ) -> Result { + let mut request = self.request(method, path); + if let Some(body) = body { + request = request.json(body); + } + checked(request.send().await?) + .await? + .json() + .await + .context("control server returned invalid JSON") + } + + fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder { + self.client + .request(method, format!("{}{}", self.base_url, path)) + } +} + +async fn checked(response: Response) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + if let Ok(error) = serde_json::from_str::(&body) { + bail!( + "control server returned {status}: {:?}: {}", + error.error.code, + error.error.message + ); + } + bail!("control server returned {status}") +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{Json, Router, routing::get}; + use serde_json::{Value, json}; + + fn app() -> Router { + Router::new().route("/v1/health", get(|| async { Json(json!({"ready": true})) })) + } + + #[tokio::test] + async fn connects_over_tcp() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app()).await }); + let client = + ControlClient::new(&format!("http://{address}"), None, Duration::from_secs(2)).unwrap(); + + let response = client.get::("/v1/health").await.unwrap(); + assert_eq!(response["ready"], true); + server.abort(); + } + + #[cfg(unix)] + #[tokio::test] + async fn connects_over_a_unix_socket() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("control.sock"); + let listener = tokio::net::UnixListener::bind(&path).unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app()).await }); + let client = ControlClient::new("", Some(&path), Duration::from_secs(2)).unwrap(); + + let response = client.get::("/v1/health").await.unwrap(); + assert_eq!(response["ready"], true); + server.abort(); + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 2eea7c7..0000000 --- a/src/config.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! TOML configuration for macaw proxies. - -use anyhow::{Context, Result}; -use macaw::core::*; -use serde::Deserialize; -use std::collections::BTreeMap; -use std::path::Path; - -/// Root configuration structure. -#[derive(Debug, Deserialize)] -pub struct Config { - pub proxies: ProxyMap, -} - -#[derive(Debug, Deserialize)] -pub struct ProxyMap(BTreeMap>); - -impl ProxyMap { - pub fn max_name_length(&self) -> usize { - self.0.keys().map(|k| k.len()).max().unwrap_or(0) - } - - pub fn iter(&self) -> impl Iterator)> { - self.0.iter() - } - - fn set_root_path(&mut self, path: &Path) { - for proxy in self.0.values_mut() { - proxy.set_root_path(path); - } - } -} - -impl Config { - /// Load configuration from a TOML file. - pub fn from_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read config file: {}", path.display()))?; - let mut config: Config = toml::from_str(&content) - .with_context(|| format!("Failed to parse config: {}", path.display()))?; - let root_path = path.parent().unwrap_or_else(|| Path::new("")); - config.proxies.set_root_path(root_path); - Ok(config) - } -} diff --git a/src/debug.rs b/src/debug.rs deleted file mode 100644 index c5a2b89..0000000 --- a/src/debug.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Debug formatter for recorded events. - -use colored::Colorize; -use macaw::core::*; -use std::collections::HashMap; -use std::hash::{Hash, Hasher}; -use std::io::IsTerminal; -use std::net::SocketAddr; -use tokio::sync::mpsc; - -use crate::config::ProxyMap; - -const PROXY_COLORS: [colored::Color; 6] = [ - colored::Color::Red, - colored::Color::Green, - colored::Color::Yellow, - colored::Color::Blue, - colored::Color::Magenta, - colored::Color::Cyan, -]; - -fn proxy_color(proxy_id: &str) -> colored::Color { - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - proxy_id.hash(&mut hasher); - let idx = hasher.finish() as usize % PROXY_COLORS.len(); - PROXY_COLORS[idx] -} - -fn format_proxy_id(proxy_id: &str, width: usize) -> String { - let len = proxy_id.chars().count(); - if len > width { - format!( - "[{}...]", - proxy_id - .chars() - .take(width.saturating_sub(3)) - .collect::() - ) - } else { - format!("[{:<1$}]", proxy_id, width) - } -} - -fn style_part(part: &RecordPart, use_color: bool) -> String { - if !use_color { - return part.to_string(); - } - match part { - RecordPart::StreamType(s) => s.cyan().to_string(), - RecordPart::Id(s) => s.yellow().to_string(), - RecordPart::Meta(s) => s.green().to_string(), - RecordPart::Content(s) => s.dimmed().to_string(), - } -} - -fn truncate_parts(parts: &[RecordPart], max_width: usize) -> Vec { - let mut used_width = 0; - let mut truncated_parts = Vec::new(); - for part in parts { - let mut part = part.clone(); - if matches!(part, RecordPart::Id(..)) { - let part_truncated = part.to_string().chars().take(8).collect::(); - part = part.replace(&part_truncated); - } - if used_width + part.len() > max_width { - let part_len = (max_width - used_width).saturating_sub(3); - let part_truncated = part.to_string().chars().take(part_len).collect::(); - truncated_parts.push(part.replace(&format!("{}...", part_truncated))); - break; - } else { - used_width += part.len(); - truncated_parts.push(part); - } - } - truncated_parts -} - -fn style_parts(parts: &[RecordPart], use_color: bool) -> String { - parts - .iter() - .map(|part| style_part(part, use_color)) - .collect::>() - .join(" ") -} - -fn style_arraw(arrow: &str) -> String { - match arrow { - "→" => "→".blue().to_string(), - "←" => "←".green().to_string(), - _ => arrow.dimmed().to_string(), - } -} - -/// Print configuration summary for record mode. -pub fn print_record_summary(proxies: &ProxyMap, bindings: &HashMap) { - let proxy_width = proxies.max_name_length().min(12); - let use_color = std::io::stderr().is_terminal(); - - if use_color { - println!("{}", "Recording mode".blue()); - println!(); - println!("{}", "Proxies:".dimmed()); - } else { - println!("Recording mode"); - println!(); - println!("Proxies:"); - } - for (proxy_id, config) in proxies.iter() { - let proxy_display = format_proxy_id(proxy_id.as_str(), proxy_width); - let target_str = config.target(); - let bind_str = bindings - .get(proxy_id) - .map(|s| s.to_string()) - .unwrap_or(config.bind().to_string()); - if use_color { - println!( - "{:<14} {:<12} → {}", - proxy_display.color(proxy_color(proxy_id)), - bind_str.bold(), - target_str.bold() - ); - } else { - println!( - "{:<14} {:<12} → {}", - proxy_display, - config.bind(), - target_str - ); - } - } - println!(); -} - -/// Print record outcome on exit -pub fn print_record_outcome(outcome: RecorderOutcome) { - if outcome.total_bytes.is_some() { - println!( - "\nRecording saved to: {}", - outcome.recording_path.display().to_string().green() - ); - } - println!( - "- Total events: {}", - outcome.total_events.to_string().green() - ); - if let Some(bytes) = outcome.total_bytes { - println!( - "- Recording size: {}", - bytesize::ByteSize::b(bytes as u64) - .display() - .si() - .to_string() - .green() - ); - } - if let Some(time) = outcome.total_time { - println!("- Total time: {}", format!("{:.2?}", time).green()); - } -} - -/// Print configuration summary for replay mode. -pub fn print_replay_summary(proxies: &ProxyMap, bindings: &HashMap) { - let proxy_width = proxies.max_name_length().min(12); - let use_color = std::io::stderr().is_terminal(); - if use_color { - println!("{}", "Replaying mode".blue()); - println!(); - println!("{}", "Proxies:".dimmed()); - } else { - println!("Replaying mode"); - println!(); - println!("Proxies:"); - } - for (proxy_id, config) in proxies.iter() { - let proxy_display = format_proxy_id(proxy_id.as_str(), proxy_width); - let target_str = format!("({})", config.target()); - let bind_str = bindings - .get(proxy_id) - .map(|s| s.to_string()) - .unwrap_or(config.bind().to_string()); - - if use_color { - println!( - "{:<14} {:<12} {}", - proxy_display.color(proxy_color(proxy_id)), - bind_str.bold(), - target_str.bold() - ); - } else { - println!( - "{:<14} {:<12} {}", - proxy_display, - bind_str, - target_str.dimmed() - ); - } - } - println!(); -} - -/// Format a recorded event for human-readable debug output. -pub fn format_recorded_event( - proxy_id: &ProxyId, - event: &dyn macaw::core::RecordEvent, - proxy_width: usize, -) -> String { - let formatter = event.format_debug(); - let direction = formatter.direction(); - let parts = formatter.parts(); - - let arrow = direction.arrow(); - let proxy_display = format_proxy_id(proxy_id.as_str(), proxy_width); - - let use_color = std::io::stderr().is_terminal(); - let term_width = terminal_size::terminal_size() - .map(|(w, _)| w.0 as usize) - .unwrap_or(120); - - let prefix_len = arrow.len() + 1 + proxy_display.len() + 1; - let msg_max_width = term_width.saturating_sub(prefix_len).saturating_sub(3); - let truncated_parts = truncate_parts(parts, msg_max_width); - - if !use_color { - return format!( - "{} {} {}", - arrow, - proxy_display, - style_parts(&truncated_parts, false) - ); - } - - format!( - "{} {} {}", - style_arraw(arrow), - proxy_display.color(proxy_color(proxy_id.as_str())), - style_parts(&truncated_parts, true) - ) -} - -/// Spawn a task that receives RecordedEvents and prints them. -pub fn spawn_debug_printer(mut rx: mpsc::UnboundedReceiver, proxy_width: usize) { - tokio::spawn(async move { - while let Some(recorded) = rx.recv().await { - let line = - format_recorded_event(&recorded.proxy_id, recorded.event.as_ref(), proxy_width); - eprintln!("{}", line); - } - }); -} diff --git a/src/main.rs b/src/main.rs index bbdf45f..8f50d1a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,40 +1,26 @@ //! Macaw CLI - record and replay network traffic. -mod config; -mod debug; -mod record; -mod replay; +mod client; +mod serve; use anyhow::Result; use clap::{Parser, Subcommand}; -use std::path::PathBuf; #[derive(Parser)] #[command(name = "macaw")] #[command(about = "Record and replay network traffic")] struct Cli { - #[arg(short, long, default_value = "config/macaw.toml")] - config: PathBuf, - - #[arg(short, long)] - debug: bool, - #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { - /// Start recording (proxies from config) - Record { - /// Recording file path - output_file: PathBuf, - }, - /// Start replay from recording file - Replay { - /// Path to recording file - recording_file: PathBuf, - }, + /// Run the versioned HTTP control server + Serve(serve::ServeArgs), + /// Control a running Macaw server + #[command(alias = "ctl")] + Client(client::ClientArgs), } #[tokio::main] @@ -46,15 +32,12 @@ async fn main() -> Result<()> { let cli = Cli::parse(); - let config = &cli.config; - let debug = cli.debug; - match cli.command { - Commands::Record { output_file } => { - record::run(config, &output_file, debug).await?; + Commands::Serve(args) => { + serve::run(args).await?; } - Commands::Replay { recording_file } => { - replay::run(config, &recording_file, debug).await?; + Commands::Client(args) => { + client::run(args).await?; } } diff --git a/src/record.rs b/src/record.rs deleted file mode 100644 index 6e8de10..0000000 --- a/src/record.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Record command - create proxies and record traffic. - -use anyhow::{Context, Result}; -use futures::StreamExt; -use macaw::core::*; -use std::collections::HashMap; -use std::path::Path; -use tokio::signal::unix::{SignalKind, signal}; -use tokio_stream::wrappers::SignalStream; -use tracing::info; - -use crate::config::Config; -use crate::debug; - -pub async fn run(config_path: &Path, output_path: &Path, debug_mode: bool) -> Result<()> { - let config = Config::from_file(config_path)?; - - let debug_tx = if debug_mode { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - let proxy_width = config.proxies.max_name_length(); - debug::spawn_debug_printer(rx, proxy_width); - - Some(tx) - } else { - None - }; - - let options = RecorderOptions { debug_tx }; - let mut macaw = Macaw::::recorder_with_options(options); - let mut bindings = HashMap::new(); - for (proxy_id, proxy_config) in config.proxies.iter() { - let socket = proxy_config - .bind_to_recorder(proxy_id, &mut macaw) - .await - .context("Failed to bind proxy to recorder")?; - bindings.insert(proxy_id.clone(), socket); - } - - debug::print_record_summary(&config.proxies, &bindings); - - let exit_handle = macaw.exit_handle(); - tokio::spawn(async move { - let mut sig_int = SignalStream::new(signal(SignalKind::interrupt()).unwrap()); - let mut sig_term = SignalStream::new(signal(SignalKind::terminate()).unwrap()); - let mut sig_quit = SignalStream::new(signal(SignalKind::quit()).unwrap()); - tokio::select! { - _ = sig_int.next() => {} - _ = sig_term.next() => {} - _ = sig_quit.next() => {} - }; - info!("Signal received. Saving recording..."); - exit_handle.exit(); - }); - - let outcome = macaw.record_when_exit(output_path).await?; - debug::print_record_outcome(outcome); - Ok(()) -} diff --git a/src/replay.rs b/src/replay.rs deleted file mode 100644 index ba2953c..0000000 --- a/src/replay.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Replay command - load recording and replay through proxies. - -use anyhow::{Context, Result}; -use macaw::core::*; -use std::collections::HashMap; -use std::path::Path; - -use crate::config::Config; -use crate::debug; - -pub async fn run(config_path: &Path, recording_path: &Path, debug_mode: bool) -> Result<()> { - let config = Config::from_file(config_path)?; - - let debug_tx = if debug_mode { - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - let proxy_width = config.proxies.max_name_length(); - debug::spawn_debug_printer(rx, proxy_width); - - Some(tx) - } else { - None - }; - let options = ReplayerOptions { debug_tx }; - - let mut macaw = Macaw::::replayer_with_options(recording_path, options)?; - let mut bindings = HashMap::new(); - for (proxy_id, proxy_config) in config.proxies.iter() { - let socket = proxy_config - .bind_to_replayer(proxy_id, &mut macaw) - .await - .context("Failed to bind proxy to recorder")?; - bindings.insert(proxy_id.clone(), socket); - } - - debug::print_replay_summary(&config.proxies, &bindings); - - macaw.play()?; - macaw.wait_until_stopped().await?; - Ok(()) -} diff --git a/src/serve/api.rs b/src/serve/api.rs new file mode 100644 index 0000000..4c7de8d --- /dev/null +++ b/src/serve/api.rs @@ -0,0 +1,866 @@ +use super::model::{ + API_VERSION, CreateProfileRequest, CreateSessionRequest, ErrorDetail, ErrorResponse, + HealthResponse, ModelError, ProfileResponse, SessionResponse, TrafficStreamEvent, +}; +use axum::extract::rejection::JsonRejection; +use axum::extract::{DefaultBodyLimit, Path, Query, State}; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use macaw::session::{ + ProfileId, SessionError, SessionErrorCode, SessionId, SessionManagerHandle, SessionName, +}; +use serde::Deserialize; +use std::time::Duration; +use std::{convert::Infallible, time}; + +const JSON_BODY_LIMIT: usize = 1024 * 1024; +const QUERY_TIMEOUT: Duration = Duration::from_secs(5); +const LIFECYCLE_TIMEOUT: Duration = Duration::from_secs(30); + +pub fn router(manager: SessionManagerHandle) -> Router { + Router::new() + .route("/v1/health", get(health)) + .route("/v1/profiles", post(create_profile).get(list_profiles)) + .route("/v1/profiles/{id}", get(get_profile).delete(remove_profile)) + .route( + "/v1/profiles/{id}/sessions", + post(create_session).get(list_profile_sessions), + ) + .route("/v1/sessions", get(list_sessions)) + .route("/v1/sessions/{id}", get(get_session).delete(remove_session)) + .route("/v1/sessions/{id}/start", post(start_session)) + .route("/v1/sessions/{id}/stop", post(stop_session)) + .route("/v1/sessions/{id}/events", get(watch_session)) + .fallback(not_found) + .layer(DefaultBodyLimit::max(JSON_BODY_LIMIT)) + .with_state(manager) +} + +async fn health( + State(manager): State, +) -> Result, ApiError> { + let ready = with_timeout(QUERY_TIMEOUT, manager.is_ready()).await?; + Ok(Json(HealthResponse { + api_version: API_VERSION, + package_version: env!("CARGO_PKG_VERSION"), + ready, + })) +} + +async fn create_session( + State(manager): State, + Path(profile_id): Path, + request: Result, JsonRejection>, +) -> Result { + let profile_id = parse_profile_id(&profile_id)?; + let Json(request) = request.map_err(ApiError::json)?; + let request = request + .into_actor_request(profile_id) + .map_err(ApiError::model)?; + let snapshot = with_timeout(LIFECYCLE_TIMEOUT, manager.create(request)).await?; + let location = format!("/v1/sessions/{}", snapshot.id); + let mut response = (StatusCode::CREATED, Json(SessionResponse::from(snapshot))).into_response(); + response.headers_mut().insert( + header::LOCATION, + HeaderValue::from_str(&location).map_err(|_| ApiError::internal())?, + ); + Ok(response) +} + +async fn create_profile( + State(manager): State, + request: Result, JsonRejection>, +) -> Result { + let Json(request) = request.map_err(ApiError::json)?; + let (id, config) = request.into_profile().map_err(ApiError::model)?; + let profile = with_timeout(QUERY_TIMEOUT, manager.create_profile(id, config)).await?; + let location = format!("/v1/profiles/{}", profile.id); + let mut response = (StatusCode::CREATED, Json(ProfileResponse::from(profile))).into_response(); + response.headers_mut().insert( + header::LOCATION, + HeaderValue::from_str(&location).map_err(|_| ApiError::internal())?, + ); + Ok(response) +} + +async fn list_profiles( + State(manager): State, +) -> Result>, ApiError> { + let profiles = with_timeout(QUERY_TIMEOUT, manager.list_profiles()).await?; + Ok(Json( + profiles.into_iter().map(ProfileResponse::from).collect(), + )) +} + +async fn get_profile( + State(manager): State, + Path(id): Path, +) -> Result, ApiError> { + let profile = with_timeout(QUERY_TIMEOUT, manager.get_profile(parse_profile_id(&id)?)).await?; + Ok(Json(ProfileResponse::from(profile))) +} + +async fn remove_profile( + State(manager): State, + Path(id): Path, +) -> Result { + with_timeout( + QUERY_TIMEOUT, + manager.remove_profile(parse_profile_id(&id)?), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn list_profile_sessions( + State(manager): State, + Path(id): Path, +) -> Result>, ApiError> { + let snapshots = with_timeout( + QUERY_TIMEOUT, + manager.list_by_profile(parse_profile_id(&id)?), + ) + .await?; + Ok(Json( + snapshots.into_iter().map(SessionResponse::from).collect(), + )) +} + +async fn list_sessions( + State(manager): State, +) -> Result>, ApiError> { + let snapshots = with_timeout(QUERY_TIMEOUT, manager.list()).await?; + Ok(Json( + snapshots.into_iter().map(SessionResponse::from).collect(), + )) +} + +async fn get_session( + State(manager): State, + Path(id): Path, +) -> Result, ApiError> { + let id = resolve_session_id(&manager, &id).await?; + let snapshot = with_timeout(QUERY_TIMEOUT, manager.get(id)).await?; + Ok(Json(SessionResponse::from(snapshot))) +} + +async fn stop_session( + State(manager): State, + Path(id): Path, +) -> Result, ApiError> { + let id = resolve_session_id(&manager, &id).await?; + let snapshot = with_timeout(LIFECYCLE_TIMEOUT, manager.stop_session(id)).await?; + Ok(Json(SessionResponse::from(snapshot))) +} + +async fn start_session( + State(manager): State, + Path(id): Path, +) -> Result, ApiError> { + let id = resolve_session_id(&manager, &id).await?; + let snapshot = with_timeout(LIFECYCLE_TIMEOUT, manager.start_session(id)).await?; + Ok(Json(SessionResponse::from(snapshot))) +} + +#[derive(Debug, Default, Deserialize)] +struct WatchQuery { + after: Option, +} + +async fn watch_session( + State(manager): State, + Path(id): Path, + Query(query): Query, +) -> Result>>, ApiError> { + let id = resolve_session_id(&manager, &id).await?; + let subscription = + with_timeout(QUERY_TIMEOUT, manager.subscribe_traffic(id, query.after)).await?; + let stream = async_stream::stream! { + if subscription.dropped > 0 { + let payload = TrafficStreamEvent::DroppedEvents { + count: subscription.dropped, + }; + let data = serde_json::to_string(&payload) + .expect("traffic stream event must serialize"); + yield Ok(Event::default().event("traffic").data(data)); + } + for event in subscription.history { + let data = serde_json::to_string(&TrafficStreamEvent::from(event)) + .expect("traffic stream event must serialize"); + yield Ok(Event::default().event("traffic").data(data)); + } + let Some(mut receiver) = subscription.receiver else { + return; + }; + loop { + let payload = match receiver.recv().await { + Ok(event) => TrafficStreamEvent::from(event), + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + TrafficStreamEvent::DroppedEvents { count } + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }; + let data = serde_json::to_string(&payload) + .expect("traffic stream event must serialize"); + yield Ok(Event::default().event("traffic").data(data)); + } + }; + Ok(Sse::new(stream).keep_alive( + KeepAlive::new() + .interval(time::Duration::from_secs(15)) + .text("keep-alive"), + )) +} + +async fn remove_session( + State(manager): State, + Path(id): Path, +) -> Result { + let id = resolve_session_id(&manager, &id).await?; + with_timeout(QUERY_TIMEOUT, manager.remove(id)).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn not_found() -> ApiError { + ApiError::new( + StatusCode::NOT_FOUND, + SessionErrorCode::NotFound, + "endpoint not found", + ) +} + +async fn resolve_session_id( + manager: &SessionManagerHandle, + value: &str, +) -> Result { + if let Ok(id) = value.parse() { + return Ok(id); + } + let name = value.parse::().map_err(|_| { + ApiError::new( + StatusCode::BAD_REQUEST, + SessionErrorCode::InvalidConfig, + "invalid session id or name", + ) + })?; + let snapshot = with_timeout(QUERY_TIMEOUT, manager.get_by_name(name)).await?; + Ok(snapshot.id) +} + +fn parse_profile_id(value: &str) -> Result { + value.parse().map_err(|message| { + ApiError::new( + StatusCode::BAD_REQUEST, + SessionErrorCode::InvalidConfig, + message, + ) + }) +} + +async fn with_timeout( + duration: Duration, + operation: impl Future>, +) -> Result { + tokio::time::timeout(duration, operation) + .await + .map_err(|_| { + ApiError::new( + StatusCode::SERVICE_UNAVAILABLE, + SessionErrorCode::ActorUnavailable, + "session manager request timed out", + ) + })? + .map_err(ApiError::session) +} + +#[derive(Debug)] +pub struct ApiError { + status: StatusCode, + detail: ErrorDetail, +} + +impl ApiError { + fn new(status: StatusCode, code: SessionErrorCode, message: impl Into) -> Self { + Self { + status, + detail: ErrorDetail { + code, + message: message.into(), + }, + } + } + + fn session(error: SessionError) -> Self { + let status = match error.code { + SessionErrorCode::InvalidConfig => StatusCode::BAD_REQUEST, + SessionErrorCode::NotFound => StatusCode::NOT_FOUND, + SessionErrorCode::Duplicate | SessionErrorCode::NotTerminal => StatusCode::CONFLICT, + SessionErrorCode::Unsupported => StatusCode::UNPROCESSABLE_ENTITY, + SessionErrorCode::ShuttingDown | SessionErrorCode::ActorUnavailable => { + StatusCode::SERVICE_UNAVAILABLE + } + SessionErrorCode::StartupFailed | SessionErrorCode::RuntimeFailed => { + StatusCode::INTERNAL_SERVER_ERROR + } + }; + Self { + status, + detail: ErrorDetail::from_session(&error), + } + } + + fn model(error: ModelError) -> Self { + match error { + ModelError::Invalid(message) => Self::new( + StatusCode::BAD_REQUEST, + SessionErrorCode::InvalidConfig, + message, + ), + ModelError::Unsupported(message) => Self::new( + StatusCode::UNPROCESSABLE_ENTITY, + SessionErrorCode::Unsupported, + message, + ), + } + } + + fn json(rejection: JsonRejection) -> Self { + if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE { + Self::new( + StatusCode::PAYLOAD_TOO_LARGE, + SessionErrorCode::InvalidConfig, + "request body exceeds the configured limit", + ) + } else { + Self::new( + StatusCode::BAD_REQUEST, + SessionErrorCode::InvalidConfig, + "malformed or invalid JSON request", + ) + } + } + + fn internal() -> Self { + Self::new( + StatusCode::INTERNAL_SERVER_ERROR, + SessionErrorCode::ActorUnavailable, + "internal server error", + ) + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + [(header::CONTENT_TYPE, "application/json")], + Json(ErrorResponse { error: self.detail }), + ) + .into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use http_body_util::BodyExt; + use macaw::session::SessionManager; + use serde_json::{Value, json}; + use tower::ServiceExt; + + async fn response_json(response: Response) -> Value { + let bytes = response + .into_body() + .collect() + .await + .expect("response body") + .to_bytes(); + serde_json::from_slice(&bytes).expect("JSON response") + } + + fn json_request(method: &str, uri: &str, value: Value) -> Request { + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(value.to_string())) + .expect("request") + } + + #[tokio::test] + async fn health_reports_version_and_readiness() { + let manager = SessionManager::start(); + let response = router(manager.clone()) + .oneshot( + Request::builder() + .uri("/v1/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["api_version"], "v1"); + assert_eq!(body["package_version"], env!("CARGO_PKG_VERSION")); + assert_eq!(body["ready"], true); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn sessions_require_an_existing_profile() { + let manager = SessionManager::start(); + let app = router(manager.clone()); + let request = json!({"mode": {"type": "replay", "recording": "recording.json"}}); + + let response = app + .clone() + .oneshot(json_request("POST", "/v1/sessions", request.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + + let response = app + .oneshot(json_request( + "POST", + "/v1/profiles/missing/sessions", + request, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + manager.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn shutdown_state_rejects_new_sessions() { + let manager = SessionManager::start(); + manager.begin_shutdown().await.unwrap(); + let app = router(manager.clone()); + + let health = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response_json(health).await["ready"], false); + + let response = app + .oneshot(json_request( + "POST", + "/v1/profiles", + json!({ + "id": "late", + "proxies": {} + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + manager.shutdown().await.unwrap(); + } + + #[cfg(feature = "http")] + #[tokio::test] + async fn session_lifecycle_uses_structured_http_contract() { + let directory = tempfile::tempdir().unwrap(); + let manager = SessionManager::start(); + let app = router(manager.clone()); + let profile = json!({ + "id": "example", + "config_root": directory.path(), + "proxies": { + "api": { + "type": "http", + "config": { + "bind": "127.0.0.1:0", + "target": "https://example.com" + } + } + } + }); + let response = app + .clone() + .oneshot(json_request("POST", "/v1/profiles", profile.clone())) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()[header::LOCATION], "/v1/profiles/example"); + let profile_body = response_json(response).await; + assert_eq!(profile_body["id"], "example"); + assert_eq!( + profile_body["config_root"], + directory.path().to_str().unwrap() + ); + assert_eq!(profile_body["proxies"]["api"]["protocol"], "http"); + assert_eq!(profile_body["proxies"]["api"]["bind"], "127.0.0.1:0"); + assert_eq!( + profile_body["proxies"]["api"]["target"], + "https://example.com" + ); + + let response = app + .clone() + .oneshot(json_request("POST", "/v1/profiles", profile)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/profiles") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let profiles = response_json(response).await; + assert_eq!(profiles.as_array().unwrap().len(), 1); + assert_eq!(profiles[0]["id"], "example"); + + let response = app + .clone() + .oneshot(json_request( + "POST", + "/v1/profiles/example/sessions", + json!({ + "name": "integration-test", + "mode": {"type": "record", "output": "api.json"} + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + let location = response.headers()[header::LOCATION] + .to_str() + .unwrap() + .to_owned(); + let created = response_json(response).await; + assert_eq!(created["name"], "integration-test"); + assert_eq!(created["profile_id"], "example"); + assert_eq!(created["state"], "ready"); + assert!(created["proxies"].as_object().unwrap().is_empty()); + + let response = app + .clone() + .oneshot(json_request( + "POST", + "/v1/profiles/example/sessions", + json!({ + "name": "integration-test", + "mode": {"type": "record", "output": "duplicate.json"} + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("{location}/start")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let started = response_json(response).await; + assert_eq!(started["state"], "running"); + assert_eq!(started["proxies"]["api"]["protocol"], "http"); + assert!( + started["proxies"]["api"]["url"] + .as_str() + .unwrap() + .starts_with("http://127.0.0.1:") + ); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/sessions/integration-test") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(&location) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/profiles/example/sessions") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let sessions = response_json(response).await; + assert_eq!(sessions.as_array().unwrap().len(), 1); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/v1/profiles/example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/sessions/integration-test/stop") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_json(response).await["state"], "stopped"); + assert!(directory.path().join("api.json").exists()); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/v1/sessions/integration-test") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + manager.shutdown().await.unwrap(); + } + + #[cfg(feature = "http")] + #[tokio::test] + async fn session_events_stream_as_sse() { + let target_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target_address = target_listener.local_addr().unwrap(); + let target = tokio::spawn(async move { + axum::serve( + target_listener, + Router::new().route("/{*path}", get(|| async { "ok" })), + ) + .await + }); + + let directory = tempfile::tempdir().unwrap(); + let manager = SessionManager::start(); + let app = router(manager.clone()); + let response = app + .clone() + .oneshot(json_request( + "POST", + "/v1/profiles", + json!({ + "id": "events", + "config_root": directory.path(), + "proxies": { + "api": { + "type": "http", + "config": { + "bind": "127.0.0.1:0", + "target": format!("http://{target_address}") + } + } + } + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + + let response = app + .clone() + .oneshot(json_request( + "POST", + "/v1/profiles/events/sessions", + json!({"mode": {"type": "record", "output": "events.json"}}), + )) + .await + .unwrap(); + let created = response_json(response).await; + let id = created["id"].as_str().unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/v1/sessions/{id}/events")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!( + response.headers()[header::CONTENT_TYPE] + .to_str() + .unwrap() + .starts_with("text/event-stream") + ); + let mut body = response.into_body(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/v1/sessions/{id}/start")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let started = response_json(response).await; + let proxy_url = started["proxies"]["api"]["url"].as_str().unwrap(); + + let proxied = reqwest::get(format!("{proxy_url}/hello")).await.unwrap(); + assert_eq!(proxied.status(), StatusCode::OK); + let frame = tokio::time::timeout(Duration::from_secs(2), body.frame()) + .await + .unwrap() + .unwrap() + .unwrap(); + let data = frame.into_data().unwrap(); + let data = String::from_utf8(data.to_vec()).unwrap(); + assert!(data.contains("\"type\":\"traffic\"")); + assert!(data.contains("\"sequence\":")); + assert!(data.contains("\"proxy\":\"api\"")); + assert!(data.contains("\"timestamp\":")); + assert!(data.contains("\"HttpRequest\":")); + assert!(data.contains("\"GET\"")); + assert!(data.contains("\"headers\":{")); + + let response = app + .oneshot( + Request::builder() + .uri(format!("/v1/sessions/{id}/events?after=0")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let frame = tokio::time::timeout(Duration::from_secs(2), response.into_body().frame()) + .await + .unwrap() + .unwrap() + .unwrap(); + let history = String::from_utf8(frame.into_data().unwrap().to_vec()).unwrap(); + assert!(history.contains("\"sequence\":1")); + assert!(history.contains("\"proxy\":\"api\"")); + + manager.stop_session(id.parse().unwrap()).await.unwrap(); + manager.shutdown().await.unwrap(); + target.abort(); + } + + #[tokio::test] + async fn rejects_unknown_fields_and_oversized_bodies() { + let manager = SessionManager::start(); + let app = router(manager.clone()); + let response = app + .clone() + .oneshot(json_request( + "POST", + "/v1/profiles", + json!({ + "id": "invalid", + "proxies": {}, + "secret": "must not be accepted" + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert!(!response_json(response).await.to_string().contains("secret")); + + let oversized = format!("{{\"padding\":\"{}\"}}", "x".repeat(JSON_BODY_LIMIT + 1)); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/profiles") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(oversized)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + manager.shutdown().await.unwrap(); + } + + #[cfg(not(feature = "wasm"))] + #[tokio::test] + async fn disabled_wasm_plugin_is_unsupported_without_echoing_config() { + let manager = SessionManager::start(); + let response = router(manager.clone()) + .oneshot(json_request( + "POST", + "/v1/profiles", + json!({ + "id": "wasm", + "proxies": { + "api": { + "type": "wasm_http", + "config": { + "target": "https://example.com", + "component": "plugin.wasm", + "config": {"password": "do-not-echo"} + } + } + } + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert!( + !response_json(response) + .await + .to_string() + .contains("do-not-echo") + ); + manager.shutdown().await.unwrap(); + } +} diff --git a/src/serve/mod.rs b/src/serve/mod.rs new file mode 100644 index 0000000..4ae3f84 --- /dev/null +++ b/src/serve/mod.rs @@ -0,0 +1,137 @@ +mod api; +mod model; +mod profiles; +mod transport; + +use anyhow::{Context, Result, bail}; +use clap::Args; +use macaw::session::{SessionManager, SessionMode, SessionState}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; +use transport::BoundControlListener; + +const DEFAULT_TCP_ADDRESS: &str = "127.0.0.1:8080"; +const SERVER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); +const SESSION_DRAIN_TIMEOUT: Duration = Duration::from_secs(60); + +#[derive(Debug, Args)] +pub struct ServeArgs { + /// TCP address for the HTTP control API + #[arg(long, value_name = "ADDRESS", conflicts_with = "unix")] + tcp: Option, + + /// Unix socket path for the HTTP control API + #[arg(long, value_name = "PATH", conflicts_with = "tcp")] + unix: Option, + + /// Load immutable startup profiles from TOML files in this directory + #[arg(long, value_name = "DIRECTORY")] + profiles_dir: Option, +} + +pub async fn run(args: ServeArgs) -> Result<()> { + run_until(args, shutdown_signal()).await +} + +async fn run_until( + args: ServeArgs, + shutdown: impl Future + Send + 'static, +) -> Result<()> { + let profiles = args + .profiles_dir + .as_deref() + .map(profiles::load_directory) + .transpose()? + .unwrap_or_default(); + let manager = SessionManager::start(); + for (id, config) in profiles { + manager.create_profile(id, config).await?; + } + let listener = bind(&args).await?; + eprintln!("macaw control server listening on {}", listener.address()); + + let app = api::router(manager.clone()); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let mut server = tokio::spawn(listener.serve(app, async move { + let _ = shutdown_rx.await; + })); + + shutdown.await; + manager.begin_shutdown().await?; + let _ = shutdown_tx.send(()); + match tokio::time::timeout(SERVER_SHUTDOWN_TIMEOUT, &mut server).await { + Ok(result) => result.context("control server task failed")??, + Err(_) => { + tracing::warn!("control connections did not close before shutdown deadline"); + server.abort(); + let _ = server.await; + } + } + + let outcomes = tokio::time::timeout(SESSION_DRAIN_TIMEOUT, manager.stop_all()) + .await + .context("timed out draining sessions")??; + let flush_failed = outcomes.iter().any(|result| match result { + Ok(snapshot) => { + matches!(snapshot.mode, SessionMode::Record { .. }) + && snapshot.state == SessionState::Failed + } + Err(_) => true, + }); + + tokio::time::timeout(SESSION_DRAIN_TIMEOUT, manager.shutdown()) + .await + .context("timed out shutting down session manager")??; + + if flush_failed { + bail!("one or more recording sessions failed to flush"); + } + Ok(()) +} + +async fn bind(args: &ServeArgs) -> Result { + if let Some(path) = &args.unix { + #[cfg(unix)] + { + return BoundControlListener::unix(path); + } + #[cfg(not(unix))] + { + let _ = path; + bail!("Unix control sockets are not supported on this platform"); + } + } + + let address = match args.tcp { + Some(address) => address, + None => DEFAULT_TCP_ADDRESS + .parse() + .expect("default TCP address must be valid"), + }; + BoundControlListener::tcp(address).await +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut terminate = + signal(SignalKind::terminate()).expect("failed to install SIGTERM handler"); + tokio::select! { + result = tokio::signal::ctrl_c() => { + if let Err(error) = result { + tracing::error!("failed to listen for Ctrl-C: {error}"); + } + } + _ = terminate.recv() => {} + } + } + + #[cfg(not(unix))] + { + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::error!("failed to listen for Ctrl-C: {error}"); + } + } +} diff --git a/src/serve/model.rs b/src/serve/model.rs new file mode 100644 index 0000000..f02d8e9 --- /dev/null +++ b/src/serve/model.rs @@ -0,0 +1,315 @@ +use macaw::core::{ProxyConfig, RecordedEvent}; +use macaw::session::{ + CreateSession, ProfileId, ProfileProxySnapshot, ProfileSnapshot, SessionConfig, + SessionEndpoint, SessionError, SessionErrorCode, SessionMode, SessionName, SessionOutcome, + SessionSnapshot, SessionState, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::path::PathBuf; + +pub const API_VERSION: &str = "v1"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateSessionRequest { + pub name: Option, + pub mode: ModeRequest, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateProfileRequest { + pub id: String, + #[serde(default)] + pub config_root: PathBuf, + pub proxies: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ModeRequest { + Record { output: PathBuf }, + Replay { recording: PathBuf }, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProxyRequest { + #[serde(rename = "type")] + implementation: String, + config: Value, +} + +#[derive(Debug)] +pub enum ModelError { + Invalid(String), + Unsupported(String), +} + +impl CreateProfileRequest { + pub fn into_profile(self) -> Result<(ProfileId, SessionConfig), ModelError> { + let id = self.id.parse().map_err(ModelError::Invalid)?; + let mut proxies = BTreeMap::new(); + for (name, proxy) in self.proxies { + proxies.insert(name, proxy.into_proxy()?); + } + Ok(( + id, + SessionConfig { + root: self.config_root, + proxies, + debug_tx: None, + }, + )) + } +} + +impl CreateSessionRequest { + pub fn into_actor_request(self, profile_id: ProfileId) -> Result { + let mode = match self.mode { + ModeRequest::Record { output } => SessionMode::Record { output }, + ModeRequest::Replay { recording } => SessionMode::Replay { recording }, + }; + let name = self + .name + .map(|name| name.parse::().map_err(ModelError::Invalid)) + .transpose()?; + let mut request = CreateSession::new(profile_id, mode); + request.name = name; + Ok(request) + } +} + +impl ProxyRequest { + fn into_proxy(self) -> Result, ModelError> { + let mut config = match self.config { + Value::Object(config) => config, + _ => { + return Err(ModelError::Invalid( + "proxy config must be a JSON object".to_owned(), + )); + } + }; + config.insert("type".to_owned(), Value::String(self.implementation)); + let proxy = serde_json::from_value::>(Value::Object(config)).map_err( + |error| { + if error.to_string().contains("unknown variant") { + ModelError::Unsupported( + "proxy implementation is not supported by this build".to_owned(), + ) + } else { + ModelError::Invalid("invalid proxy configuration".to_owned()) + } + }, + )?; + proxy.validate(false).map_err(ModelError::Invalid)?; + Ok(proxy) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[cfg(feature = "http")] + #[test] + fn opaque_proxy_config_deserializes_through_trait_object() { + let request: CreateProfileRequest = serde_json::from_value(json!({ + "id": "test", + "proxies": { + "api": { + "type": "http", + "config": { + "bind": "127.0.0.1:0", + "target": "https://example.com" + } + } + } + })) + .unwrap(); + let (_, config) = request.into_profile().unwrap(); + let proxy = config.proxies.get("api").unwrap(); + assert_eq!(proxy.protocol(), "http"); + assert_eq!(proxy.target(), Some("https://example.com")); + } + + #[test] + fn proxy_envelope_rejects_non_object_config() { + let request: CreateProfileRequest = serde_json::from_value(json!({ + "id": "test", + "proxies": { + "api": {"type": "anything", "config": "not-an-object"} + } + })) + .unwrap(); + assert!(matches!( + request.into_profile(), + Err(ModelError::Invalid(_)) + )); + } + + #[test] + fn proxy_envelope_rejects_unknown_fields() { + let result = serde_json::from_value::(json!({ + "id": "test", + "proxies": { + "api": { + "type": "anything", + "config": {}, + "unexpected": true + } + } + })); + assert!(result.is_err()); + } +} + +#[derive(Debug, Serialize)] +pub struct HealthResponse { + pub api_version: &'static str, + pub package_version: &'static str, + pub ready: bool, +} + +#[derive(Debug, Serialize)] +pub struct ProfileResponse { + pub id: ProfileId, + pub config_root: PathBuf, + pub proxies: BTreeMap, +} + +impl From for ProfileResponse { + fn from(profile: ProfileSnapshot) -> Self { + Self { + id: profile.id, + config_root: profile.config_root, + proxies: profile.proxies, + } + } +} + +#[derive(Debug, Serialize)] +pub struct SessionResponse { + pub id: macaw::session::SessionId, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub profile_id: ProfileId, + pub mode: ModeResponse, + pub state: SessionState, + pub proxies: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub outcome: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ModeResponse { + Record { output: PathBuf }, + Replay { recording: PathBuf }, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum OutcomeResponse { + Record { + recording_path: PathBuf, + total_events: usize, + total_bytes: Option, + total_time_millis: Option, + }, + Replay, +} + +impl From for SessionResponse { + fn from(snapshot: SessionSnapshot) -> Self { + let mode = match snapshot.mode { + SessionMode::Record { output } => ModeResponse::Record { output }, + SessionMode::Replay { recording } => ModeResponse::Replay { recording }, + }; + let outcome = snapshot.outcome.map(|outcome| match outcome { + SessionOutcome::Record { + recording_path, + total_events, + total_bytes, + total_time_millis, + } => OutcomeResponse::Record { + recording_path, + total_events, + total_bytes, + total_time_millis, + }, + SessionOutcome::Replay => OutcomeResponse::Replay, + }); + Self { + id: snapshot.id, + name: snapshot.name, + profile_id: snapshot.profile_id, + mode, + state: snapshot.state, + proxies: snapshot.endpoints, + outcome, + error: snapshot.error.as_ref().map(ErrorDetail::from_session), + } + } +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: ErrorDetail, +} + +#[derive(Debug, Serialize)] +pub struct ErrorDetail { + pub code: SessionErrorCode, + pub message: String, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TrafficStreamEvent { + Traffic { + sequence: u64, + #[serde(flatten)] + event: RecordedEvent, + }, + DroppedEvents { + count: u64, + }, +} + +impl From for TrafficStreamEvent { + fn from(event: macaw::session::SequencedRecordedEvent) -> Self { + Self::Traffic { + sequence: event.sequence, + event: event.event, + } + } +} + +impl ErrorDetail { + pub fn from_session(error: &SessionError) -> Self { + Self { + code: error.code, + message: safe_error_message(error), + } + } +} + +fn safe_error_message(error: &SessionError) -> String { + match error.code { + SessionErrorCode::InvalidConfig + | SessionErrorCode::NotFound + | SessionErrorCode::Duplicate + | SessionErrorCode::NotTerminal + | SessionErrorCode::Unsupported + | SessionErrorCode::ShuttingDown => error.message.clone(), + SessionErrorCode::StartupFailed => "session startup failed".to_owned(), + SessionErrorCode::RuntimeFailed => "session runtime failed".to_owned(), + SessionErrorCode::ActorUnavailable => "session manager unavailable".to_owned(), + } +} diff --git a/src/serve/profiles.rs b/src/serve/profiles.rs new file mode 100644 index 0000000..89083a2 --- /dev/null +++ b/src/serve/profiles.rs @@ -0,0 +1,104 @@ +use anyhow::{Context, Result, bail}; +use macaw::session::{ProfileId, SessionConfig}; +use std::path::Path; + +pub fn load_directory(directory: &Path) -> Result> { + let mut paths = std::fs::read_dir(directory) + .with_context(|| format!("failed to read profiles directory {}", directory.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + paths.sort(); + + let mut profiles = Vec::new(); + for path in paths { + if path.extension().and_then(|extension| extension.to_str()) != Some("toml") { + continue; + } + if !path + .metadata() + .with_context(|| format!("failed to inspect profile {}", path.display()))? + .is_file() + { + continue; + } + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| anyhow::anyhow!("profile filename must be valid UTF-8"))?; + let id = stem.parse::().map_err(|error| { + anyhow::anyhow!("invalid profile filename {}: {error}", path.display()) + })?; + let content = std::fs::read_to_string(&path) + .with_context(|| format!("failed to read profile {}", path.display()))?; + let mut config: SessionConfig = toml::from_str(&content) + .with_context(|| format!("failed to parse profile {}", path.display()))?; + config.root = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf(); + if profiles + .iter() + .any(|(existing, _): &(ProfileId, SessionConfig)| existing == &id) + { + bail!("duplicate profile id {id}"); + } + profiles.push((id, config)); + } + Ok(profiles) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "http")] + #[test] + fn loads_sorted_toml_profiles_with_file_relative_roots() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write( + directory.path().join("beta.toml"), + r#" + [proxies.api] + type = "http" + target = "https://example.com" + "#, + ) + .unwrap(); + std::fs::write( + directory.path().join("alpha.toml"), + r#" + [proxies.api] + type = "http" + target = "https://example.com" + "#, + ) + .unwrap(); + std::fs::write(directory.path().join("ignored.txt"), "not TOML").unwrap(); + + let profiles = load_directory(directory.path()).unwrap(); + assert_eq!(profiles.len(), 2); + assert_eq!(profiles[0].0.to_string(), "alpha"); + assert_eq!(profiles[1].0.to_string(), "beta"); + assert_eq!(profiles[0].1.root, directory.path()); + } + + #[test] + fn rejects_invalid_profile_filenames_and_toml() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("invalid name.toml"), "[proxies]").unwrap(); + assert!(load_directory(directory.path()).is_err()); + + std::fs::remove_file(directory.path().join("invalid name.toml")).unwrap(); + std::fs::write(directory.path().join("valid.toml"), "not = [valid").unwrap(); + assert!(load_directory(directory.path()).is_err()); + } + + #[test] + fn rejects_missing_profile_directory() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing"); + let error = load_directory(&missing).unwrap_err(); + assert!( + error + .to_string() + .contains("failed to read profiles directory") + ); + } +} diff --git a/src/serve/transport.rs b/src/serve/transport.rs new file mode 100644 index 0000000..22eb7ed --- /dev/null +++ b/src/serve/transport.rs @@ -0,0 +1,214 @@ +use anyhow::{Context, Result, bail}; +use axum::Router; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use tokio::net::TcpListener; + +#[cfg(unix)] +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +#[cfg(unix)] +use tokio::net::UnixListener; + +#[derive(Debug)] +pub enum ControlListener { + Tcp(TcpListener), + #[cfg(unix)] + Unix(UnixListener), +} + +#[derive(Debug)] +pub struct BoundControlListener { + listener: ControlListener, + address: String, + #[cfg(unix)] + cleanup: Option, +} + +impl BoundControlListener { + pub async fn tcp(address: SocketAddr) -> Result { + let listener = TcpListener::bind(address) + .await + .with_context(|| format!("failed to bind control server to {address}"))?; + let address = listener.local_addr()?.to_string(); + Ok(Self { + listener: ControlListener::Tcp(listener), + address, + #[cfg(unix)] + cleanup: None, + }) + } + + #[cfg(unix)] + pub fn unix(path: &Path) -> Result { + prepare_unix_path(path)?; + let listener = UnixListener::bind(path) + .with_context(|| format!("failed to bind Unix control socket {}", path.display()))?; + let metadata = std::fs::symlink_metadata(path)?; + let cleanup = UnixSocketCleanup { + path: path.to_path_buf(), + device: metadata.dev(), + inode: metadata.ino(), + }; + Ok(Self { + listener: ControlListener::Unix(listener), + address: path.display().to_string(), + cleanup: Some(cleanup), + }) + } + + pub fn address(&self) -> &str { + &self.address + } + + pub async fn serve( + self, + app: Router, + shutdown: impl Future + Send + 'static, + ) -> Result<()> { + let Self { + listener, + address: _, + #[cfg(unix)] + cleanup, + } = self; + #[cfg(unix)] + let _cleanup = cleanup; + + match listener { + ControlListener::Tcp(listener) => { + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await?; + } + #[cfg(unix)] + ControlListener::Unix(listener) => { + axum::serve(listener, app) + .with_graceful_shutdown(shutdown) + .await?; + } + } + Ok(()) + } +} + +#[cfg(unix)] +fn prepare_unix_path(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() => { + std::fs::remove_file(path).with_context(|| { + format!("failed to remove stale Unix socket {}", path.display()) + })?; + } + Ok(_) => bail!( + "refusing to replace non-socket file at Unix control path {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("failed to inspect Unix control path {}", path.display()) + }); + } + } + Ok(()) +} + +#[cfg(unix)] +#[derive(Debug)] +struct UnixSocketCleanup { + path: PathBuf, + device: u64, + inode: u64, +} + +#[cfg(unix)] +impl Drop for UnixSocketCleanup { + fn drop(&mut self) { + let Ok(metadata) = std::fs::symlink_metadata(&self.path) else { + return; + }; + if metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode + { + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + #[tokio::test] + async fn unix_socket_cleanup_is_safe() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("control.sock"); + { + let listener = BoundControlListener::unix(&path).unwrap(); + assert!(path.exists()); + drop(listener); + } + assert!(!path.exists()); + + std::fs::write(&path, "keep me").unwrap(); + let error = BoundControlListener::unix(&path).unwrap_err(); + assert!(error.to_string().contains("refusing to replace non-socket")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me"); + } + + #[cfg(unix)] + #[tokio::test] + async fn unix_listener_replaces_only_stale_sockets() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("stale.sock"); + drop(std::os::unix::net::UnixListener::bind(&path).unwrap()); + assert!(path.exists()); + + let listener = BoundControlListener::unix(&path).unwrap(); + drop(listener); + assert!(!path.exists()); + } + + #[cfg(unix)] + #[tokio::test] + async fn serves_the_same_http_router_over_unix_sockets() { + use bytes::Bytes; + use http_body_util::Empty; + use hyper::Request; + use hyper_util::rt::TokioIo; + use macaw::session::SessionManager; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("api.sock"); + let listener = BoundControlListener::unix(&path).unwrap(); + let manager = SessionManager::start(); + let app = crate::serve::api::router(manager.clone()); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(listener.serve(app, async move { + let _ = shutdown_rx.await; + })); + + let stream = tokio::net::UnixStream::connect(&path).await.unwrap(); + let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) + .await + .unwrap(); + tokio::spawn(connection); + let response = sender + .send_request( + Request::builder() + .uri("/v1/health") + .body(Empty::::new()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), hyper::StatusCode::OK); + + shutdown_tx.send(()).unwrap(); + server.await.unwrap().unwrap(); + assert!(!path.exists()); + manager.shutdown().await.unwrap(); + } +} diff --git a/tests/session.rs b/tests/session.rs new file mode 100644 index 0000000..59284d2 --- /dev/null +++ b/tests/session.rs @@ -0,0 +1,305 @@ +#![cfg(feature = "http")] + +use anyhow::{Context, Result}; +use macaw::http::HttpProxyConfig; +use macaw::session::{ + CreateSession, ProfileId, SessionConfig, SessionError, SessionErrorCode, SessionManager, + SessionMode, SessionState, +}; +use std::collections::BTreeMap; +use std::net::TcpListener; + +fn recorder_config( + root: &std::path::Path, + proxy_name: &str, + target: std::net::SocketAddr, +) -> SessionConfig { + SessionConfig { + root: root.to_path_buf(), + proxies: BTreeMap::from([( + proxy_name.to_string(), + Box::new(HttpProxyConfig::new( + "127.0.0.1:0", + format!("http://{target}"), + )) as Box, + )]), + debug_tx: None, + } +} + +async fn upstream() -> Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + tokio::spawn(async move { + let mut connections = Vec::new(); + while let Ok((connection, _)) = listener.accept().await { + connections.push(connection); + } + }); + Ok(address) +} + +#[test] +fn protocol_config_deserializes_through_trait_object() -> Result<()> { + let config: SessionConfig = toml::from_str( + r#" + [proxies.api] + type = "http" + target = "https://example.test" + "#, + )?; + let proxy = config.proxies.get("api").context("API proxy missing")?; + + assert_eq!(proxy.bind(), "127.0.0.1:0"); + assert_eq!(proxy.target(), Some("https://example.test")); + Ok(()) +} + +#[tokio::test] +async fn recorder_sessions_stop_independently_and_release_ports() -> Result<()> { + let directory = tempfile::tempdir()?; + let target = upstream().await?; + let manager = SessionManager::start(); + let first = manager + .record( + "first.json", + recorder_config(directory.path(), "first", target), + ) + .await?; + let second = manager + .record( + "second.json", + recorder_config(directory.path(), "second", target), + ) + .await?; + + let first_endpoint = first + .endpoints + .get("first") + .context("first proxy endpoint missing")? + .address; + let stopped = manager.stop_session(first.id).await?; + assert_eq!(stopped.state, SessionState::Stopped); + assert!(directory.path().join("first.json").exists()); + TcpListener::bind(first_endpoint).context("stopped session must release its listener")?; + + let second_status = manager.get(second.id).await?; + assert_eq!(second_status.state, SessionState::Running); + let second_endpoint = second + .endpoints + .get("second") + .context("second proxy endpoint missing")? + .address; + assert!(TcpListener::bind(second_endpoint).is_err()); + + manager.stop_session(second.id).await?; + assert!(directory.path().join("second.json").exists()); + manager.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn concurrent_stop_requests_share_one_outcome() -> Result<()> { + let directory = tempfile::tempdir()?; + let target = upstream().await?; + let manager = SessionManager::start(); + let session = manager + .record( + "recording.json", + recorder_config(directory.path(), "proxy", target), + ) + .await?; + + let first = { + let manager = manager.clone(); + tokio::spawn(async move { manager.stop_session(session.id).await }) + }; + let second = { + let manager = manager.clone(); + tokio::spawn(async move { manager.stop_session(session.id).await }) + }; + let (first, second) = tokio::join!(first, second); + assert_eq!(first??, second??); + + manager.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn natural_replay_completion_does_not_stop_recorder() -> Result<()> { + let directory = tempfile::tempdir()?; + let target = upstream().await?; + let manager = SessionManager::start(); + + let seed = manager + .record( + "seed.json", + recorder_config(directory.path(), "seed", target), + ) + .await?; + manager.stop_session(seed.id).await?; + + let recorder = manager + .record( + "concurrent.json", + recorder_config(directory.path(), "recorder", target), + ) + .await?; + let replay = manager + .replay( + "seed.json", + recorder_config(directory.path(), "replayer", target), + ) + .await?; + + let replay_status = tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + let status = manager.get(replay.id).await?; + if status.state.is_terminal() { + break Ok::<_, SessionError>(status); + } + tokio::task::yield_now().await; + } + }) + .await??; + assert_eq!(replay_status.state, SessionState::Stopped); + assert_eq!(manager.get(recorder.id).await?.state, SessionState::Running); + + manager.stop_session(recorder.id).await?; + manager.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn manager_shutdown_waits_for_recording_flush() -> Result<()> { + let directory = tempfile::tempdir()?; + let target = upstream().await?; + let manager = SessionManager::start(); + let session = manager + .record( + "shutdown.json", + recorder_config(directory.path(), "proxy", target), + ) + .await?; + let endpoint = session + .endpoints + .get("proxy") + .context("proxy endpoint missing")? + .address; + + manager.shutdown().await?; + + assert!(directory.path().join("shutdown.json").exists()); + TcpListener::bind(endpoint).context("manager completion must include listener shutdown")?; + Ok(()) +} + +#[tokio::test] +async fn recording_write_failure_is_reported_as_session_failure() -> Result<()> { + let directory = tempfile::tempdir()?; + let target = upstream().await?; + let manager = SessionManager::start(); + let session = manager + .record( + // Saving to a directory is guaranteed to fail on supported platforms. + ".", + recorder_config(directory.path(), "proxy", target), + ) + .await?; + + let stopped = manager.stop_session(session.id).await?; + assert_eq!(stopped.state, SessionState::Failed); + assert!(stopped.outcome.is_none()); + assert!(stopped.error.is_some()); + + manager.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn profile_supports_concurrent_sessions_and_independent_deletion() -> Result<()> { + let directory = tempfile::tempdir()?; + let target = upstream().await?; + let manager = SessionManager::start(); + let profile_id = ProfileId::new("shared").unwrap(); + manager + .create_profile( + profile_id.clone(), + recorder_config(directory.path(), "api", target), + ) + .await?; + + let first = manager + .create(CreateSession::new( + profile_id.clone(), + SessionMode::Record { + output: "first.json".into(), + }, + )) + .await?; + let first = manager.start_session(first.id).await?; + let second = manager + .create(CreateSession::new( + profile_id.clone(), + SessionMode::Record { + output: "second.json".into(), + }, + )) + .await?; + let second = manager.start_session(second.id).await?; + assert_eq!(first.profile_id, profile_id); + assert_eq!(second.profile_id, profile_id); + assert_ne!( + first.endpoints.get("api").unwrap().address, + second.endpoints.get("api").unwrap().address + ); + assert_eq!(manager.list_by_profile(profile_id.clone()).await?.len(), 2); + + manager.remove_profile(profile_id.clone()).await?; + assert_eq!(manager.get(first.id).await?.state, SessionState::Running); + let error = manager + .create(CreateSession::new( + profile_id, + SessionMode::Replay { + recording: "missing.json".into(), + }, + )) + .await + .unwrap_err(); + assert_eq!(error.code, SessionErrorCode::NotFound); + + manager.stop_session(first.id).await?; + manager.stop_session(second.id).await?; + manager.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn profile_validation_defers_mode_specific_requirements_to_session_creation() -> Result<()> { + let manager = SessionManager::start(); + let profile_id = ProfileId::new("replay-only").unwrap(); + let config = SessionConfig { + root: Default::default(), + proxies: BTreeMap::from([( + "api".to_owned(), + Box::new(HttpProxyConfig::replay("127.0.0.1:0")) as Box, + )]), + debug_tx: None, + }; + manager.create_profile(profile_id.clone(), config).await?; + + let error = manager + .create(CreateSession::new( + profile_id, + SessionMode::Record { + output: "recording.json".into(), + }, + )) + .await + .unwrap_err(); + assert_eq!(error.code, SessionErrorCode::InvalidConfig); + assert!(manager.list().await?.is_empty()); + + manager.shutdown().await?; + Ok(()) +}