diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49497fa..ceaee54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: build-and-test: name: Build & Test (features=${{ matrix.features }}) runs-on: ubuntu-latest + env: + CONFIG_FIXTURES_TOKEN: ${{ secrets.CONFIG_FIXTURES_TOKEN }} strategy: fail-fast: false matrix: @@ -24,6 +26,28 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Checkout shared pipe fixtures repo + if: ${{ env.CONFIG_FIXTURES_TOKEN != '' }} + uses: actions/checkout@v4 + with: + repository: trydirect/config + ref: main + token: ${{ env.CONFIG_FIXTURES_TOKEN }} + path: config-fixtures-repo + fetch-depth: 1 + persist-credentials: false + sparse-checkout: | + shared-fixtures + - name: Shared pipe fixtures unavailable + if: ${{ env.CONFIG_FIXTURES_TOKEN == '' }} + run: | + echo "::notice::CONFIG_FIXTURES_TOKEN is unavailable for this workflow run; shared-fixture tests will be skipped." + - name: Link shared pipe fixtures + if: ${{ env.CONFIG_FIXTURES_TOKEN != '' }} + run: | + rm -rf "${GITHUB_WORKSPACE}/../config" "${GITHUB_WORKSPACE}/../shared-fixtures" + ln -sfn "${GITHUB_WORKSPACE}/config-fixtures-repo/shared-fixtures" "${GITHUB_WORKSPACE}/../shared-fixtures" + test -d "${GITHUB_WORKSPACE}/../shared-fixtures/pipe-contract" - name: Setup Rust toolchain (${{ matrix.rust }}) uses: dtolnay/rust-toolchain@stable diff --git a/Cargo.lock b/Cargo.lock index a7e8ba6..5c7b928 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +22,54 @@ dependencies = [ "memchr", ] +[[package]] +name = "amq-protocol" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587d313f3a8b4a40f866cc84b6059fe83133bf172165ac3b583129dd211d8e1c" +dependencies = [ + "amq-protocol-tcp", + "amq-protocol-types", + "amq-protocol-uri", + "cookie-factory", + "nom", + "serde", +] + +[[package]] +name = "amq-protocol-tcp" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc707ab9aa964a85d9fc25908a3fdc486d2e619406883b3105b48bf304a8d606" +dependencies = [ + "amq-protocol-uri", + "tcp-stream", + "tracing", +] + +[[package]] +name = "amq-protocol-types" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf99351d92a161c61ec6ecb213bc7057f5b837dd4e64ba6cb6491358efd770c4" +dependencies = [ + "cookie-factory", + "nom", + "serde", + "serde_json", +] + +[[package]] +name = "amq-protocol-uri" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89f8273826a676282208e5af38461a07fe939def57396af6ad5997fcf56577d" +dependencies = [ + "amq-protocol-types", + "percent-encoding", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -76,6 +135,45 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -101,6 +199,127 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.3.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" +dependencies = [ + "async-channel", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor-trait" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af57045d58eeb1f7060e7025a1631cbc6399e0a1d10ad6735b3d0ea7f8346ce" +dependencies = [ + "async-global-executor", + "async-trait", + "executor-trait", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling 3.11.0", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6012d170ad00de56c9ee354aef2e358359deb1ec504254e0e5a3774771de0e" +dependencies = [ + "async-io 1.13.0", + "async-trait", + "futures-core", + "reactor-trait", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -123,6 +342,12 @@ dependencies = [ "syn", ] +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -254,6 +479,18 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -269,6 +506,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + [[package]] name = "bollard" version = "0.19.4" @@ -339,6 +598,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.49" @@ -397,6 +665,16 @@ dependencies = [ "phf_codegen", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.53" @@ -437,6 +715,18 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -452,6 +742,37 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -517,6 +838,44 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.5" @@ -527,6 +886,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "deunicode" version = "1.6.2" @@ -561,6 +929,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "dotenvy" version = "0.15.7" @@ -595,6 +969,51 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + +[[package]] +name = "executor-trait" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c39dff9342e4e0e16ce96be751eb21a94e94a87bb2f6e63ad1961c2ce109bf" +dependencies = [ + "async-trait", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -613,6 +1032,23 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -643,6 +1079,40 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.3.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -737,7 +1207,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags", + "bitflags 2.10.0", "ignore", "walkdir", ] @@ -779,6 +1249,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1126,6 +1608,36 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1173,6 +1685,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lapin" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2aa4725b9607915fa1a73e940710a3be6af508ce700e56897cbe8847fbb07" +dependencies = [ + "amq-protocol", + "async-global-executor-trait", + "async-reactor-trait", + "async-trait", + "executor-trait", + "flume", + "futures-core", + "futures-io", + "parking_lot", + "pinky-swear", + "reactor-trait", + "serde", + "tracing", + "waker-fn", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1191,6 +1725,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "linux-raw-sys" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1267,6 +1807,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -1315,12 +1861,22 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -1334,9 +1890,19 @@ dependencies = [ name = "nu-ansi-term" version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" dependencies = [ - "windows-sys 0.61.2", + "num-integer", + "num-traits", ] [[package]] @@ -1345,6 +1911,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1354,6 +1929,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1366,6 +1950,40 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "p12-keystore" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cae83056e7cb770211494a0ecf66d9fa7eba7d00977e5bb91f0e925b40b937f" +dependencies = [ + "cbc", + "cms", + "der", + "des", + "hex", + "hmac", + "pkcs12", + "pkcs5", + "rand 0.9.2", + "rc2", + "sha1", + "sha2", + "thiserror", + "x509-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1398,6 +2016,25 @@ dependencies = [ "regex", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1527,6 +2164,89 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pinky-swear" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ea6e230dd3a64d61bcb8b79e597d3ab6b4c94ec7a234ce687dd718b4f2e657" +dependencies = [ + "doc-comment", + "flume", + "parking_lot", + "tracing", +] + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.3.0", + "futures-io", +] + +[[package]] +name = "pkcs12" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" +dependencies = [ + "cms", + "const-oid", + "der", + "digest", + "spki", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes", + "cbc", + "der", + "pbkdf2", + "scrypt", + "sha2", + "spki", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi 0.5.2", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1862,13 +2582,33 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher", +] + +[[package]] +name = "reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "438a4293e4d097556730f4711998189416232f009c137389e0f961d2bc0ddc58" +dependencies = [ + "async-trait", + "futures-core", + "futures-io", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -1997,16 +2737,39 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + [[package]] name = "rustix" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] @@ -2025,6 +2788,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-connector" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" +dependencies = [ + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -2067,6 +2856,15 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2076,6 +2874,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -2106,6 +2913,40 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.27" @@ -2300,6 +3141,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "socket2" version = "0.5.10" @@ -2320,6 +3171,25 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2344,6 +3214,7 @@ dependencies = [ "hmac", "http-body-util", "hyper", + "lapin", "mockito", "nix", "prost", @@ -2434,16 +3305,28 @@ dependencies = [ "windows", ] +[[package]] +name = "tcp-stream" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495b0abdce3dc1f8fd27240651c9e68890c14e9d9c61527b1ce44d8a5a7bd3d5" +dependencies = [ + "cfg-if", + "p12-keystore", + "rustls-connector", + "rustls-pemfile", +] + [[package]] name = "tempfile" version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "fastrand", + "fastrand 2.3.0", "getrandom 0.3.4", "once_cell", - "rustix", + "rustix 1.1.2", "windows-sys 0.61.2", ] @@ -2739,7 +3622,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-core", "futures-util", @@ -2977,6 +3860,12 @@ dependencies = [ "libc", ] +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "walkdir" version = "2.5.0" @@ -3216,6 +4105,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3243,6 +4141,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3276,6 +4189,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3288,6 +4207,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3300,6 +4225,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3324,6 +4255,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3336,6 +4273,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3348,6 +4291,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3360,6 +4309,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3384,6 +4339,34 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", +] + +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 04c4701..a4b1bc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } tonic = { version = "0.12", features = ["tls"] } prost = "0.13" prost-types = "0.13" +lapin = "2" tera = "1" tower-http = { version = "0.6", features = ["fs"] } base64 = "0.22" diff --git a/Dockerfile b/Dockerfile index 7481a78..f020fa4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ FROM clux/muslrust:stable AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ +COPY build.rs build.rs +COPY proto proto COPY src src COPY templates templates COPY static static @@ -44,4 +46,4 @@ ENV MODE="serve-ui" # CMD ["/usr/local/bin/status", "serve", "--port", "5000", "--with-ui"] ENTRYPOINT ["/usr/local/bin/status"] -CMD ["serve", "--port", "5000", "--with-ui"] \ No newline at end of file +CMD ["serve", "--port", "5000", "--with-ui"] diff --git a/Dockerfile.compose-agent b/Dockerfile.compose-agent index 2e0e329..69aec62 100644 --- a/Dockerfile.compose-agent +++ b/Dockerfile.compose-agent @@ -2,6 +2,8 @@ FROM clux/muslrust:stable AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ +COPY build.rs build.rs +COPY proto proto COPY src src COPY templates templates COPY static static diff --git a/Dockerfile.prod b/Dockerfile.prod index 3f9ee9c..fd567d1 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -1,7 +1,10 @@ +# syntax=docker/dockerfile:1.4 FROM clux/muslrust:stable AS builder WORKDIR /app COPY Cargo.toml Cargo.lock* ./ +COPY build.rs build.rs +COPY proto proto COPY src src COPY templates templates COPY static static @@ -21,4 +24,4 @@ ENV RUST_LOG=info EXPOSE 5000 USER 0 ENTRYPOINT ["/status"] -CMD ["serve", "--port", "5000", "--with-ui"] \ No newline at end of file +CMD ["serve", "--port", "5000", "--with-ui"] diff --git a/README.md b/README.md index 6ef26b0..4dc478b 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,15 @@ status serve --port 5000 # JSON API only status serve --port 5000 --with-ui # API + web dashboard ``` +## Command Transport Split + +Status Panel uses **two different command transport paths**: + +1. **Normal Status Panel commands** use the dashboard DB queue plus HTTP long-polling. The agent waits on `/api/v1/agent/commands/wait/{deployment_hash}`, executes the command locally, then reports back to `/api/v1/agent/commands/report`. +2. **Agent-executor pipe steps** are a separate path. AMQP/RabbitMQ support belongs to that executor flow, not to the normal Status Panel command queue. + +This means RabbitMQ is **not** the transport for regular `health`, `logs`, `deploy_app`, or other Status Panel commands. Pipe operations such as `activate_pipe`, `deactivate_pipe`, and `trigger_pipe` run inside the agent runtime, but the normal command delivery path is still DB queue + long-polling. + ## Build from Source ```bash @@ -151,6 +160,8 @@ Or use Docker Compose with the included `docker-compose.yml` for a full setup wi | `POST` | `/api/v1/commands/enqueue` | Enqueue a command | | `POST` | `/api/v1/commands/report` | Report execution result | +The local `/api/v1/commands/*` endpoints are the agent's own Axum API surface. When connected to the remote dashboard, the daemon uses the `/api/v1/agent/commands/*` contract instead. AMQP-backed executor traffic is separate from both of these HTTP command paths. + ### Self-Update | Method | Path | Description | @@ -179,6 +190,7 @@ The agent accepts signed commands from the Stacker dashboard covering the full l | `config_diff` | Detect configuration drift | | `configure_proxy` | Nginx proxy management | | `configure_firewall` | iptables policy management | +| `activate_pipe` / `deactivate_pipe` / `trigger_pipe` | Agent-side pipe registration and runtime execution | ## Security diff --git a/build.rs b/build.rs index 586c11b..b14b34a 100644 --- a/build.rs +++ b/build.rs @@ -1,4 +1,8 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + fn main() -> Result<(), Box> { + emit_display_version(); println!("cargo:rerun-if-changed=proto/pipe.proto"); // Vendor protoc so builds work without a system-installed protoc let _protoc = protoc_bin_vendored::protoc_bin_path().expect("vendored protoc not found"); @@ -9,3 +13,66 @@ fn main() -> Result<(), Box> { .compile_protos(&["proto/pipe.proto"], &["proto"])?; Ok(()) } + +fn emit_display_version() { + let cargo_version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string()); + let display_version = match git_short_hash() { + Some(hash) => format!("{cargo_version} ({hash})"), + None => cargo_version, + }; + println!("cargo:rustc-env=STATUS_DISPLAY_VERSION={display_version}"); + + if let Some(git_dir) = git_dir() { + emit_git_rerun_paths(&git_dir); + } +} + +fn git_short_hash() -> Option { + git_output(&["rev-parse", "--short=7", "HEAD"]) +} + +fn git_dir() -> Option { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?); + let git_dir = git_output(&["rev-parse", "--git-dir"])?; + let path = PathBuf::from(git_dir); + Some(if path.is_absolute() { + path + } else { + manifest_dir.join(path) + }) +} + +fn emit_git_rerun_paths(git_dir: &Path) { + let head_path = git_dir.join("HEAD"); + println!("cargo:rerun-if-changed={}", head_path.display()); + + let packed_refs = git_dir.join("packed-refs"); + println!("cargo:rerun-if-changed={}", packed_refs.display()); + + if let Ok(head_contents) = std::fs::read_to_string(&head_path) { + if let Some(reference) = head_contents.strip_prefix("ref: ") { + let ref_path = git_dir.join(reference.trim()); + println!("cargo:rerun-if-changed={}", ref_path.display()); + } + } +} + +fn git_output(args: &[&str]) -> Option { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").ok()?; + let output = Command::new("git") + .args(args) + .current_dir(manifest_dir) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let value = String::from_utf8(output.stdout).ok()?; + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} diff --git a/docs/COMPOSE_AGENT_SIDECAR.md b/docs/COMPOSE_AGENT_SIDECAR.md index 8af7d13..0da0cf4 100644 --- a/docs/COMPOSE_AGENT_SIDECAR.md +++ b/docs/COMPOSE_AGENT_SIDECAR.md @@ -35,6 +35,14 @@ The Compose Agent Sidecar is a separate container that handles Docker Compose op 5. **Watchdog Monitoring**: Automatic health checks and restart logic for the compose-agent container. +### Command Transport Boundary + +The sidecar split does **not** change the main Status Panel command transport: + +- **Normal dashboard commands** still move through the Status Panel DB queue and the agent's HTTP long-poll loop (`/api/v1/agent/commands/wait/{deployment_hash}` → execute locally → `/api/v1/agent/commands/report`). +- **AMQP/RabbitMQ** belongs to the separate agent-executor pipe-step path, not to the normal Status Panel command queue. +- Pipe commands such as `activate_pipe`, `deactivate_pipe`, and `trigger_pipe` now execute inside the agent runtime, but their command delivery is still initiated through the regular dashboard command path unless a separate executor flow is used. + ## Configuration ### Docker Compose Setup diff --git a/docs/LONG_POLLING_QUICKSTART.md b/docs/LONG_POLLING_QUICKSTART.md index 10ee51a..24f943b 100644 --- a/docs/LONG_POLLING_QUICKSTART.md +++ b/docs/LONG_POLLING_QUICKSTART.md @@ -13,6 +13,8 @@ COMMAND_TIMEOUT_SECS=300 ``` > **Note:** The commands in this quick start target the agent's local Axum API (`/api/v1/commands/*`). When the agent polls the remote Stacker dashboard it calls the `/api/v1/agent/commands/*` endpoints and sends `Authorization: Bearer $AGENT_TOKEN` (for example `/api/v1/agent/commands/wait/{deployment_hash}`). +> +> **Transport split:** regular Status Panel commands still use the DB queue + HTTP long-polling path. AMQP/RabbitMQ belongs to the separate agent-executor pipe-step flow and is not the transport used for normal `health`, `logs`, `deploy_app`, or other dashboard commands. ## 2️⃣ Start the Agent diff --git a/src/agent/daemon.rs b/src/agent/daemon.rs index a3a60d0..da4e930 100644 --- a/src/agent/daemon.rs +++ b/src/agent/daemon.rs @@ -12,7 +12,7 @@ use crate::agent::config::Config; use crate::commands::executor::CommandExecutor; use crate::commands::firewall::FirewallPolicy; use crate::commands::validator::CommandValidator; -use crate::commands::TimeoutStrategy; +use crate::commands::{default_pipe_runtime_state_path, PipeRuntime, TimeoutStrategy}; use crate::monitoring::{ spawn_heartbeat, ControlPlane, MetricsCollector, MetricsSnapshot, MetricsStore, }; @@ -122,6 +122,20 @@ pub async fn run(config_path: String) -> Result<()> { // Build firewall policy from config (no API port in daemon mode) let firewall_policy = FirewallPolicy::from_config(&cfg, None); + let pipe_runtime = PipeRuntime::new(); + pipe_runtime + .configure_persistence(default_pipe_runtime_state_path(Some(&config_path))) + .await; + match pipe_runtime.restore_from_disk().await { + Ok(restored) if restored > 0 => { + info!(restored, "restored persisted pipe runtime registrations"); + } + Ok(_) => {} + Err(error) => { + warn!(error = %error, "failed to restore persisted pipe runtime registrations"); + } + } + let ctx = PollingContext { dashboard_url, deployment_hash, @@ -132,6 +146,7 @@ pub async fn run(config_path: String) -> Result<()> { command_timeout, firewall_policy, control_plane, + pipe_runtime, }; // Spawn the long-polling loop @@ -161,6 +176,7 @@ struct PollingContext { command_timeout: u64, firewall_policy: FirewallPolicy, control_plane: ControlPlane, + pipe_runtime: PipeRuntime, } /// Long-polling loop: continuously waits for commands and executes them @@ -231,7 +247,14 @@ async fn execute_and_report( command_type = %cmd.name, "executing stacker command" ); - match execute_stacker_command(&cmd, &stacker_cmd, &ctx.firewall_policy).await { + match execute_stacker_command( + &cmd, + &stacker_cmd, + &ctx.firewall_policy, + &ctx.pipe_runtime, + ) + .await + { Ok(result) => result, Err(e) => { error!(command_id = %cmd.command_id, error = %e, "stacker command execution failed"); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c34147c..972f14d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -16,7 +16,10 @@ pub use deploy::{ pub use docker_executor::execute_docker_operation; pub use docker_ops::DockerOperation; pub use self_update::{get_update_status, start_update_job, UpdateJobs, UpdatePhase, UpdateStatus}; -pub use stacker::{execute_stacker_command, parse_stacker_command, StackerCommand}; +pub use stacker::{ + default_pipe_runtime_state_path, execute_stacker_command, parse_stacker_command, PipeRuntime, + StackerCommand, +}; pub use timeout::{TimeoutPhase, TimeoutStrategy, TimeoutTracker}; pub use validator::{CommandValidator, ValidatorConfig}; pub use version_check::check_remote_version; diff --git a/src/commands/stacker.rs b/src/commands/stacker.rs index a878528..c2ecab7 100644 --- a/src/commands/stacker.rs +++ b/src/commands/stacker.rs @@ -1,17 +1,31 @@ use anyhow::{bail, Context, Result}; -#[cfg(feature = "docker")] use chrono::{SecondsFormat, Utc}; +use futures_util::StreamExt; +use lapin::{ + options::{BasicAckOptions, BasicConsumeOptions, QueueBindOptions}, + types::FieldTable, + Connection, ConnectionProperties, +}; #[cfg(feature = "docker")] use regex::Regex; use serde::Deserialize; use serde::Serialize; -#[cfg(any(feature = "docker", test))] use serde_json::json; use serde_json::Value; +use std::collections::HashMap; #[cfg(feature = "docker")] -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::sync::Arc; #[cfg(feature = "docker")] use std::sync::OnceLock; +use tokio::io::AsyncWriteExt; +use tokio::sync::RwLock; +use tokio::task::AbortHandle; +use tokio::time::Duration; +use tracing::{debug, info, warn}; #[cfg(feature = "docker")] use crate::transport::CommandError; @@ -65,6 +79,7 @@ mod trigger_pipe_handler_tests { .await; let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "11111111-1111-1111-1111-111111111111".into(), @@ -80,24 +95,28 @@ mod trigger_pipe_handler_tests { trigger_type: "manual".into(), }; - let result = handle_trigger_pipe(&agent_cmd, &data) + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) .await .expect("trigger_pipe should execute"); mock.assert_async().await; - assert_eq!(result.status, "completed"); + assert_eq!(result.status, "success"); assert!(result.error.is_none()); let body = result.result.expect("trigger_pipe result body"); assert_eq!(body["success"], true); assert_eq!(body["mapped_data"], json!({ "email": "dev@try.direct" })); + assert_eq!(body["target_response"]["transport"], "http"); assert_eq!(body["target_response"]["status"], 200); + assert_eq!(body["target_response"]["delivered"], true); assert_eq!(body["target_response"]["body"], json!({ "accepted": true })); + assert_eq!(body["lifecycle"], Value::Null); } #[tokio::test] async fn handle_trigger_pipe_requires_external_target_url() { let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "11111111-1111-1111-1111-111111111111".into(), @@ -113,7 +132,7 @@ mod trigger_pipe_handler_tests { trigger_type: "manual".into(), }; - let result = handle_trigger_pipe(&agent_cmd, &data) + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) .await .expect("trigger_pipe should return structured failure"); @@ -168,9 +187,44 @@ mod trigger_pipe_handler_tests { assert_eq!(normalize_trigger_pipe_method("", "GET"), "GET"); } + #[test] + fn given_http_target_response_when_delivery_succeeds_then_report_includes_transport_and_delivery_status( + ) { + let response = + build_trigger_pipe_target_response("http", Some(202), json!({"accepted": true})); + + assert_eq!(response["transport"], "http"); + assert_eq!(response["status"], 202); + assert_eq!(response["delivered"], true); + assert_eq!(response["body"], json!({"accepted": true})); + } + + #[test] + fn given_target_send_error_when_delivery_fails_then_report_preserves_transport_context() { + let response = build_trigger_pipe_target_response("websocket", None, Value::Null); + + assert_eq!(response["transport"], "websocket"); + assert_eq!(response["status"], Value::Null); + assert_eq!(response["delivered"], false); + assert_eq!(response["body"], Value::Null); + } + + #[test] + fn pipe_source_worker_kind_recognizes_rabbitmq_sources() { + assert_eq!( + pipe_source_worker_kind("rabbitmq"), + Some(PipeSourceWorkerKind::Amqp) + ); + assert_eq!( + pipe_source_worker_kind("amqp"), + Some(PipeSourceWorkerKind::Amqp) + ); + } + #[tokio::test] async fn handle_trigger_pipe_requires_input_or_source_details() { let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "11111111-1111-1111-1111-111111111111".into(), @@ -186,7 +240,7 @@ mod trigger_pipe_handler_tests { trigger_type: "manual".into(), }; - let result = handle_trigger_pipe(&agent_cmd, &data) + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) .await .expect("trigger_pipe should return structured failure"); @@ -201,6 +255,7 @@ mod trigger_pipe_handler_tests { async fn handle_trigger_pipe_routes_ws_target() { // Use a port that is not listening so the WS connection fails fast let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-ws-1".into(), @@ -216,7 +271,7 @@ mod trigger_pipe_handler_tests { trigger_type: "manual".into(), }; - let result = handle_trigger_pipe(&agent_cmd, &data) + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) .await .expect("trigger_pipe should return structured result"); @@ -227,11 +282,16 @@ mod trigger_pipe_handler_tests { error.contains("WebSocket") || error.contains("Connection refused"), "expected WS connection error, got: {error}" ); + let body = result.result.expect("trigger_pipe failure result body"); + assert_eq!(body["target_response"]["transport"], "websocket"); + assert_eq!(body["target_response"]["delivered"], false); + assert_eq!(body["target_response"]["status"], Value::Null); } #[tokio::test] async fn handle_trigger_pipe_routes_grpc_target() { let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-grpc-1".into(), @@ -247,7 +307,7 @@ mod trigger_pipe_handler_tests { trigger_type: "manual".into(), }; - let result = handle_trigger_pipe(&agent_cmd, &data) + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) .await .expect("trigger_pipe should return structured result"); @@ -258,11 +318,16 @@ mod trigger_pipe_handler_tests { error.contains("gRPC") || error.contains("connection") || error.contains("connect"), "expected gRPC connection error, got: {error}" ); + let body = result.result.expect("trigger_pipe failure result body"); + assert_eq!(body["target_response"]["transport"], "grpc"); + assert_eq!(body["target_response"]["delivered"], false); + assert_eq!(body["target_response"]["status"], Value::Null); } #[tokio::test] async fn handle_trigger_pipe_routes_grpcs_target() { let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "pipe-grpcs-1".into(), @@ -278,7 +343,7 @@ mod trigger_pipe_handler_tests { trigger_type: "manual".into(), }; - let result = handle_trigger_pipe(&agent_cmd, &data) + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) .await .expect("trigger_pipe should return structured result"); @@ -289,11 +354,16 @@ mod trigger_pipe_handler_tests { error.contains("gRPC") || error.contains("connection") || error.contains("connect"), "expected gRPC connection error for grpcs://, got: {error}" ); + let body = result.result.expect("trigger_pipe failure result body"); + assert_eq!(body["target_response"]["transport"], "grpc"); + assert_eq!(body["target_response"]["delivered"], false); + assert_eq!(body["target_response"]["status"], Value::Null); } #[tokio::test] async fn handle_trigger_pipe_grpc_rejects_empty_pipe_instance_id() { let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); let data = TriggerPipeCommand { deployment_hash: "dep-123".into(), pipe_instance_id: "".into(), @@ -309,7 +379,7 @@ mod trigger_pipe_handler_tests { trigger_type: "manual".into(), }; - let result = handle_trigger_pipe(&agent_cmd, &data) + let result = handle_trigger_pipe(&agent_cmd, &data, &pipe_runtime) .await .expect("trigger_pipe should return structured result"); @@ -320,6 +390,204 @@ mod trigger_pipe_handler_tests { "expected empty step_id error, got: {error}" ); } + + #[tokio::test] + async fn handle_activate_and_trigger_pipe_uses_registered_runtime_config() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/runtime/pipe") + .match_body(Matcher::Exact(r#"{"email":"runtime@try.direct"}"#.into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"accepted":true}"#) + .create_async() + .await; + + let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); + let activate = ActivatePipeCommand { + deployment_hash: "dep-123".into(), + pipe_instance_id: "pipe-runtime-1".into(), + source_container: None, + source_endpoint: "/".into(), + source_method: "GET".into(), + source_broker_url: None, + source_queue: None, + source_exchange: None, + source_routing_key: None, + target_url: Some(server.url()), + target_container: None, + target_endpoint: "/runtime/pipe".into(), + target_method: "POST".into(), + field_mapping: Some(json!({ "email": "$.user.email" })), + trigger_type: "manual".into(), + }; + + let activate_result = handle_activate_pipe(&agent_cmd, &activate, &pipe_runtime) + .await + .expect("activate_pipe should succeed"); + assert_eq!(activate_result.status, "success"); + assert_eq!( + activate_result + .result + .as_ref() + .and_then(|body| body.get("lifecycle")) + .and_then(|body| body.get("state")), + Some(&json!("active")) + ); + + let trigger = TriggerPipeCommand { + deployment_hash: "dep-123".into(), + pipe_instance_id: "pipe-runtime-1".into(), + input_data: Some(json!({ "user": { "email": "runtime@try.direct" } })), + source_container: None, + source_endpoint: "/".into(), + source_method: "GET".into(), + target_url: None, + target_container: None, + target_endpoint: "/".into(), + target_method: "POST".into(), + field_mapping: None, + trigger_type: "manual".into(), + }; + + let trigger_result = handle_trigger_pipe(&agent_cmd, &trigger, &pipe_runtime) + .await + .expect("trigger_pipe should use registered target"); + + mock.assert_async().await; + assert_eq!(trigger_result.status, "success"); + let stored = pipe_runtime.snapshot("dep-123", "pipe-runtime-1").await; + assert_eq!( + stored.and_then(|snapshot| snapshot.last_triggered_at), + trigger_result + .result + .as_ref() + .and_then(|body| body.get("triggered_at")) + .and_then(Value::as_str) + .map(str::to_string) + ); + assert_eq!( + trigger_result + .result + .as_ref() + .and_then(|body| body.get("lifecycle")) + .and_then(|body| body.get("trigger_count")), + Some(&json!(1)) + ); + } + + #[tokio::test] + async fn handle_deactivate_pipe_is_idempotent() { + let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); + let activate = ActivatePipeCommand { + deployment_hash: "dep-123".into(), + pipe_instance_id: "pipe-runtime-2".into(), + source_container: None, + source_endpoint: "/".into(), + source_method: "GET".into(), + source_broker_url: None, + source_queue: None, + source_exchange: None, + source_routing_key: None, + target_url: Some("https://example.com".into()), + target_container: None, + target_endpoint: "/runtime/pipe".into(), + target_method: "POST".into(), + field_mapping: None, + trigger_type: "manual".into(), + }; + let deactivate = DeactivatePipeCommand { + deployment_hash: "dep-123".into(), + pipe_instance_id: "pipe-runtime-2".into(), + }; + + handle_activate_pipe(&agent_cmd, &activate, &pipe_runtime) + .await + .expect("activate_pipe should succeed"); + let first = handle_deactivate_pipe(&agent_cmd, &deactivate, &pipe_runtime) + .await + .expect("deactivate_pipe should succeed"); + let second = handle_deactivate_pipe(&agent_cmd, &deactivate, &pipe_runtime) + .await + .expect("deactivate_pipe should stay idempotent"); + + assert_eq!(first.status, "success"); + assert_eq!(second.status, "success"); + assert_eq!( + first.result.as_ref().and_then(|body| body.get("removed")), + Some(&json!(true)) + ); + assert_eq!( + second.result.as_ref().and_then(|body| body.get("removed")), + Some(&json!(false)) + ); + assert_eq!( + second + .result + .as_ref() + .and_then(|body| body.get("lifecycle")) + .and_then(|body| body.get("state")), + Some(&json!("inactive")) + ); + } + + #[tokio::test] + async fn trigger_pipe_failure_updates_lifecycle_state() { + let agent_cmd = make_trigger_agent_command(); + let pipe_runtime = PipeRuntime::new(); + let activate = ActivatePipeCommand { + deployment_hash: "dep-123".into(), + pipe_instance_id: "pipe-runtime-3".into(), + source_container: None, + source_endpoint: "/".into(), + source_method: "GET".into(), + source_broker_url: None, + source_queue: None, + source_exchange: None, + source_routing_key: None, + target_url: Some("ws://127.0.0.1:19995".into()), + target_container: None, + target_endpoint: "/runtime/pipe".into(), + target_method: "POST".into(), + field_mapping: None, + trigger_type: "manual".into(), + }; + + handle_activate_pipe(&agent_cmd, &activate, &pipe_runtime) + .await + .expect("activate_pipe should succeed"); + + let trigger = TriggerPipeCommand { + deployment_hash: "dep-123".into(), + pipe_instance_id: "pipe-runtime-3".into(), + input_data: Some(json!({ "user": { "email": "runtime@try.direct" } })), + source_container: None, + source_endpoint: "/".into(), + source_method: "GET".into(), + target_url: None, + target_container: None, + target_endpoint: "/".into(), + target_method: "POST".into(), + field_mapping: None, + trigger_type: "manual".into(), + }; + + let trigger_result = handle_trigger_pipe(&agent_cmd, &trigger, &pipe_runtime) + .await + .expect("trigger_pipe should return structured failure"); + + assert_eq!(trigger_result.status, "failed"); + assert_eq!( + trigger_result + .result + .as_ref() + .and_then(|body| body.get("lifecycle")) + .and_then(|body| body.get("state")), + Some(&json!("failed")) + ); + } } impl std::fmt::Display for ContainerRuntime { @@ -423,6 +691,8 @@ pub enum StackerCommand { ListContainers(ListContainersCommand), ConfigureFirewall(ConfigureFirewallCommand), ProbeEndpoints(ProbeEndpointsCommand), + ActivatePipe(ActivatePipeCommand), + DeactivatePipe(DeactivatePipeCommand), TriggerPipe(TriggerPipeCommand), } @@ -510,6 +780,48 @@ pub struct ErrorSummaryCommand { redact: bool, } +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct ActivatePipeCommand { + #[serde(default)] + deployment_hash: String, + pipe_instance_id: String, + #[serde(default)] + source_container: Option, + #[serde(default = "default_pipe_source_endpoint")] + source_endpoint: String, + #[serde(default = "default_pipe_source_method")] + source_method: String, + #[serde(default)] + source_broker_url: Option, + #[serde(default)] + source_queue: Option, + #[serde(default)] + source_exchange: Option, + #[serde(default)] + source_routing_key: Option, + #[serde(default)] + target_url: Option, + #[serde(default)] + target_container: Option, + #[serde(default = "default_pipe_target_endpoint")] + target_endpoint: String, + #[serde(default = "default_pipe_target_method")] + target_method: String, + #[serde(default)] + field_mapping: Option, + #[serde(default = "default_activate_pipe_trigger_type")] + trigger_type: String, +} + +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct DeactivatePipeCommand { + #[serde(default)] + deployment_hash: String, + pipe_instance_id: String, +} + #[cfg_attr(not(feature = "docker"), allow(dead_code))] #[derive(Debug, Clone, Deserialize)] pub struct TriggerPipeCommand { @@ -562,111 +874,623 @@ fn normalize_trigger_pipe_method(method: &str, default_method: &str) -> String { } } +fn default_activate_pipe_trigger_type() -> String { + "webhook".to_string() +} + fn default_pipe_trigger_type() -> String { "manual".to_string() } -/// Command to fetch app configuration from Vault -#[cfg_attr(not(feature = "docker"), allow(dead_code))] -#[derive(Debug, Clone, Deserialize)] -pub struct FetchConfigCommand { - #[serde(default)] - deployment_hash: String, - #[serde(default)] - app_code: String, - /// If true, also write the config to the destination path - #[serde(default)] - apply: bool, -} +pub fn default_pipe_runtime_state_path(config_path: Option<&str>) -> Option { + if let Ok(path) = std::env::var("PIPE_RUNTIME_STATE_PATH") { + let trimmed = path.trim(); + if trimmed.is_empty() { + return None; + } + return Some(PathBuf::from(trimmed)); + } -/// Command to apply configuration from Vault to the filesystem and restart container -#[cfg_attr(not(feature = "docker"), allow(dead_code))] -#[derive(Debug, Clone, Deserialize)] -pub struct ApplyConfigCommand { - #[serde(default)] - deployment_hash: String, - #[serde(default)] - app_code: String, - /// Optional: override the config content (instead of fetching from Vault) - #[serde(default)] - config_content: Option, - /// Optional: override the destination path - #[serde(default)] - destination_path: Option, - /// Whether to restart the container after applying config - #[serde(default = "default_true")] - restart_after: bool, + if let Some(config_path) = config_path { + let config_path = PathBuf::from(config_path); + let base_dir = config_path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .map(|path| path.to_path_buf()) + .or_else(|| std::env::current_dir().ok()); + return base_dir.map(|dir| dir.join(".status").join("pipe-runtime-state.json")); + } + + std::env::current_dir() + .ok() + .map(|dir| dir.join(".status").join("pipe-runtime-state.json")) } -/// Command to deploy a new app container via docker compose -#[cfg_attr(not(feature = "docker"), allow(dead_code))] -#[derive(Debug, Clone, Deserialize)] -pub struct DeployAppCommand { - #[serde(default)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct PipeRuntimeKey { deployment_hash: String, - #[serde(default)] - app_code: String, - /// Optional: docker-compose.yml content (generated from J2 template) - /// If provided, will be written to disk before deploying - #[serde(default)] - compose_content: Option, - /// Optional: specific image to use (overrides compose file) - #[serde(default)] - image: Option, - /// Optional: environment variables to set - #[serde(default)] - env_vars: Option>, - /// Whether to pull the image before starting (default: true) - #[serde(default = "default_true")] - pull: bool, - /// Whether to remove existing container before deploying - #[serde(default)] - force_recreate: bool, - /// Optional: config files to write before deploying (uses existing AppConfig struct) - #[serde(default)] - config_files: Option>, - /// Container runtime to use: "runc" (default) or "kata" for microVM isolation - #[serde(default)] - runtime: Option, + pipe_instance_id: String, } -/// Command to remove an app container and associated config -#[cfg_attr(not(feature = "docker"), allow(dead_code))] -#[derive(Debug, Clone, Deserialize)] -pub struct RemoveAppCommand { - #[serde(default)] - deployment_hash: String, - #[serde(default)] - app_code: String, - #[serde(default = "default_true")] - delete_config: bool, - #[serde(default)] - remove_volumes: bool, - #[serde(default)] - remove_image: bool, +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum PipeLifecycleState { + Active, + Inactive, + Failed, } -/// Command to fetch all app configurations from Vault for a deployment -#[cfg_attr(not(feature = "docker"), allow(dead_code))] -#[derive(Debug, Clone, Deserialize)] -pub struct FetchAllConfigsCommand { - #[serde(default)] - deployment_hash: String, - /// Optional: specific app codes to fetch (if empty, fetches all) - #[serde(default)] - app_codes: Vec, - /// Whether to apply configs to disk after fetching - #[serde(default)] - apply: bool, - /// Whether to create a ZIP archive of all configs - #[serde(default)] - archive: bool, +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PipeLifecycleSnapshot { + state: PipeLifecycleState, + activated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + deactivated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_triggered_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + last_error: Option, + trigger_count: u64, + last_updated_at: String, } -/// Command to fetch configs and deploy an app in one operation -#[cfg_attr(not(feature = "docker"), allow(dead_code))] -#[derive(Debug, Clone, Deserialize)] -pub struct DeployWithConfigsCommand { +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PipeRegistration { + source_container: Option, + source_endpoint: String, + source_method: String, + source_broker_url: Option, + source_queue: Option, + source_exchange: Option, + source_routing_key: Option, + target_url: Option, + target_container: Option, + target_endpoint: String, + target_method: String, + field_mapping: Option, + trigger_type: String, + lifecycle: PipeLifecycleSnapshot, +} + +#[derive(Debug, Clone, Default)] +pub struct PipeRuntime { + registrations: Arc>>, + lifecycle: Arc>>, + workers: Arc>>, + state_path: Arc>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedPipeEntry { + deployment_hash: String, + pipe_instance_id: String, + registration: PipeRegistration, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct PersistedPipeRuntime { + entries: Vec, +} + +#[derive(Debug, Clone)] +struct ActivationResult { + replaced: bool, + registration: PipeRegistration, + previous_lifecycle: Option, +} + +#[derive(Debug, Clone)] +struct DeactivationResult { + removed: bool, + lifecycle: PipeLifecycleSnapshot, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PipeSourceWorkerKind { + Poll, + Websocket, + Grpc, + Amqp, +} + +impl PipeRuntime { + pub fn new() -> Self { + Self::default() + } + + pub async fn configure_persistence(&self, path: Option) { + let mut state_path = self.state_path.write().await; + *state_path = path; + } + + pub async fn restore_from_disk(&self) -> Result { + let Some(path) = self.state_path.read().await.clone() else { + return Ok(0); + }; + if !path.exists() { + return Ok(0); + } + + let body = tokio::fs::read_to_string(&path) + .await + .with_context(|| format!("reading pipe runtime state from {}", path.display()))?; + if body.trim().is_empty() { + return Ok(0); + } + + let persisted: PersistedPipeRuntime = serde_json::from_str(&body) + .with_context(|| format!("parsing pipe runtime state from {}", path.display()))?; + + { + let mut registrations = self.registrations.write().await; + let mut lifecycle = self.lifecycle.write().await; + registrations.clear(); + lifecycle.clear(); + for entry in &persisted.entries { + let key = PipeRuntimeKey { + deployment_hash: entry.deployment_hash.clone(), + pipe_instance_id: entry.pipe_instance_id.clone(), + }; + registrations.insert(key.clone(), entry.registration.clone()); + lifecycle.insert(key, entry.registration.lifecycle.clone()); + } + } + + for entry in &persisted.entries { + self.spawn_source_worker_if_needed( + &entry.deployment_hash, + &entry.pipe_instance_id, + entry.registration.clone(), + ) + .await; + } + + Ok(persisted.entries.len()) + } + + async fn persist_active_registrations(&self) -> Result<()> { + let Some(path) = self.state_path.read().await.clone() else { + return Ok(()); + }; + + let registrations = self.registrations.read().await; + let persisted = PersistedPipeRuntime { + entries: registrations + .iter() + .map(|(key, registration)| PersistedPipeEntry { + deployment_hash: key.deployment_hash.clone(), + pipe_instance_id: key.pipe_instance_id.clone(), + registration: redact_persisted_registration(registration), + }) + .collect(), + }; + drop(registrations); + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("creating pipe runtime state dir {}", parent.display()))?; + } + + let body = serde_json::to_vec_pretty(&persisted).context("serializing pipe runtime")?; + let mut options = tokio::fs::OpenOptions::new(); + options.create(true).write(true).truncate(true); + #[cfg(unix)] + options.mode(0o600); + + let mut file = options + .open(&path) + .await + .with_context(|| format!("opening pipe runtime state {}", path.display()))?; + file.write_all(&body) + .await + .with_context(|| format!("writing pipe runtime state to {}", path.display()))?; + file.flush() + .await + .with_context(|| format!("flushing pipe runtime state {}", path.display()))?; + #[cfg(unix)] + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .await + .with_context(|| format!("setting permissions on {}", path.display()))?; + Ok(()) + } + + async fn activate( + &self, + key: PipeRuntimeKey, + registration: PipeRegistration, + ) -> ActivationResult { + let (replaced, previous_lifecycle) = { + let mut registrations = self.registrations.write().await; + let mut lifecycle = self.lifecycle.write().await; + let previous_lifecycle = lifecycle.get(&key).cloned(); + let replaced = registrations + .insert(key.clone(), registration.clone()) + .is_some(); + lifecycle.insert(key, registration.lifecycle.clone()); + (replaced, previous_lifecycle) + }; + if let Err(error) = self.persist_active_registrations().await { + warn!(error = %error, "failed to persist active pipe registrations after activate"); + } + ActivationResult { + replaced, + registration, + previous_lifecycle, + } + } + + async fn deactivate( + &self, + deployment_hash: &str, + pipe_instance_id: &str, + deactivated_at: String, + ) -> DeactivationResult { + let key = PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }; + let (removed, snapshot) = { + let mut registrations = self.registrations.write().await; + let mut lifecycle = self.lifecycle.write().await; + let removed = registrations.remove(&key).is_some(); + let mut snapshot = lifecycle + .get(&key) + .cloned() + .unwrap_or(PipeLifecycleSnapshot { + state: PipeLifecycleState::Inactive, + activated_at: deactivated_at.clone(), + deactivated_at: None, + last_triggered_at: None, + last_error: None, + trigger_count: 0, + last_updated_at: deactivated_at.clone(), + }); + snapshot.state = PipeLifecycleState::Inactive; + snapshot.deactivated_at = Some(deactivated_at.clone()); + snapshot.last_updated_at = deactivated_at; + lifecycle.insert(key, snapshot.clone()); + (removed, snapshot) + }; + if let Err(error) = self.persist_active_registrations().await { + warn!(error = %error, "failed to persist active pipe registrations after deactivate"); + } + DeactivationResult { + removed, + lifecycle: snapshot, + } + } + + async fn resolve( + &self, + deployment_hash: &str, + pipe_instance_id: &str, + ) -> Option { + let registrations = self.registrations.read().await; + registrations + .get(&PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }) + .cloned() + } + + async fn mark_triggered( + &self, + deployment_hash: &str, + pipe_instance_id: &str, + triggered_at: String, + ) { + let mut registrations = self.registrations.write().await; + if let Some(registration) = registrations.get_mut(&PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }) { + registration.lifecycle.state = PipeLifecycleState::Active; + registration.lifecycle.last_triggered_at = Some(triggered_at.clone()); + registration.lifecycle.last_error = None; + registration.lifecycle.trigger_count += 1; + registration.lifecycle.last_updated_at = triggered_at.clone(); + } + drop(registrations); + + let mut lifecycle = self.lifecycle.write().await; + if let Some(snapshot) = lifecycle.get_mut(&PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }) { + snapshot.state = PipeLifecycleState::Active; + snapshot.last_triggered_at = Some(triggered_at.clone()); + snapshot.last_error = None; + snapshot.trigger_count += 1; + snapshot.last_updated_at = triggered_at; + } + } + + async fn mark_failed( + &self, + deployment_hash: &str, + pipe_instance_id: &str, + failed_at: String, + error: String, + ) { + let mut registrations = self.registrations.write().await; + if let Some(registration) = registrations.get_mut(&PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }) { + registration.lifecycle.state = PipeLifecycleState::Failed; + registration.lifecycle.last_error = Some(error.clone()); + registration.lifecycle.last_updated_at = failed_at.clone(); + } + drop(registrations); + + let mut lifecycle = self.lifecycle.write().await; + lifecycle + .entry(PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }) + .and_modify(|snapshot| { + snapshot.state = PipeLifecycleState::Failed; + snapshot.last_error = Some(error.clone()); + snapshot.last_updated_at = failed_at.clone(); + }) + .or_insert(PipeLifecycleSnapshot { + state: PipeLifecycleState::Failed, + activated_at: failed_at.clone(), + deactivated_at: None, + last_triggered_at: None, + last_error: Some(error), + trigger_count: 0, + last_updated_at: failed_at, + }); + } + + async fn snapshot( + &self, + deployment_hash: &str, + pipe_instance_id: &str, + ) -> Option { + let lifecycle = self.lifecycle.read().await; + lifecycle + .get(&PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }) + .cloned() + } + + async fn replace_worker(&self, key: PipeRuntimeKey, handle: AbortHandle) { + let mut workers = self.workers.write().await; + if let Some(existing) = workers.insert(key, handle) { + existing.abort(); + } + } + + async fn stop_worker(&self, deployment_hash: &str, pipe_instance_id: &str) { + let mut workers = self.workers.write().await; + if let Some(existing) = workers.remove(&PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }) { + existing.abort(); + } + } + + async fn spawn_source_worker_if_needed( + &self, + deployment_hash: &str, + pipe_instance_id: &str, + registration: PipeRegistration, + ) { + self.stop_worker(deployment_hash, pipe_instance_id).await; + + let Some(kind) = pipe_source_worker_kind(®istration.trigger_type) else { + return; + }; + + let runtime = self.clone(); + let key = PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + }; + let key_for_task = key.clone(); + let handle = tokio::spawn(async move { + match kind { + PipeSourceWorkerKind::Poll => { + run_poll_source_worker(runtime, key_for_task, registration).await + } + PipeSourceWorkerKind::Websocket => { + run_websocket_source_worker(runtime, key_for_task, registration).await + } + PipeSourceWorkerKind::Grpc => { + run_grpc_source_worker(runtime, key_for_task, registration).await + } + PipeSourceWorkerKind::Amqp => { + run_amqp_source_worker(runtime, key_for_task, registration).await + } + } + }); + + self.replace_worker(key, handle.abort_handle()).await; + } + + pub async fn trigger_registered_payload( + &self, + deployment_hash: &str, + pipe_instance_id: &str, + payload: Value, + trigger_type: &str, + ) -> Result { + let command_id = format!( + "pipe-{}-{}", + pipe_instance_id, + chrono::Utc::now().timestamp_millis() + ); + let agent_cmd = AgentCommand { + id: command_id.clone(), + command_id, + name: "trigger_pipe".to_string(), + params: json!({}), + deployment_hash: Some(deployment_hash.to_string()), + app_code: None, + }; + let trigger = TriggerPipeCommand { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + input_data: Some(payload), + source_container: None, + source_endpoint: default_pipe_source_endpoint(), + source_method: default_pipe_source_method(), + target_url: None, + target_container: None, + target_endpoint: default_pipe_target_endpoint(), + target_method: default_pipe_target_method(), + field_mapping: None, + trigger_type: trigger_type.to_string(), + }; + + handle_trigger_pipe(&agent_cmd, &trigger, self).await + } +} + +impl PipeLifecycleSnapshot { + fn active(activated_at: String) -> Self { + Self { + state: PipeLifecycleState::Active, + activated_at: activated_at.clone(), + deactivated_at: None, + last_triggered_at: None, + last_error: None, + trigger_count: 0, + last_updated_at: activated_at, + } + } +} + +impl From for PipeRegistration { + fn from(value: ActivatePipeCommand) -> Self { + Self { + source_container: value.source_container, + source_endpoint: value.source_endpoint, + source_method: value.source_method, + source_broker_url: value.source_broker_url, + source_queue: value.source_queue, + source_exchange: value.source_exchange, + source_routing_key: value.source_routing_key, + target_url: value.target_url, + target_container: value.target_container, + target_endpoint: value.target_endpoint, + target_method: value.target_method, + field_mapping: value.field_mapping, + trigger_type: value.trigger_type, + lifecycle: PipeLifecycleSnapshot::active(String::new()), + } + } +} + +/// Command to fetch app configuration from Vault +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct FetchConfigCommand { + #[serde(default)] + deployment_hash: String, + #[serde(default)] + app_code: String, + /// If true, also write the config to the destination path + #[serde(default)] + apply: bool, +} + +/// Command to apply configuration from Vault to the filesystem and restart container +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct ApplyConfigCommand { + #[serde(default)] + deployment_hash: String, + #[serde(default)] + app_code: String, + /// Optional: override the config content (instead of fetching from Vault) + #[serde(default)] + config_content: Option, + /// Optional: override the destination path + #[serde(default)] + destination_path: Option, + /// Whether to restart the container after applying config + #[serde(default = "default_true")] + restart_after: bool, +} + +/// Command to deploy a new app container via docker compose +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct DeployAppCommand { + #[serde(default)] + deployment_hash: String, + #[serde(default)] + app_code: String, + /// Optional: docker-compose.yml content (generated from J2 template) + /// If provided, will be written to disk before deploying + #[serde(default)] + compose_content: Option, + /// Optional: specific image to use (overrides compose file) + #[serde(default)] + image: Option, + /// Optional: environment variables to set + #[serde(default)] + env_vars: Option>, + /// Whether to pull the image before starting (default: true) + #[serde(default = "default_true")] + pull: bool, + /// Whether to remove existing container before deploying + #[serde(default)] + force_recreate: bool, + /// Optional: config files to write before deploying (uses existing AppConfig struct) + #[serde(default)] + config_files: Option>, + /// Container runtime to use: "runc" (default) or "kata" for microVM isolation + #[serde(default)] + runtime: Option, +} + +/// Command to remove an app container and associated config +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct RemoveAppCommand { + #[serde(default)] + deployment_hash: String, + #[serde(default)] + app_code: String, + #[serde(default = "default_true")] + delete_config: bool, + #[serde(default)] + remove_volumes: bool, + #[serde(default)] + remove_image: bool, +} + +/// Command to fetch all app configurations from Vault for a deployment +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct FetchAllConfigsCommand { + #[serde(default)] + deployment_hash: String, + /// Optional: specific app codes to fetch (if empty, fetches all) + #[serde(default)] + app_codes: Vec, + /// Whether to apply configs to disk after fetching + #[serde(default)] + apply: bool, + /// Whether to create a ZIP archive of all configs + #[serde(default)] + archive: bool, +} + +/// Command to fetch configs and deploy an app in one operation +#[cfg_attr(not(feature = "docker"), allow(dead_code))] +#[derive(Debug, Clone, Deserialize)] +pub struct DeployWithConfigsCommand { #[serde(default)] deployment_hash: String, #[serde(default)] @@ -990,6 +1814,20 @@ pub fn parse_stacker_command(cmd: &AgentCommand) -> Result { + let payload: ActivatePipeCommand = serde_json::from_value(unwrap_params(&cmd.params)) + .context("invalid activate_pipe payload")?; + let payload = payload.normalize().with_command_context(cmd); + payload.validate()?; + Ok(Some(StackerCommand::ActivatePipe(payload))) + } + "deactivate_pipe" | "stacker.deactivate_pipe" => { + let payload: DeactivatePipeCommand = serde_json::from_value(unwrap_params(&cmd.params)) + .context("invalid deactivate_pipe payload")?; + let payload = payload.normalize().with_command_context(cmd); + payload.validate()?; + Ok(Some(StackerCommand::DeactivatePipe(payload))) + } "trigger_pipe" | "stacker.trigger_pipe" => { let payload: TriggerPipeCommand = serde_json::from_value(unwrap_params(&cmd.params)) .context("invalid trigger_pipe payload")?; @@ -1005,6 +1843,7 @@ pub async fn execute_stacker_command( agent_cmd: &AgentCommand, command: &StackerCommand, firewall_policy: &firewall::FirewallPolicy, + pipe_runtime: &PipeRuntime, ) -> Result { // Firewall commands don't require Docker if let StackerCommand::ConfigureFirewall(data) = command { @@ -1013,12 +1852,25 @@ pub async fn execute_stacker_command( #[cfg(feature = "docker")] { - execute_with_docker(agent_cmd, command, firewall_policy).await + execute_with_docker(agent_cmd, command, firewall_policy, pipe_runtime).await } #[cfg(not(feature = "docker"))] { - let _ = (agent_cmd, command); - bail!("docker feature not enabled for stacker commands") + match command { + StackerCommand::ActivatePipe(data) => { + handle_activate_pipe(agent_cmd, data, pipe_runtime).await + } + StackerCommand::DeactivatePipe(data) => { + handle_deactivate_pipe(agent_cmd, data, pipe_runtime).await + } + StackerCommand::TriggerPipe(data) => { + handle_trigger_pipe(agent_cmd, data, pipe_runtime).await + } + _ => { + let _ = (firewall_policy, pipe_runtime); + bail!("docker feature not enabled for stacker commands") + } + } } } @@ -1304,7 +2156,7 @@ impl ErrorSummaryCommand { } } -impl TriggerPipeCommand { +impl ActivatePipeCommand { fn normalize(mut self) -> Self { self.deployment_hash = trimmed(&self.deployment_hash); self.pipe_instance_id = trimmed(&self.pipe_instance_id); @@ -1315,17 +2167,21 @@ impl TriggerPipeCommand { } self.source_method = normalize_trigger_pipe_method(&self.source_method, &default_pipe_source_method()); + self.source_broker_url = self.source_broker_url.map(|value| trimmed(&value)); + self.source_queue = self.source_queue.map(|value| trimmed(&value)); + self.source_exchange = self.source_exchange.map(|value| trimmed(&value)); + self.source_routing_key = self.source_routing_key.map(|value| trimmed(&value)); self.target_url = self.target_url.map(|value| trimmed(&value)); self.target_container = self.target_container.map(|value| trimmed(&value)); self.target_endpoint = trimmed(&self.target_endpoint); if self.target_endpoint.is_empty() { - self.target_endpoint = "/".to_string(); + self.target_endpoint = default_pipe_target_endpoint(); } self.target_method = normalize_trigger_pipe_method(&self.target_method, &default_pipe_target_method()); self.trigger_type = trimmed(&self.trigger_type).to_lowercase(); if self.trigger_type.is_empty() { - self.trigger_type = default_pipe_trigger_type(); + self.trigger_type = default_activate_pipe_trigger_type(); } self } @@ -1346,12 +2202,116 @@ impl TriggerPipeCommand { if self.pipe_instance_id.is_empty() { bail!("pipe_instance_id is required"); } - Ok(()) - } -} - -impl FetchConfigCommand { - fn normalize(mut self) -> Self { + if matches!(self.trigger_type.as_str(), "amqp" | "rabbitmq") { + if self + .source_broker_url + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() + { + bail!("activate_pipe with rabbitmq trigger_type requires source_broker_url"); + } + if self + .source_queue + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() + { + bail!("activate_pipe with rabbitmq trigger_type requires source_queue"); + } + } + if self + .target_url + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() + && self + .target_container + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() + { + bail!("activate_pipe requires target_url or target_container"); + } + Ok(()) + } +} + +impl DeactivatePipeCommand { + fn normalize(mut self) -> Self { + self.deployment_hash = trimmed(&self.deployment_hash); + self.pipe_instance_id = trimmed(&self.pipe_instance_id); + self + } + + fn with_command_context(mut self, agent_cmd: &AgentCommand) -> Self { + if self.deployment_hash.is_empty() { + if let Some(hash) = &agent_cmd.deployment_hash { + self.deployment_hash = hash.clone(); + } + } + self + } + + fn validate(&self) -> Result<()> { + if self.deployment_hash.is_empty() { + bail!("deployment_hash is required"); + } + if self.pipe_instance_id.is_empty() { + bail!("pipe_instance_id is required"); + } + Ok(()) + } +} + +impl TriggerPipeCommand { + fn normalize(mut self) -> Self { + self.deployment_hash = trimmed(&self.deployment_hash); + self.pipe_instance_id = trimmed(&self.pipe_instance_id); + self.source_container = self.source_container.map(|value| trimmed(&value)); + self.source_endpoint = trimmed(&self.source_endpoint); + if self.source_endpoint.is_empty() { + self.source_endpoint = default_pipe_source_endpoint(); + } + self.source_method = + normalize_trigger_pipe_method(&self.source_method, &default_pipe_source_method()); + self.target_url = self.target_url.map(|value| trimmed(&value)); + self.target_container = self.target_container.map(|value| trimmed(&value)); + self.target_endpoint = trimmed(&self.target_endpoint); + if self.target_endpoint.is_empty() { + self.target_endpoint = "/".to_string(); + } + self.target_method = + normalize_trigger_pipe_method(&self.target_method, &default_pipe_target_method()); + self.trigger_type = trimmed(&self.trigger_type).to_lowercase(); + if self.trigger_type.is_empty() { + self.trigger_type = default_pipe_trigger_type(); + } + self + } + + fn with_command_context(mut self, agent_cmd: &AgentCommand) -> Self { + if self.deployment_hash.is_empty() { + if let Some(hash) = &agent_cmd.deployment_hash { + self.deployment_hash = hash.clone(); + } + } + self + } + + fn validate(&self) -> Result<()> { + if self.deployment_hash.is_empty() { + bail!("deployment_hash is required"); + } + if self.pipe_instance_id.is_empty() { + bail!("pipe_instance_id is required"); + } + Ok(()) + } +} + +impl FetchConfigCommand { + fn normalize(mut self) -> Self { self.deployment_hash = trimmed(&self.deployment_hash); self.app_code = trimmed(&self.app_code); self @@ -1854,7 +2814,6 @@ pub fn build_compose_command(variant: ComposeVariant) -> (String, Vec) { } } -#[cfg(feature = "docker")] fn base_result( agent_cmd: &AgentCommand, deployment_hash: &str, @@ -1874,7 +2833,6 @@ fn base_result( } } -#[cfg(feature = "docker")] fn now_timestamp() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } @@ -1917,6 +2875,7 @@ async fn execute_with_docker( agent_cmd: &AgentCommand, command: &StackerCommand, firewall_policy: &firewall::FirewallPolicy, + pipe_runtime: &PipeRuntime, ) -> Result { match command { StackerCommand::Health(data) => handle_health(agent_cmd, data).await, @@ -1939,14 +2898,21 @@ async fn execute_with_docker( StackerCommand::ServerResources(data) => handle_server_resources(agent_cmd, data).await, StackerCommand::ListContainers(data) => handle_list_containers(agent_cmd, data).await, StackerCommand::ProbeEndpoints(data) => handle_probe_endpoints(agent_cmd, data).await, - StackerCommand::TriggerPipe(data) => handle_trigger_pipe(agent_cmd, data).await, + StackerCommand::ActivatePipe(data) => { + handle_activate_pipe(agent_cmd, data, pipe_runtime).await + } + StackerCommand::DeactivatePipe(data) => { + handle_deactivate_pipe(agent_cmd, data, pipe_runtime).await + } + StackerCommand::TriggerPipe(data) => { + handle_trigger_pipe(agent_cmd, data, pipe_runtime).await + } StackerCommand::ConfigureFirewall(data) => { firewall::handle_configure_firewall(agent_cmd, data, firewall_policy).await } } } -#[cfg(feature = "docker")] fn extract_json_path_value(source: &Value, path: &str) -> Value { let trimmed = path.trim(); if !trimmed.starts_with("$.") { @@ -1970,7 +2936,6 @@ fn extract_json_path_value(source: &Value, path: &str) -> Value { current.clone() } -#[cfg(feature = "docker")] fn apply_pipe_field_mapping(source: &Value, field_mapping: Option<&Value>) -> Value { let Some(Value::Object(mapping)) = field_mapping else { return source.clone(); @@ -1991,7 +2956,6 @@ fn apply_pipe_field_mapping(source: &Value, field_mapping: Option<&Value>) -> Va Value::Object(mapped) } -#[cfg(feature = "docker")] fn build_pipe_target_url(base: &str, endpoint: &str) -> String { let trimmed_base = base.trim_end_matches('/'); let trimmed_endpoint = endpoint.trim(); @@ -2005,6 +2969,158 @@ fn build_pipe_target_url(base: &str, endpoint: &str) -> String { ) } +fn pipe_source_worker_kind(trigger_type: &str) -> Option { + match trigger_type { + "poll" => Some(PipeSourceWorkerKind::Poll), + "websocket" | "ws" => Some(PipeSourceWorkerKind::Websocket), + "grpc" => Some(PipeSourceWorkerKind::Grpc), + "amqp" | "rabbitmq" => Some(PipeSourceWorkerKind::Amqp), + _ => None, + } +} + +fn pipe_source_poll_interval() -> Duration { + std::env::var("PIPE_POLL_INTERVAL_SECS") + .ok() + .and_then(|value| value.parse::().ok()) + .map(|secs| secs.max(1)) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(30)) +} + +fn pipe_source_retry_delay() -> Duration { + Duration::from_secs(5) +} + +fn trigger_pipe_target_transport(target_mode: &str, target_value: &str) -> &'static str { + match target_mode { + "container" => "container_http", + "external" if target_value.starts_with("ws://") || target_value.starts_with("wss://") => { + "websocket" + } + "external" + if target_value.starts_with("grpc://") || target_value.starts_with("grpcs://") => + { + "grpc" + } + _ => "http", + } +} + +fn build_trigger_pipe_target_response(transport: &str, status: Option, body: Value) -> Value { + json!({ + "transport": transport, + "status": status, + "delivered": status.map(|value| (200..300).contains(&value)).unwrap_or(false), + "body": body, + }) +} + +fn redact_persisted_registration(registration: &PipeRegistration) -> PipeRegistration { + let mut registration = registration.clone(); + registration.source_broker_url = registration + .source_broker_url + .as_deref() + .map(redact_url_credentials); + registration.target_url = registration + .target_url + .as_deref() + .map(redact_url_credentials); + registration +} + +fn redact_url_credentials(raw: &str) -> String { + let Some((scheme, remainder)) = raw.split_once("://") else { + return raw.to_string(); + }; + let authority_end = remainder.find('/').unwrap_or(remainder.len()); + let (authority, rest) = remainder.split_at(authority_end); + let Some((_, host)) = authority.rsplit_once('@') else { + return raw.to_string(); + }; + format!("{scheme}://***@{host}{rest}") +} + +fn registered_pipe_key(deployment_hash: &str, pipe_instance_id: &str) -> PipeRuntimeKey { + PipeRuntimeKey { + deployment_hash: deployment_hash.to_string(), + pipe_instance_id: pipe_instance_id.to_string(), + } +} + +fn trigger_has_inline_source(data: &TriggerPipeCommand) -> bool { + data.input_data.is_some() + || data + .source_container + .as_deref() + .filter(|value| !value.is_empty()) + .is_some() +} + +fn trigger_has_inline_target(data: &TriggerPipeCommand) -> bool { + data.target_url + .as_deref() + .filter(|value| !value.is_empty()) + .is_some() + || data + .target_container + .as_deref() + .filter(|value| !value.is_empty()) + .is_some() +} + +fn merge_trigger_with_registration( + data: &TriggerPipeCommand, + registration: Option<&PipeRegistration>, +) -> TriggerPipeCommand { + let mut merged = data.clone(); + if let Some(registration) = registration { + if merged + .source_container + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() + { + merged.source_container = registration.source_container.clone(); + } + if merged.source_endpoint == default_pipe_source_endpoint() { + merged.source_endpoint = registration.source_endpoint.clone(); + } + if merged.source_method == default_pipe_source_method() { + merged.source_method = registration.source_method.clone(); + } + if merged + .target_url + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() + { + merged.target_url = registration.target_url.clone(); + } + if merged + .target_container + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() + { + merged.target_container = registration.target_container.clone(); + } + if merged.target_endpoint == default_pipe_target_endpoint() { + merged.target_endpoint = registration.target_endpoint.clone(); + } + if merged.target_method == default_pipe_target_method() { + merged.target_method = registration.target_method.clone(); + } + if merged.field_mapping.is_none() { + merged.field_mapping = registration.field_mapping.clone(); + } + if merged.trigger_type == default_pipe_trigger_type() { + merged.trigger_type = registration.trigger_type.clone(); + } + } + merged +} + #[cfg(feature = "docker")] fn shell_escape_single_quotes(value: &str) -> String { value.replace('\'', r#"'\"'\"'"#) @@ -2034,7 +3150,6 @@ fn build_trigger_pipe_source_command(endpoint: &str, method: &str) -> String { ) } -#[cfg(feature = "docker")] async fn send_trigger_pipe_request( url: &str, method: &str, @@ -2068,6 +3183,34 @@ async fn send_trigger_pipe_request( Ok((status, body)) } +async fn fetch_external_pipe_source_request(url: &str, method: &str) -> Result<(u16, Value)> { + let method = reqwest::Method::from_bytes(method.as_bytes()) + .with_context(|| format!("invalid source_method '{}'", method))?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .context("building trigger_pipe source http client")?; + + let response = client + .request(method, url) + .send() + .await + .with_context(|| format!("fetching trigger_pipe source from {}", url))?; + + let status = response.status().as_u16(); + let body_text = response + .text() + .await + .context("reading trigger_pipe source response body")?; + let body = if body_text.trim().is_empty() { + Value::Null + } else { + serde_json::from_str(&body_text).unwrap_or(Value::String(body_text)) + }; + + Ok((status, body)) +} + #[cfg(feature = "docker")] async fn fetch_trigger_pipe_source_request( container: &str, @@ -2105,6 +3248,15 @@ async fn fetch_trigger_pipe_source_request( Ok((status, body)) } +#[cfg(not(feature = "docker"))] +async fn fetch_trigger_pipe_source_request( + _container: &str, + _endpoint: &str, + _method: &str, +) -> Result<(u16, Value)> { + bail!("source_container requires docker feature") +} + #[cfg(feature = "docker")] async fn send_trigger_pipe_container_request( container: &str, @@ -2143,50 +3295,531 @@ async fn send_trigger_pipe_container_request( Ok((status, body)) } -#[cfg(feature = "docker")] -async fn handle_trigger_pipe( - agent_cmd: &AgentCommand, - data: &TriggerPipeCommand, -) -> Result { - let mut result = base_result(agent_cmd, &data.deployment_hash, "", "trigger_pipe"); - let source_data = match data.input_data.clone() { - Some(value) => value, - None => match data - .source_container - .as_deref() - .filter(|value| !value.is_empty()) - { - Some(container) => match fetch_trigger_pipe_source_request( - container, - &data.source_endpoint, - &data.source_method, - ) - .await - { - Ok((status_code, response_body)) if (200..300).contains(&status_code) => { - response_body +#[cfg(not(feature = "docker"))] +async fn send_trigger_pipe_container_request( + _container: &str, + _endpoint: &str, + _method: &str, + _payload: &Value, +) -> Result<(u16, Value)> { + bail!("target_container requires docker feature") +} + +async fn run_poll_source_worker( + runtime: PipeRuntime, + key: PipeRuntimeKey, + registration: PipeRegistration, +) { + let interval = pipe_source_poll_interval(); + info!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + interval_secs = interval.as_secs(), + "pipe poll source worker started" + ); + + loop { + let fetched = match registration.source_container.as_deref() { + Some(container) if !container.is_empty() => { + fetch_trigger_pipe_source_request( + container, + ®istration.source_endpoint, + ®istration.source_method, + ) + .await + } + _ => { + fetch_external_pipe_source_request( + ®istration.source_endpoint, + ®istration.source_method, + ) + .await + } + }; + + match fetched { + Ok((status_code, payload)) if (200..300).contains(&status_code) => { + if let Err(error) = runtime + .trigger_registered_payload( + &key.deployment_hash, + &key.pipe_instance_id, + payload, + "poll", + ) + .await + { + warn!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + "poll source trigger failed" + ); } - Ok((status_code, response_body)) => { - let error = format!("source fetch failed with status {}", status_code); - result.status = "failed".into(); - result.result = Some(json!({ - "type": "trigger_pipe", - "deployment_hash": data.deployment_hash, - "pipe_instance_id": data.pipe_instance_id, - "success": false, - "source_data": response_body, - "mapped_data": Value::Null, - "target_response": Value::Null, - "error": error, - "triggered_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "trigger_type": data.trigger_type, - })); - result.error = Some(error); - return Ok(result); + } + Ok((status_code, _)) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("poll source failed with status {}", status_code), + ) + .await; + } + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("poll source error: {}", error), + ) + .await; + } + } + + tokio::time::sleep(interval).await; + } +} + +async fn run_websocket_source_worker( + runtime: PipeRuntime, + key: PipeRuntimeKey, + registration: PipeRegistration, +) { + info!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + source = %registration.source_endpoint, + "pipe websocket source worker started" + ); + + loop { + match crate::transport::websocket::ws_fetch_source(®istration.source_endpoint).await { + Ok(payload) => { + if let Err(error) = runtime + .trigger_registered_payload( + &key.deployment_hash, + &key.pipe_instance_id, + payload, + "websocket", + ) + .await + { + warn!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + "websocket source trigger failed" + ); } - Err(err) => { - let error = format!("failed to fetch trigger_pipe source: {}", err); - result.status = "failed".into(); + } + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("websocket source error: {}", error), + ) + .await; + debug!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + "websocket source worker will retry" + ); + tokio::time::sleep(pipe_source_retry_delay()).await; + } + } + } +} + +async fn run_grpc_source_worker( + runtime: PipeRuntime, + key: PipeRuntimeKey, + registration: PipeRegistration, +) { + let grpc_endpoint = if registration.source_endpoint.starts_with("grpcs://") { + registration + .source_endpoint + .replacen("grpcs://", "https://", 1) + } else { + registration + .source_endpoint + .replacen("grpc://", "http://", 1) + }; + + info!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + endpoint = %grpc_endpoint, + "pipe gRPC source worker started" + ); + + loop { + match crate::transport::grpc_client::grpc_fetch_source( + &grpc_endpoint, + &key.pipe_instance_id, + &key.pipe_instance_id, + ) + .await + { + Ok(payload) => { + if let Err(error) = runtime + .trigger_registered_payload( + &key.deployment_hash, + &key.pipe_instance_id, + payload, + "grpc", + ) + .await + { + warn!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + "gRPC source trigger failed" + ); + } + } + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("gRPC source error: {}", error), + ) + .await; + debug!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + "gRPC source worker will retry" + ); + tokio::time::sleep(pipe_source_retry_delay()).await; + } + } + } +} + +async fn run_amqp_source_worker( + runtime: PipeRuntime, + key: PipeRuntimeKey, + registration: PipeRegistration, +) { + let broker_url = registration.source_broker_url.clone().unwrap_or_default(); + let queue = registration.source_queue.clone().unwrap_or_default(); + let exchange = registration.source_exchange.clone().unwrap_or_default(); + let routing_key = registration.source_routing_key.clone().unwrap_or_default(); + + info!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + queue = %queue, + "pipe AMQP source worker started" + ); + + loop { + match Connection::connect(&broker_url, ConnectionProperties::default()).await { + Ok(connection) => match connection.create_channel().await { + Ok(channel) => { + if !exchange.is_empty() { + if let Err(error) = channel + .queue_bind( + &queue, + &exchange, + &routing_key, + QueueBindOptions::default(), + FieldTable::default(), + ) + .await + { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("AMQP queue bind failed: {}", error), + ) + .await; + tokio::time::sleep(pipe_source_retry_delay()).await; + continue; + } + } + + match channel + .basic_consume( + &queue, + &format!("status-panel-pipe-{}", key.pipe_instance_id), + BasicConsumeOptions::default(), + FieldTable::default(), + ) + .await + { + Ok(mut consumer) => { + while let Some(delivery) = consumer.next().await { + match delivery { + Ok(delivery) => { + let payload = + serde_json::from_slice::(&delivery.data) + .unwrap_or_else(|_| { + Value::String( + String::from_utf8_lossy(&delivery.data) + .to_string(), + ) + }); + if let Err(error) = runtime + .trigger_registered_payload( + &key.deployment_hash, + &key.pipe_instance_id, + payload, + "rabbitmq", + ) + .await + { + warn!( + deployment_hash = %key.deployment_hash, + pipe_instance_id = %key.pipe_instance_id, + error = %error, + "AMQP source trigger failed" + ); + } + if let Err(error) = + delivery.ack(BasicAckOptions::default()).await + { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("AMQP ack failed: {}", error), + ) + .await; + break; + } + } + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("AMQP consume failed: {}", error), + ) + .await; + break; + } + } + } + } + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("AMQP consumer setup failed: {}", error), + ) + .await; + } + } + } + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("AMQP channel creation failed: {}", error), + ) + .await; + } + }, + Err(error) => { + runtime + .mark_failed( + &key.deployment_hash, + &key.pipe_instance_id, + now_timestamp(), + format!("AMQP connection failed: {}", error), + ) + .await; + } + } + + tokio::time::sleep(pipe_source_retry_delay()).await; + } +} + +async fn handle_activate_pipe( + agent_cmd: &AgentCommand, + data: &ActivatePipeCommand, + pipe_runtime: &PipeRuntime, +) -> Result { + let mut result = base_result(agent_cmd, &data.deployment_hash, "", "activate_pipe"); + let activated_at = now_timestamp(); + let mut registration = PipeRegistration::from(data.clone()); + registration.lifecycle = PipeLifecycleSnapshot::active(activated_at); + + let activation = pipe_runtime + .activate( + registered_pipe_key(&data.deployment_hash, &data.pipe_instance_id), + registration, + ) + .await; + + result.result = Some(json!({ + "type": "activate_pipe", + "deployment_hash": data.deployment_hash, + "pipe_instance_id": data.pipe_instance_id, + "active": true, + "replaced": activation.replaced, + "reactivated": activation.previous_lifecycle.is_some(), + "trigger_type": data.trigger_type, + "lifecycle": activation.registration.lifecycle, + })); + + pipe_runtime + .spawn_source_worker_if_needed( + &data.deployment_hash, + &data.pipe_instance_id, + activation.registration, + ) + .await; + + Ok(result) +} + +async fn handle_deactivate_pipe( + agent_cmd: &AgentCommand, + data: &DeactivatePipeCommand, + pipe_runtime: &PipeRuntime, +) -> Result { + let mut result = base_result(agent_cmd, &data.deployment_hash, "", "deactivate_pipe"); + let deactivated_at = now_timestamp(); + let deactivation = pipe_runtime + .deactivate( + &data.deployment_hash, + &data.pipe_instance_id, + deactivated_at, + ) + .await; + pipe_runtime + .stop_worker(&data.deployment_hash, &data.pipe_instance_id) + .await; + + result.result = Some(json!({ + "type": "deactivate_pipe", + "deployment_hash": data.deployment_hash, + "pipe_instance_id": data.pipe_instance_id, + "active": false, + "removed": deactivation.removed, + "lifecycle": deactivation.lifecycle, + })); + + Ok(result) +} + +async fn handle_trigger_pipe( + agent_cmd: &AgentCommand, + data: &TriggerPipeCommand, + pipe_runtime: &PipeRuntime, +) -> Result { + let mut result = base_result(agent_cmd, &data.deployment_hash, "", "trigger_pipe"); + let registration = pipe_runtime + .resolve(&data.deployment_hash, &data.pipe_instance_id) + .await; + if registration.is_none() + && !trigger_has_inline_source(data) + && !trigger_has_inline_target(data) + { + let error = format!( + "pipe_instance_id {} is not active on this agent", + data.pipe_instance_id + ); + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.clone(), + ) + .await; + result.status = "failed".into(); + result.result = Some(json!({ + "type": "trigger_pipe", + "deployment_hash": data.deployment_hash, + "pipe_instance_id": data.pipe_instance_id, + "success": false, + "source_data": Value::Null, + "mapped_data": Value::Null, + "target_response": Value::Null, + "error": error, + "triggered_at": now_timestamp(), + "trigger_type": data.trigger_type, + })); + result.error = Some(error); + return Ok(result); + } + + let resolved = merge_trigger_with_registration(data, registration.as_ref()); + let source_data = match resolved.input_data.clone() { + Some(value) => value, + None => match resolved + .source_container + .as_deref() + .filter(|value| !value.is_empty()) + { + Some(container) => match fetch_trigger_pipe_source_request( + container, + &resolved.source_endpoint, + &resolved.source_method, + ) + .await + { + Ok((status_code, response_body)) if (200..300).contains(&status_code) => { + response_body + } + Ok((status_code, response_body)) => { + let error = format!("source fetch failed with status {}", status_code); + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.clone(), + ) + .await; + result.status = "failed".into(); + result.result = Some(json!({ + "type": "trigger_pipe", + "deployment_hash": data.deployment_hash, + "pipe_instance_id": data.pipe_instance_id, + "success": false, + "source_data": response_body, + "mapped_data": Value::Null, + "target_response": Value::Null, + "error": error, + "triggered_at": now_timestamp(), + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, + })); + result.error = Some(error); + return Ok(result); + } + Err(err) => { + let error = format!("failed to fetch trigger_pipe source: {}", err); + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.clone(), + ) + .await; + result.status = "failed".into(); result.result = Some(json!({ "type": "trigger_pipe", "deployment_hash": data.deployment_hash, @@ -2196,8 +3829,9 @@ async fn handle_trigger_pipe( "mapped_data": Value::Null, "target_response": Value::Null, "error": error, - "triggered_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "trigger_type": data.trigger_type, + "triggered_at": now_timestamp(), + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, })); result.error = Some(error); return Ok(result); @@ -2205,6 +3839,14 @@ async fn handle_trigger_pipe( }, None => { let error = "trigger_pipe requires input_data or source_container"; + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.to_string(), + ) + .await; result.status = "failed".into(); result.result = Some(json!({ "type": "trigger_pipe", @@ -2215,8 +3857,9 @@ async fn handle_trigger_pipe( "mapped_data": Value::Null, "target_response": Value::Null, "error": error, - "triggered_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "trigger_type": data.trigger_type, + "triggered_at": now_timestamp(), + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, })); result.error = Some(error.into()); return Ok(result); @@ -2224,20 +3867,32 @@ async fn handle_trigger_pipe( }, }; - let mapped_data = apply_pipe_field_mapping(&source_data, data.field_mapping.as_ref()); + let mapped_data = apply_pipe_field_mapping(&source_data, resolved.field_mapping.as_ref()); let target = match ( - data.target_url.as_deref().filter(|value| !value.is_empty()), - data.target_container + resolved + .target_url + .as_deref() + .filter(|value| !value.is_empty()), + resolved + .target_container .as_deref() .filter(|value| !value.is_empty()), ) { (Some(value), _) => Ok(( "external", - build_pipe_target_url(value, &data.target_endpoint), + build_pipe_target_url(value, &resolved.target_endpoint), )), (None, Some(value)) => Ok(("container", value.to_string())), (None, None) => { let error = "trigger_pipe requires target_url or target_container"; + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.to_string(), + ) + .await; result.status = "failed".into(); result.result = Some(json!({ "type": "trigger_pipe", @@ -2248,8 +3903,9 @@ async fn handle_trigger_pipe( "mapped_data": mapped_data, "target_response": Value::Null, "error": error, - "triggered_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "trigger_type": data.trigger_type, + "triggered_at": now_timestamp(), + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, })); result.error = Some(error.into()); Err(()) @@ -2259,6 +3915,7 @@ async fn handle_trigger_pipe( return Ok(result); } let (target_mode, target_value) = target.unwrap(); + let target_transport = trigger_pipe_target_transport(target_mode, &target_value); let send_result = match target_mode { "external" => { @@ -2288,14 +3945,15 @@ async fn handle_trigger_pipe( .map_err(|e| anyhow::anyhow!(e)) } } else { - send_trigger_pipe_request(&target_value, &data.target_method, &mapped_data).await + send_trigger_pipe_request(&target_value, &resolved.target_method, &mapped_data) + .await } } "container" => { send_trigger_pipe_container_request( &target_value, - &data.target_endpoint, - &data.target_method, + &resolved.target_endpoint, + &resolved.target_method, &mapped_data, ) .await @@ -2305,7 +3963,15 @@ async fn handle_trigger_pipe( match send_result { Ok((status_code, response_body)) if (200..300).contains(&status_code) => { - result.status = "completed".into(); + let triggered_at = now_timestamp(); + pipe_runtime + .mark_triggered( + &data.deployment_hash, + &data.pipe_instance_id, + triggered_at.clone(), + ) + .await; + result.status = "success".into(); result.result = Some(json!({ "type": "trigger_pipe", "deployment_hash": data.deployment_hash, @@ -2313,16 +3979,26 @@ async fn handle_trigger_pipe( "success": true, "source_data": source_data, "mapped_data": mapped_data, - "target_response": { - "status": status_code, - "body": response_body, - }, - "triggered_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "trigger_type": data.trigger_type, + "target_response": build_trigger_pipe_target_response( + target_transport, + Some(status_code), + response_body, + ), + "triggered_at": triggered_at, + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, })); } Ok((status_code, response_body)) => { let error = format!("target request failed with status {}", status_code); + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.clone(), + ) + .await; result.status = "failed".into(); result.result = Some(json!({ "type": "trigger_pipe", @@ -2331,18 +4007,28 @@ async fn handle_trigger_pipe( "success": false, "source_data": source_data, "mapped_data": mapped_data, - "target_response": { - "status": status_code, - "body": response_body, - }, + "target_response": build_trigger_pipe_target_response( + target_transport, + Some(status_code), + response_body, + ), "error": error, - "triggered_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "trigger_type": data.trigger_type, + "triggered_at": now_timestamp(), + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, })); result.error = Some(error); } Err(err) => { let error = err.to_string(); + pipe_runtime + .mark_failed( + &data.deployment_hash, + &data.pipe_instance_id, + now_timestamp(), + error.clone(), + ) + .await; result.status = "failed".into(); result.result = Some(json!({ "type": "trigger_pipe", @@ -2351,10 +4037,15 @@ async fn handle_trigger_pipe( "success": false, "source_data": source_data, "mapped_data": mapped_data, - "target_response": Value::Null, + "target_response": build_trigger_pipe_target_response( + target_transport, + None, + Value::Null, + ), "error": error, - "triggered_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - "trigger_type": data.trigger_type, + "triggered_at": now_timestamp(), + "trigger_type": resolved.trigger_type, + "lifecycle": pipe_runtime.snapshot(&data.deployment_hash, &data.pipe_instance_id).await, })); result.error = Some(error); } @@ -5706,6 +7397,74 @@ async fn handle_probe_endpoints( mod tests { use super::*; use serde_json::json; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + use tempfile::tempdir; + + fn fixture_path(path: &str) -> PathBuf { + let relative_path = match path { + "activate_pipe.webhook.command.json" => { + "../shared-fixtures/pipe-contract/activate_pipe.webhook.command.json" + } + "activate_pipe.rabbitmq.command.json" => { + "../shared-fixtures/pipe-contract/activate_pipe.rabbitmq.command.json" + } + "deactivate_pipe.command.json" => { + "../shared-fixtures/pipe-contract/deactivate_pipe.command.json" + } + "trigger_pipe.manual.command.json" => { + "../shared-fixtures/pipe-contract/trigger_pipe.manual.command.json" + } + "trigger_pipe.replay.command.json" => { + "../shared-fixtures/pipe-contract/trigger_pipe.replay.command.json" + } + other => panic!("unknown fixture: {}", other), + }; + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative_path) + } + + fn shared_fixtures_available() -> bool { + fixture_path("activate_pipe.webhook.command.json").exists() + } + + fn fixture(path: &str) -> Value { + let fixture_path = fixture_path(path); + let body = std::fs::read_to_string(&fixture_path).unwrap_or_else(|error| { + panic!( + "failed to read fixture {} at {}: {}", + path, + fixture_path.display(), + error + ) + }); + + serde_json::from_str(&body).expect("fixture should be valid json") + } + + struct EnvGuard { + vars: Vec<(String, Option)>, + } + + impl EnvGuard { + fn new(keys: &[&str]) -> Self { + let vars = keys + .iter() + .map(|k| (k.to_string(), std::env::var(k).ok())) + .collect(); + Self { vars } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, original) in &self.vars { + match original { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } macro_rules! stacker_test { ($name:ident, $cmd_name:expr, $payload:expr, $variant:path) => { @@ -5728,6 +7487,375 @@ mod tests { }; } + #[test] + fn parses_activate_pipe_shared_webhook_fixture() { + if !shared_fixtures_available() { + eprintln!("skipping shared fixture test: shared fixtures are unavailable"); + return; + } + let cmd = AgentCommand { + id: "cmd-activate-fixture".into(), + command_id: "cmd-activate-fixture".into(), + name: "activate_pipe".into(), + params: json!({ "params": fixture("activate_pipe.webhook.command.json") }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::ActivatePipe(data)) => { + assert_eq!(data.deployment_hash, "dep-123"); + assert_eq!(data.source_container.as_deref(), Some("source-app")); + assert_eq!(data.trigger_type, "webhook"); + } + other => panic!("Expected ActivatePipe command, got {:?}", other), + } + } + + #[test] + fn parses_activate_pipe_shared_rabbitmq_fixture() { + if !shared_fixtures_available() { + eprintln!("skipping shared fixture test: shared fixtures are unavailable"); + return; + } + let cmd = AgentCommand { + id: "cmd-activate-rabbit-fixture".into(), + command_id: "cmd-activate-rabbit-fixture".into(), + name: "activate_pipe".into(), + params: json!({ "params": fixture("activate_pipe.rabbitmq.command.json") }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::ActivatePipe(data)) => { + assert_eq!(data.deployment_hash, "dep-123"); + assert_eq!(data.trigger_type, "rabbitmq"); + assert_eq!(data.source_queue.as_deref(), Some("events.queue")); + } + other => panic!("Expected ActivatePipe command, got {:?}", other), + } + } + + #[test] + fn parses_deactivate_pipe_shared_fixture() { + if !shared_fixtures_available() { + eprintln!("skipping shared fixture test: shared fixtures are unavailable"); + return; + } + let cmd = AgentCommand { + id: "cmd-deactivate-fixture".into(), + command_id: "cmd-deactivate-fixture".into(), + name: "deactivate_pipe".into(), + params: json!({ "params": fixture("deactivate_pipe.command.json") }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::DeactivatePipe(data)) => { + assert_eq!( + data.pipe_instance_id, + "11111111-1111-1111-1111-111111111111" + ); + } + other => panic!("Expected DeactivatePipe command, got {:?}", other), + } + } + + #[test] + fn parses_trigger_pipe_shared_manual_fixture() { + if !shared_fixtures_available() { + eprintln!("skipping shared fixture test: shared fixtures are unavailable"); + return; + } + let cmd = AgentCommand { + id: "cmd-trigger-fixture".into(), + command_id: "cmd-trigger-fixture".into(), + name: "trigger_pipe".into(), + params: json!({ "params": fixture("trigger_pipe.manual.command.json") }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::TriggerPipe(data)) => { + assert_eq!(data.trigger_type, "manual"); + assert_eq!(data.target_url.as_deref(), Some("https://example.com")); + } + other => panic!("Expected TriggerPipe command, got {:?}", other), + } + } + + #[test] + fn parses_trigger_pipe_shared_replay_fixture() { + if !shared_fixtures_available() { + eprintln!("skipping shared fixture test: shared fixtures are unavailable"); + return; + } + let cmd = AgentCommand { + id: "cmd-trigger-replay-fixture".into(), + command_id: "cmd-trigger-replay-fixture".into(), + name: "trigger_pipe".into(), + params: json!({ "params": fixture("trigger_pipe.replay.command.json") }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::TriggerPipe(data)) => { + assert_eq!(data.trigger_type, "replay"); + assert_eq!(data.input_data, Some(json!({ "invoice_id": "inv-replay" }))); + } + other => panic!("Expected TriggerPipe command, got {:?}", other), + } + } + + #[tokio::test] + async fn pipe_runtime_persists_and_restores_active_registration() { + let dir = tempdir().unwrap(); + let state_path = dir.path().join("pipe-runtime.json"); + + let runtime = PipeRuntime::new(); + runtime + .configure_persistence(Some(state_path.clone())) + .await; + + let mut registration = PipeRegistration::from(ActivatePipeCommand { + deployment_hash: "dep-restore".into(), + pipe_instance_id: "pipe-restore-1".into(), + source_container: Some("source-app".into()), + source_endpoint: "/source".into(), + source_method: "GET".into(), + source_broker_url: None, + source_queue: None, + source_exchange: None, + source_routing_key: None, + target_url: Some("https://example.com".into()), + target_container: None, + target_endpoint: "/runtime/pipe".into(), + target_method: "POST".into(), + field_mapping: Some(json!({ "email": "$.user.email" })), + trigger_type: "webhook".into(), + }); + registration.lifecycle = PipeLifecycleSnapshot::active("2026-01-01T00:00:00Z".into()); + + runtime + .activate( + PipeRuntimeKey { + deployment_hash: "dep-restore".into(), + pipe_instance_id: "pipe-restore-1".into(), + }, + registration, + ) + .await; + + let restored = PipeRuntime::new(); + restored.configure_persistence(Some(state_path)).await; + let count = restored.restore_from_disk().await.unwrap(); + + assert_eq!(count, 1); + let registration = restored + .resolve("dep-restore", "pipe-restore-1") + .await + .expect("registration should restore"); + assert_eq!( + registration.target_url.as_deref(), + Some("https://example.com") + ); + assert_eq!(registration.trigger_type, "webhook"); + + let snapshot = restored + .snapshot("dep-restore", "pipe-restore-1") + .await + .expect("lifecycle should restore"); + assert_eq!(snapshot.state, PipeLifecycleState::Active); + assert_eq!(snapshot.activated_at, "2026-01-01T00:00:00Z"); + } + + #[tokio::test] + async fn pipe_runtime_deactivate_removes_persisted_registration() { + let dir = tempdir().unwrap(); + let state_path = dir.path().join("pipe-runtime.json"); + + let runtime = PipeRuntime::new(); + runtime + .configure_persistence(Some(state_path.clone())) + .await; + + let mut registration = PipeRegistration::from(ActivatePipeCommand { + deployment_hash: "dep-deactivate".into(), + pipe_instance_id: "pipe-deactivate-1".into(), + source_container: Some("source-app".into()), + source_endpoint: "/source".into(), + source_method: "GET".into(), + source_broker_url: None, + source_queue: None, + source_exchange: None, + source_routing_key: None, + target_url: Some("https://example.com".into()), + target_container: None, + target_endpoint: "/runtime/pipe".into(), + target_method: "POST".into(), + field_mapping: None, + trigger_type: "webhook".into(), + }); + registration.lifecycle = PipeLifecycleSnapshot::active("2026-01-01T00:00:00Z".into()); + + runtime + .activate( + PipeRuntimeKey { + deployment_hash: "dep-deactivate".into(), + pipe_instance_id: "pipe-deactivate-1".into(), + }, + registration, + ) + .await; + + runtime + .deactivate( + "dep-deactivate", + "pipe-deactivate-1", + "2026-01-01T00:05:00Z".into(), + ) + .await; + + let restored = PipeRuntime::new(); + restored.configure_persistence(Some(state_path)).await; + let count = restored.restore_from_disk().await.unwrap(); + + assert_eq!(count, 0); + assert!(restored + .resolve("dep-deactivate", "pipe-deactivate-1") + .await + .is_none()); + } + + #[tokio::test] + async fn pipe_runtime_restore_restarts_poll_worker() { + let dir = tempdir().unwrap(); + let state_path = dir.path().join("pipe-runtime.json"); + + let runtime = PipeRuntime::new(); + runtime + .configure_persistence(Some(state_path.clone())) + .await; + + let mut registration = PipeRegistration::from(ActivatePipeCommand { + deployment_hash: "dep-poll".into(), + pipe_instance_id: "pipe-poll-1".into(), + source_container: None, + source_endpoint: "http://127.0.0.1:1/source".into(), + source_method: "GET".into(), + source_broker_url: None, + source_queue: None, + source_exchange: None, + source_routing_key: None, + target_url: Some("https://example.com".into()), + target_container: None, + target_endpoint: "/runtime/pipe".into(), + target_method: "POST".into(), + field_mapping: None, + trigger_type: "poll".into(), + }); + registration.lifecycle = PipeLifecycleSnapshot::active("2026-01-01T00:00:00Z".into()); + + runtime + .activate( + PipeRuntimeKey { + deployment_hash: "dep-poll".into(), + pipe_instance_id: "pipe-poll-1".into(), + }, + registration, + ) + .await; + + let restored = PipeRuntime::new(); + restored.configure_persistence(Some(state_path)).await; + let count = restored.restore_from_disk().await.unwrap(); + assert_eq!(count, 1); + + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + let workers = restored.workers.read().await; + assert_eq!(workers.len(), 1); + drop(workers); + restored.stop_worker("dep-poll", "pipe-poll-1").await; + } + + #[test] + fn default_pipe_runtime_state_path_uses_config_directory() { + let path = default_pipe_runtime_state_path(Some("/tmp/status/config.json")).unwrap(); + assert_eq!( + path, + PathBuf::from("/tmp/status/.status/pipe-runtime-state.json") + ); + } + + #[test] + fn pipe_source_poll_interval_clamps_zero_to_one_second() { + let _env = EnvGuard::new(&["PIPE_POLL_INTERVAL_SECS"]); + std::env::set_var("PIPE_POLL_INTERVAL_SECS", "0"); + + assert_eq!(pipe_source_poll_interval(), Duration::from_secs(1)); + } + + #[tokio::test] + async fn pipe_runtime_persistence_redacts_credentials() { + let dir = tempdir().unwrap(); + let state_path = dir.path().join("pipe-runtime.json"); + + let runtime = PipeRuntime::new(); + runtime + .configure_persistence(Some(state_path.clone())) + .await; + + let mut registration = PipeRegistration::from(ActivatePipeCommand { + deployment_hash: "dep-secret".into(), + pipe_instance_id: "pipe-secret-1".into(), + source_container: None, + source_endpoint: "/source".into(), + source_method: "GET".into(), + source_broker_url: Some("amqp://guest:guest@localhost:5672/%2f".into()), + source_queue: Some("events.queue".into()), + source_exchange: Some("events.exchange".into()), + source_routing_key: Some("events.created".into()), + target_url: Some("https://user:token@example.com/hooks".into()), + target_container: None, + target_endpoint: "/runtime/pipe".into(), + target_method: "POST".into(), + field_mapping: None, + trigger_type: "rabbitmq".into(), + }); + registration.lifecycle = PipeLifecycleSnapshot::active("2026-01-01T00:00:00Z".into()); + + runtime + .activate( + PipeRuntimeKey { + deployment_hash: "dep-secret".into(), + pipe_instance_id: "pipe-secret-1".into(), + }, + registration, + ) + .await; + + let body = tokio::fs::read_to_string(&state_path).await.unwrap(); + assert!(!body.contains("guest:guest")); + assert!(!body.contains("user:token")); + assert!(body.contains("amqp://***@localhost:5672/%2f")); + assert!(body.contains("https://***@example.com/hooks")); + + #[cfg(unix)] + { + let mode = std::fs::metadata(&state_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + } + stacker_test!( parses_health_command, "health", @@ -5875,6 +8003,53 @@ mod tests { }), StackerCommand::ListContainers ); + stacker_test!( + parses_activate_pipe_command, + "activate_pipe", + json!({ + "params": { + "pipe_instance_id": "11111111-1111-1111-1111-111111111111", + "target_url": "https://example.com" + } + }), + StackerCommand::ActivatePipe + ); + + #[test] + fn activate_pipe_defaults_trigger_type_to_webhook() { + let cmd = AgentCommand { + id: "cmd-activate-default".into(), + command_id: "cmd-activate-default".into(), + name: "activate_pipe".into(), + params: json!({ + "params": { + "pipe_instance_id": "11111111-1111-1111-1111-111111111111", + "target_url": "https://example.com" + } + }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::ActivatePipe(data)) => { + assert_eq!(data.trigger_type, "webhook"); + } + other => panic!("Expected ActivatePipe command, got {:?}", other), + } + } + + stacker_test!( + parses_deactivate_pipe_command, + "deactivate_pipe", + json!({ + "params": { + "pipe_instance_id": "11111111-1111-1111-1111-111111111111" + } + }), + StackerCommand::DeactivatePipe + ); stacker_test!( parses_trigger_pipe_command, "trigger_pipe", @@ -6013,6 +8188,104 @@ mod tests { } } + #[test] + fn parses_activate_pipe_fields() { + let cmd = AgentCommand { + id: "cmd-activate".into(), + command_id: "cmd-activate".into(), + name: "activate_pipe".into(), + params: json!({ + "params": { + "pipe_instance_id": "11111111-1111-1111-1111-111111111111", + "target_url": "https://example.com", + "target_endpoint": "/runtime/pipe", + "target_method": "post", + "field_mapping": { "email": "$.user.email" }, + "trigger_type": "webhook" + } + }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::ActivatePipe(data)) => { + assert_eq!(data.deployment_hash, "dep-123"); + assert_eq!(data.target_url.as_deref(), Some("https://example.com")); + assert_eq!(data.target_endpoint, "/runtime/pipe"); + assert_eq!(data.target_method, "POST"); + assert_eq!(data.trigger_type, "webhook"); + } + other => panic!("Expected ActivatePipe command, got {:?}", other), + } + } + + #[test] + fn parses_activate_pipe_rabbitmq_fields() { + let cmd = AgentCommand { + id: "cmd-activate-amqp".into(), + command_id: "cmd-activate-amqp".into(), + name: "activate_pipe".into(), + params: json!({ + "params": { + "pipe_instance_id": "pipe-amqp-1", + "source_broker_url": "amqp://guest:guest@localhost:5672/%2f", + "source_queue": "events.queue", + "source_exchange": "events.exchange", + "source_routing_key": "events.created", + "target_url": "https://example.com", + "trigger_type": "rabbitmq" + } + }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::ActivatePipe(data)) => { + assert_eq!(data.trigger_type, "rabbitmq"); + assert_eq!( + data.source_broker_url.as_deref(), + Some("amqp://guest:guest@localhost:5672/%2f") + ); + assert_eq!(data.source_queue.as_deref(), Some("events.queue")); + assert_eq!(data.source_exchange.as_deref(), Some("events.exchange")); + assert_eq!(data.source_routing_key.as_deref(), Some("events.created")); + } + other => panic!("Expected ActivatePipe command, got {:?}", other), + } + } + + #[test] + fn parses_deactivate_pipe_fields() { + let cmd = AgentCommand { + id: "cmd-deactivate".into(), + command_id: "cmd-deactivate".into(), + name: "deactivate_pipe".into(), + params: json!({ + "params": { + "pipe_instance_id": "11111111-1111-1111-1111-111111111111" + } + }), + deployment_hash: Some("dep-123".into()), + app_code: None, + }; + + let parsed = parse_stacker_command(&cmd).unwrap(); + match parsed { + Some(StackerCommand::DeactivatePipe(data)) => { + assert_eq!(data.deployment_hash, "dep-123"); + assert_eq!( + data.pipe_instance_id, + "11111111-1111-1111-1111-111111111111" + ); + } + other => panic!("Expected DeactivatePipe command, got {:?}", other), + } + } + // --- ContainerRuntime tests --- #[test] diff --git a/src/comms/local_api.rs b/src/comms/local_api.rs index ea273f6..5f30d23 100644 --- a/src/comms/local_api.rs +++ b/src/comms/local_api.rs @@ -42,7 +42,7 @@ use crate::commands::{ check_remote_version, get_update_status, start_update_job, UpdateJobs, UpdatePhase, }; use crate::commands::{ - execute_stacker_command, parse_stacker_command, CommandValidator, DockerOperation, + execute_stacker_command, parse_stacker_command, CommandValidator, DockerOperation, PipeRuntime, TimeoutStrategy, }; use crate::comms::notifications::{self, MarkReadRequest, NotificationStore, UnreadCountResponse}; @@ -127,6 +127,7 @@ pub struct AppState { pub firewall_policy: FirewallPolicy, pub login_limiter: RateLimiter, pub notification_store: NotificationStore, + pub pipe_runtime: PipeRuntime, } impl AppState { @@ -193,6 +194,7 @@ impl AppState { firewall_policy, login_limiter: RateLimiter::new_per_minute(5), notification_store: notifications::new_notification_store(), + pipe_runtime: PipeRuntime::new(), } } } @@ -1129,6 +1131,10 @@ async fn capabilities_handler(State(state): State) -> impl IntoResp features.push("logs".to_string()); features.push("restart".to_string()); } + features.push("pipes".to_string()); + features.push("activate_pipe".to_string()); + features.push("deactivate_pipe".to_string()); + features.push("trigger_pipe".to_string()); if compose_agent { features.push("compose_agent".to_string()); } @@ -1502,6 +1508,11 @@ pub fn create_router(state: SharedState) -> Router { .route("/api/v1/commands/enqueue", post(commands_enqueue)) .route("/api/v1/auth/rotate-token", post(rotate_token)); + router = router.route( + "/api/v1/pipes/webhook/{deployment_hash}/{pipe_instance_id}", + post(pipe_webhook_ingest), + ); + // Marketplace & dashboard linking router = router .route("/marketplace", get(marketplace_page)) @@ -1969,7 +1980,14 @@ async fn commands_execute( ) .to_string(); if let Some(stacker_cmd) = parsed_stacker_cmd { - match execute_stacker_command(&cmd, &stacker_cmd, &state.firewall_policy).await { + match execute_stacker_command( + &cmd, + &stacker_cmd, + &state.firewall_policy, + &state.pipe_runtime, + ) + .await + { Ok(result) => { return Json(attach_command_provenance(&state, result, &executed_by).await) .into_response(); @@ -2104,6 +2122,41 @@ async fn commands_enqueue( (StatusCode::ACCEPTED, Json(json!({"queued": true}))).into_response() } +async fn pipe_webhook_ingest( + State(state): State, + Path((deployment_hash, pipe_instance_id)): Path<(String, String)>, + headers: HeaderMap, + body: Bytes, +) -> impl IntoResponse { + if let Err(resp) = verify_stacker_post(&state, &headers, &body, "commands:execute").await { + return resp.into_response(); + } + + let payload: serde_json::Value = match serde_json::from_slice(&body) { + Ok(value) => value, + Err(error) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": format!("invalid webhook payload: {}", error)})), + ) + .into_response() + } + }; + + match state + .pipe_runtime + .trigger_registered_payload(&deployment_hash, &pipe_instance_id, payload, "webhook") + .await + { + Ok(result) => Json(result).into_response(), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": error.to_string()})), + ) + .into_response(), + } +} + #[derive(Deserialize)] struct RotateTokenRequest { new_token: String, @@ -2151,9 +2204,24 @@ pub fn default_bind_address(bind: Option) -> std::net::Ipv4Addr { } } -pub async fn serve(config: Config, port: u16, with_ui: bool) -> Result<()> { +pub async fn serve(config: Config, config_path: &str, port: u16, with_ui: bool) -> Result<()> { let cfg = Arc::new(config); let state = Arc::new(AppState::new(cfg, with_ui, Some(port))); + state + .pipe_runtime + .configure_persistence(crate::commands::default_pipe_runtime_state_path(Some( + config_path, + ))) + .await; + match state.pipe_runtime.restore_from_disk().await { + Ok(restored) if restored > 0 => { + info!(restored, "restored persisted pipe runtime registrations"); + } + Ok(_) => {} + Err(error) => { + error!(error = %error, "failed to restore persisted pipe runtime registrations"); + } + } // Spawn token refresh task if Vault is configured if let (Some(vault_client), Some(token_cache)) = (&state.vault_client, &state.token_cache) { @@ -2330,4 +2398,23 @@ mod tests { Value::String("status_panel".to_string()) ); } + + #[tokio::test] + async fn capabilities_include_pipe_operations() { + let state = test_state(Some("status_panel")); + + let response = capabilities_handler(State(state)).await.into_response(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("capabilities body"); + let payload: Value = serde_json::from_slice(&body).expect("capabilities json"); + let features = payload["features"] + .as_array() + .expect("features should be an array"); + + assert!(features.contains(&Value::String("pipes".to_string()))); + assert!(features.contains(&Value::String("activate_pipe".to_string()))); + assert!(features.contains(&Value::String("deactivate_pipe".to_string()))); + assert!(features.contains(&Value::String("trigger_pipe".to_string()))); + } } diff --git a/src/main.rs b/src/main.rs index 75189e7..bdad5a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ use tracing::info; /// Application version from Cargo.toml const VERSION: &str = env!("CARGO_PKG_VERSION"); +const DISPLAY_VERSION: &str = env!("STATUS_DISPLAY_VERSION"); const PKG_NAME: &str = env!("CARGO_PKG_NAME"); /// Check that `path` points to a readable file. Prints a friendly error and @@ -161,7 +162,7 @@ fn print_banner() { } #[derive(Parser)] -#[command(name = "status", version, about = "")] +#[command(name = "status", version = DISPLAY_VERSION, about = "")] struct AppCli { /// Run in daemon mode (background) #[arg(long)] @@ -180,6 +181,13 @@ struct AppCli { command: Option, } +fn is_direct_version_request() -> bool { + matches!( + std::env::args().skip(1).collect::>().as_slice(), + [flag] if flag == "--version" || flag == "-V" + ) +} + #[derive(Subcommand)] enum Commands { /// Start HTTP server (local API) @@ -281,14 +289,17 @@ fn run_daemon() -> Result<()> { #[tokio::main] async fn main() -> Result<()> { + if is_direct_version_request() { + println!("{}", DISPLAY_VERSION); + return Ok(()); + } + // Load environment variables from .env if present let _ = dotenv(); utils::logging::init(); - // Show startup banner - print_banner(); - let args = AppCli::parse(); + print_banner(); if args.daemon { run_daemon()?; } @@ -302,7 +313,7 @@ async fn main() -> Result<()> { info!("Starting local API server on port {port}"); } let config = agent::config::Config::from_file(&args.config)?; - comms::local_api::serve(config, port, with_ui).await?; + comms::local_api::serve(config, &args.config, port, with_ui).await?; } #[cfg(feature = "docker")] Some(Commands::Containers) => { diff --git a/tests/cli_version.rs b/tests/cli_version.rs new file mode 100644 index 0000000..67e4825 --- /dev/null +++ b/tests/cli_version.rs @@ -0,0 +1,38 @@ +use assert_cmd::Command; + +fn expected_version_output() -> String { + let base = env!("CARGO_PKG_VERSION"); + let git_hash = std::process::Command::new("git") + .args(["rev-parse", "--short=7", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + match git_hash { + Some(hash) => format!("{base} ({hash})"), + None => base.to_string(), + } +} + +#[test] +fn status_version_prints_display_version_only() { + let mut cmd = Command::cargo_bin("status").unwrap(); + cmd.arg("--version") + .assert() + .success() + .stdout(format!("{}\n", expected_version_output())) + .stderr(""); +} + +#[test] +fn status_short_version_flag_prints_display_version_only() { + let mut cmd = Command::cargo_bin("status").unwrap(); + cmd.arg("-V") + .assert() + .success() + .stdout(format!("{}\n", expected_version_output())) + .stderr(""); +} diff --git a/tests/http_routes.rs b/tests/http_routes.rs index 1c1a3ee..c5b5a51 100644 --- a/tests/http_routes.rs +++ b/tests/http_routes.rs @@ -84,6 +84,33 @@ async fn test_capabilities_endpoint() { assert!(value.get("features").is_some()); } +#[tokio::test] +async fn given_capabilities_request_when_agent_supports_pipe_runtime_then_pipe_features_are_advertised( +) { + let app = test_router(); + + let response = app + .oneshot( + Request::builder() + .uri("/capabilities") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); + let value: Value = serde_json::from_slice(&body_bytes).unwrap(); + let features = value["features"].as_array().expect("features array"); + + assert!(features.contains(&Value::String("pipes".to_string()))); + assert!(features.contains(&Value::String("activate_pipe".to_string()))); + assert!(features.contains(&Value::String("deactivate_pipe".to_string()))); + assert!(features.contains(&Value::String("trigger_pipe".to_string()))); +} + #[tokio::test] async fn test_login_page_get() { let app = test_router(); diff --git a/tests/security_integration.rs b/tests/security_integration.rs index ea73c18..690b374 100644 --- a/tests/security_integration.rs +++ b/tests/security_integration.rs @@ -1,14 +1,22 @@ +use axum::extract::{Path, State}; use axum::http::{Request, StatusCode}; -use axum::{body::Body, Router}; +use axum::routing::{get, post}; +use axum::{body::Body, Json, Router}; use base64::{engine::general_purpose, Engine}; use hmac::{Hmac, Mac}; use http_body_util::BodyExt; +use mockito::{Matcher, Server}; use serde_json::json; +use serde_json::Value; use sha2::Sha256; use status_panel::agent::config::{Config, ReqData}; use status_panel::comms::local_api::{create_router, AppState}; use std::sync::Arc; use std::sync::{Mutex, OnceLock}; +use tokio::net::TcpListener; +use tokio::sync::Mutex as AsyncMutex; +use tokio::task::JoinHandle; +use tokio::time::{sleep, timeout, Duration}; use tower::ServiceExt; // for Router::oneshot use uuid::Uuid; @@ -20,6 +28,31 @@ fn lock_tests() -> std::sync::MutexGuard<'static, ()> { } } +struct EnvGuard { + vars: Vec<(String, Option)>, +} + +impl EnvGuard { + fn new(keys: &[&str]) -> Self { + let vars = keys + .iter() + .map(|k| (k.to_string(), std::env::var(k).ok())) + .collect(); + Self { vars } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + for (key, original) in &self.vars { + match original { + Some(v) => std::env::set_var(key, v), + None => std::env::remove_var(key), + } + } + } +} + fn test_config() -> Arc { Arc::new(Config { domain: Some("test.example.com".to_string()), @@ -35,17 +68,30 @@ fn test_config() -> Arc { }) } -fn router_with_env(agent_id: &str, token: &str, scopes: &str) -> Router { +fn router_with_env(agent_id: &str, token: &str, scopes: &str) -> (Router, EnvGuard) { + let env = EnvGuard::new(&[ + "AGENT_ID", + "AGENT_TOKEN", + "AGENT_SCOPES", + "RATE_LIMIT_PER_MIN", + ]); std::env::set_var("AGENT_ID", agent_id); std::env::set_var("AGENT_TOKEN", token); std::env::set_var("AGENT_SCOPES", scopes); std::env::set_var("RATE_LIMIT_PER_MIN", "1000"); let state = Arc::new(AppState::new(test_config(), false, None)); - create_router(state) + (create_router(state), env) } type HmacSha256 = Hmac; +#[derive(Clone)] +struct TargetCaptureState { + requests: Arc>>, + status: StatusCode, + response_body: Value, +} + fn sign_b64(token: &str, body: &[u8]) -> String { let mut mac = HmacSha256::new_from_slice(token.as_bytes()).unwrap(); mac.update(body); @@ -86,10 +132,114 @@ async fn post_with_sig( (status, body) } +async fn post_raw_with_sig( + app: &Router, + path: &str, + agent_id: &str, + token: &str, + body: &str, + timestamp: Option, + request_id: Option, +) -> (StatusCode, bytes::Bytes) { + let ts = timestamp.unwrap_or_else(|| format!("{}", chrono::Utc::now().timestamp())); + let rid = request_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let sig = sign_b64(token, body.as_bytes()); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header("content-type", "application/json") + .header("X-Agent-Id", agent_id) + .header("X-Timestamp", ts) + .header("X-Request-Id", rid) + .header("X-Agent-Signature", sig) + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, body) +} + +async fn capture_target_request( + Path(path): Path, + State(state): State, + Json(payload): Json, +) -> (StatusCode, Json) { + state + .requests + .lock() + .await + .push((format!("/{}", path), payload)); + (state.status, Json(state.response_body.clone())) +} + +async fn source_payload_handler(State(payload): State) -> Json { + Json(payload) +} + +async fn spawn_target_capture_server( + status: StatusCode, + response_body: Value, +) -> ( + String, + Arc>>, + JoinHandle<()>, +) { + let requests = Arc::new(AsyncMutex::new(Vec::new())); + let state = TargetCaptureState { + requests: requests.clone(), + status, + response_body, + }; + let app = Router::new() + .route("/{path}", post(capture_target_request)) + .with_state(state); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (base_url, requests, handle) +} + +async fn spawn_source_server(payload: Value) -> (String, JoinHandle<()>) { + let app = Router::new() + .route("/source", get(source_payload_handler)) + .with_state(payload); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (base_url, handle) +} + +async fn wait_for_request_count( + requests: &Arc>>, + expected: usize, +) -> Vec<(String, Value)> { + timeout(Duration::from_secs(5), async { + loop { + let snapshot = requests.lock().await.clone(); + if snapshot.len() >= expected { + return snapshot; + } + sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("timed out waiting for captured requests") +} + #[tokio::test] async fn execute_requires_signature_and_scope() { let _g = lock_tests(); - let app = router_with_env("agent-1", "secret-token", "commands:execute"); + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); // Missing signature let response = app @@ -131,7 +281,7 @@ async fn execute_requires_signature_and_scope() { #[tokio::test] async fn replay_detection_returns_409() { let _g = lock_tests(); - let app = router_with_env("agent-1", "secret-token", "commands:execute"); + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); let rid = Uuid::new_v4().to_string(); let path = "/api/v1/commands/execute"; let body = json!({"id": "cmd-3", "command_id": "cmd-exec-3", "name": "echo hi", "params": {}}); @@ -192,7 +342,7 @@ async fn rate_limit_returns_429() { async fn scope_denied_returns_403() { let _g = lock_tests(); // Do not include commands:execute - let app = router_with_env("agent-1", "secret-token", "commands:report"); + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:report"); let (status, body) = post_with_sig( &app, "/api/v1/commands/execute", @@ -210,9 +360,10 @@ async fn scope_denied_returns_403() { #[tokio::test] async fn wait_can_require_signature() { let _g = lock_tests(); + let _env = EnvGuard::new(&["WAIT_REQUIRE_SIGNATURE"]); // Enable signing for GET /wait std::env::set_var("WAIT_REQUIRE_SIGNATURE", "true"); - let app = router_with_env("agent-1", "secret-token", "commands:wait"); + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:wait"); // Missing signature should fail let response = app @@ -251,3 +402,702 @@ async fn wait_can_require_signature() { // No commands queued -> 204 No Content assert_eq!(response.status(), StatusCode::NO_CONTENT); } + +#[tokio::test] +async fn given_signed_local_wait_request_when_queue_is_empty_then_local_wait_returns_no_content() { + let _g = lock_tests(); + let _env = EnvGuard::new(&["WAIT_REQUIRE_SIGNATURE"]); + std::env::set_var("WAIT_REQUIRE_SIGNATURE", "true"); + let (app, _router_env) = router_with_env("agent-1", "secret-token", "commands:wait"); + + let ts = format!("{}", chrono::Utc::now().timestamp()); + let rid = Uuid::new_v4().to_string(); + let sig = sign_b64("secret-token", b""); + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/commands/wait/session?timeout=1") + .header("X-Agent-Id", "agent-1") + .header("X-Timestamp", ts) + .header("X-Request-Id", rid) + .header("X-Agent-Signature", sig) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn given_pipe_command_enqueued_when_agent_waits_and_reports_result_then_transport_path_delivers_and_records_execution( +) { + let _g = lock_tests(); + let _env = EnvGuard::new(&["WAIT_REQUIRE_SIGNATURE"]); + std::env::set_var("WAIT_REQUIRE_SIGNATURE", "true"); + let (app, _router_env) = router_with_env( + "agent-1", + "secret-token", + "commands:enqueue,commands:wait,commands:report", + ); + + let (enqueue_status, enqueue_body) = post_with_sig( + &app, + "/api/v1/commands/enqueue", + "agent-1", + "secret-token", + json!({ + "id": "queued-activate-pipe", + "command_id": "queued-activate-pipe", + "name": "activate_pipe", + "deployment_hash": "dep-daemon", + "params": { + "pipe_instance_id": "pipe-daemon-1", + "target_url": "https://example.com", + "trigger_type": "manual" + } + }), + None, + ) + .await; + assert_eq!(enqueue_status, StatusCode::ACCEPTED); + let enqueue_payload: Value = serde_json::from_slice(&enqueue_body).unwrap(); + assert_eq!(enqueue_payload["queued"], true); + + let ts = format!("{}", chrono::Utc::now().timestamp()); + let rid = Uuid::new_v4().to_string(); + let sig = sign_b64("secret-token", b""); + let wait_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/commands/wait/dep-daemon?timeout=1") + .header("X-Agent-Id", "agent-1") + .header("X-Timestamp", ts) + .header("X-Request-Id", rid) + .header("X-Agent-Signature", sig) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(wait_response.status(), StatusCode::OK); + let waited_body = wait_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let waited_payload: Value = serde_json::from_slice(&waited_body).unwrap(); + assert_eq!(waited_payload["name"], "activate_pipe"); + assert_eq!(waited_payload["deployment_hash"], "dep-daemon"); + assert_eq!( + waited_payload["params"]["pipe_instance_id"], + "pipe-daemon-1" + ); + + let (report_status, report_body) = post_with_sig( + &app, + "/api/v1/commands/report", + "agent-1", + "secret-token", + json!({ + "command_id": "queued-activate-pipe", + "status": "success", + "result": { + "type": "activate_pipe", + "pipe_instance_id": "pipe-daemon-1" + }, + "completed_at": chrono::Utc::now().to_rfc3339(), + "deployment_hash": "dep-daemon", + "command_type": "activate_pipe", + "executed_by": "status_panel" + }), + None, + ) + .await; + assert_eq!(report_status, StatusCode::OK); + let report_payload: Value = serde_json::from_slice(&report_body).unwrap(); + assert_eq!(report_payload["accepted"], true); + + let metrics_response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/api/v1/diagnostics/commands") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(metrics_response.status(), StatusCode::OK); + let metrics_body = metrics_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let metrics_payload: Value = serde_json::from_slice(&metrics_body).unwrap(); + assert_eq!(metrics_payload["status_panel_count"], 1); + assert_eq!(metrics_payload["total_count"], 1); + assert_eq!(metrics_payload["last_control_plane"], "status_panel"); +} + +#[tokio::test] +async fn given_registered_webhook_pipe_when_signed_webhook_arrives_then_payload_is_forwarded_to_target( +) { + let _g = lock_tests(); + let mut server = Server::new_async().await; + let target = server + .mock("POST", "/pipe-target") + .match_body(Matcher::Exact(r#"{"email":"webhook@try.direct"}"#.into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"accepted":true}"#) + .create_async() + .await; + + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); + + let (activate_status, _) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-activate-webhook", + "command_id": "cmd-activate-webhook", + "name": "activate_pipe", + "params": { + "deployment_hash": "dep-webhook", + "pipe_instance_id": "pipe-webhook-1", + "target_url": server.url(), + "target_endpoint": "/pipe-target", + "target_method": "POST", + "field_mapping": { "email": "$.user.email" }, + "trigger_type": "webhook" + } + }), + None, + ) + .await; + assert_eq!(activate_status, StatusCode::OK); + + let (webhook_status, webhook_body) = post_with_sig( + &app, + "/api/v1/pipes/webhook/dep-webhook/pipe-webhook-1", + "agent-1", + "secret-token", + json!({ + "user": { + "email": "webhook@try.direct" + } + }), + None, + ) + .await; + + assert_eq!(webhook_status, StatusCode::OK); + let payload: serde_json::Value = serde_json::from_slice(&webhook_body).unwrap(); + assert_eq!(payload["status"], "success"); + assert_eq!(payload["result"]["target_response"]["transport"], "http"); + assert_eq!(payload["result"]["target_response"]["delivered"], true); + target.assert_async().await; +} + +#[tokio::test] +async fn given_signed_webhook_request_without_execute_scope_when_pipe_ingest_is_called_then_it_is_rejected( +) { + let _g = lock_tests(); + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:report"); + + let (status, body) = post_with_sig( + &app, + "/api/v1/pipes/webhook/dep-webhook/pipe-webhook-1", + "agent-1", + "secret-token", + json!({"user": {"email": "webhook@try.direct"}}), + None, + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + let payload: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["error"], "insufficient scope"); +} + +#[tokio::test] +async fn given_signed_webhook_request_with_invalid_json_when_pipe_ingest_is_called_then_it_returns_bad_request( +) { + let _g = lock_tests(); + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); + + let (status, body) = post_raw_with_sig( + &app, + "/api/v1/pipes/webhook/dep-webhook/pipe-webhook-1", + "agent-1", + "secret-token", + "{invalid-json", + None, + None, + ) + .await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + let payload: Value = serde_json::from_slice(&body).unwrap(); + assert!(payload["error"] + .as_str() + .unwrap_or_default() + .contains("invalid webhook payload")); +} + +#[tokio::test] +async fn given_replayed_signed_webhook_request_when_pipe_ingest_is_called_then_replay_is_blocked() { + let _g = lock_tests(); + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); + let request_id = Uuid::new_v4().to_string(); + + let (first_status, first_body) = post_with_sig( + &app, + "/api/v1/pipes/webhook/dep-webhook/missing-pipe", + "agent-1", + "secret-token", + json!({"user": {"email": "webhook@try.direct"}}), + Some(request_id.clone()), + ) + .await; + assert_eq!(first_status, StatusCode::OK); + let first_payload: Value = serde_json::from_slice(&first_body).unwrap(); + assert_eq!(first_payload["status"], "failed"); + assert_eq!(first_payload["result"]["success"], false); + + let (second_status, second_body) = post_with_sig( + &app, + "/api/v1/pipes/webhook/dep-webhook/missing-pipe", + "agent-1", + "secret-token", + json!({"user": {"email": "webhook@try.direct"}}), + Some(request_id), + ) + .await; + assert_eq!(second_status, StatusCode::CONFLICT); + let second_payload: Value = serde_json::from_slice(&second_body).unwrap(); + assert_eq!(second_payload["error"], "replay detected"); +} + +#[tokio::test] +async fn given_reactivated_manual_pipe_when_it_is_triggered_then_only_the_latest_target_receives_payload( +) { + let _g = lock_tests(); + let (target_url, requests, target_handle) = + spawn_target_capture_server(StatusCode::OK, json!({"accepted": true})).await; + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); + + let (first_activate_status, first_activate_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-activate-first", + "command_id": "cmd-activate-first", + "name": "activate_pipe", + "params": { + "deployment_hash": "dep-reactivate", + "pipe_instance_id": "pipe-reactivate-1", + "target_url": target_url, + "target_endpoint": "/first", + "target_method": "POST", + "field_mapping": { "email": "$.user.email" }, + "trigger_type": "manual" + } + }), + None, + ) + .await; + assert_eq!(first_activate_status, StatusCode::OK); + let first_activate_payload: Value = serde_json::from_slice(&first_activate_body).unwrap(); + assert_eq!(first_activate_payload["result"]["replaced"], false); + assert_eq!(first_activate_payload["result"]["reactivated"], false); + + let (second_activate_status, second_activate_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-activate-second", + "command_id": "cmd-activate-second", + "name": "activate_pipe", + "params": { + "deployment_hash": "dep-reactivate", + "pipe_instance_id": "pipe-reactivate-1", + "target_url": target_url, + "target_endpoint": "/second", + "target_method": "POST", + "field_mapping": { "email": "$.user.email" }, + "trigger_type": "manual" + } + }), + None, + ) + .await; + assert_eq!(second_activate_status, StatusCode::OK); + let second_activate_payload: Value = serde_json::from_slice(&second_activate_body).unwrap(); + assert_eq!(second_activate_payload["result"]["replaced"], true); + assert_eq!(second_activate_payload["result"]["reactivated"], true); + + let (trigger_status, trigger_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-trigger-reactivate", + "command_id": "cmd-trigger-reactivate", + "name": "trigger_pipe", + "params": { + "deployment_hash": "dep-reactivate", + "pipe_instance_id": "pipe-reactivate-1", + "input_data": { + "user": { + "email": "replace@try.direct" + } + } + } + }), + None, + ) + .await; + assert_eq!(trigger_status, StatusCode::OK); + let trigger_payload: Value = serde_json::from_slice(&trigger_body).unwrap(); + assert_eq!(trigger_payload["status"], "success"); + + let captured = wait_for_request_count(&requests, 1).await; + assert_eq!( + captured, + vec![( + "/second".to_string(), + json!({"email": "replace@try.direct"}) + )] + ); + + target_handle.abort(); +} + +#[tokio::test] +async fn given_poll_pipe_when_source_worker_fetches_payload_then_target_receives_it_and_deactivation_stops_future_deliveries( +) { + let _g = lock_tests(); + let _env = EnvGuard::new(&["PIPE_POLL_INTERVAL_SECS"]); + std::env::set_var("PIPE_POLL_INTERVAL_SECS", "1"); + let (source_url, source_handle) = + spawn_source_server(json!({"user": {"email": "poll@try.direct"}})).await; + let (target_url, requests, target_handle) = + spawn_target_capture_server(StatusCode::OK, json!({"accepted": true})).await; + let (app, _router_env) = router_with_env("agent-1", "secret-token", "commands:execute"); + + let (activate_status, activate_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-activate-poll", + "command_id": "cmd-activate-poll", + "name": "activate_pipe", + "params": { + "deployment_hash": "dep-poll", + "pipe_instance_id": "pipe-poll-1", + "source_endpoint": format!("{}/source", source_url), + "source_method": "GET", + "target_url": target_url, + "target_endpoint": "/pipe-target", + "target_method": "POST", + "field_mapping": { "email": "$.user.email" }, + "trigger_type": "poll" + } + }), + None, + ) + .await; + assert_eq!(activate_status, StatusCode::OK); + let activate_payload: Value = serde_json::from_slice(&activate_body).unwrap(); + assert_eq!(activate_payload["status"], "success"); + + let first_delivery = wait_for_request_count(&requests, 1).await; + assert_eq!( + first_delivery, + vec![( + "/pipe-target".to_string(), + json!({"email": "poll@try.direct"}) + )] + ); + + let (deactivate_status, deactivate_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-deactivate-poll", + "command_id": "cmd-deactivate-poll", + "name": "deactivate_pipe", + "params": { + "deployment_hash": "dep-poll", + "pipe_instance_id": "pipe-poll-1" + } + }), + None, + ) + .await; + assert_eq!(deactivate_status, StatusCode::OK); + let deactivate_payload: Value = serde_json::from_slice(&deactivate_body).unwrap(); + assert_eq!( + deactivate_payload["result"]["lifecycle"]["state"], + "inactive" + ); + + sleep(Duration::from_millis(1300)).await; + let final_snapshot = requests.lock().await.clone(); + assert_eq!(final_snapshot.len(), 1); + + target_handle.abort(); + source_handle.abort(); +} + +#[tokio::test] +async fn given_registered_manual_pipe_when_target_returns_server_error_then_failed_delivery_shape_and_lifecycle_are_reported( +) { + let _g = lock_tests(); + let (target_url, requests, target_handle) = spawn_target_capture_server( + StatusCode::INTERNAL_SERVER_ERROR, + json!({"error": "downstream unavailable"}), + ) + .await; + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); + + let (activate_status, _) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-activate-fail", + "command_id": "cmd-activate-fail", + "name": "activate_pipe", + "params": { + "deployment_hash": "dep-fail", + "pipe_instance_id": "pipe-fail-1", + "target_url": target_url, + "target_endpoint": "/pipe-target", + "target_method": "POST", + "field_mapping": { "email": "$.user.email" }, + "trigger_type": "manual" + } + }), + None, + ) + .await; + assert_eq!(activate_status, StatusCode::OK); + + let (trigger_status, trigger_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-trigger-fail", + "command_id": "cmd-trigger-fail", + "name": "trigger_pipe", + "params": { + "deployment_hash": "dep-fail", + "pipe_instance_id": "pipe-fail-1", + "input_data": { + "user": { + "email": "failure@try.direct" + } + } + } + }), + None, + ) + .await; + assert_eq!(trigger_status, StatusCode::OK); + let trigger_payload: Value = serde_json::from_slice(&trigger_body).unwrap(); + assert_eq!(trigger_payload["status"], "failed"); + assert_eq!(trigger_payload["result"]["success"], false); + assert_eq!( + trigger_payload["result"]["target_response"]["transport"], + "http" + ); + assert_eq!(trigger_payload["result"]["target_response"]["status"], 500); + assert_eq!( + trigger_payload["result"]["target_response"]["delivered"], + false + ); + assert_eq!( + trigger_payload["result"]["target_response"]["body"], + json!({"error": "downstream unavailable"}) + ); + assert_eq!(trigger_payload["result"]["lifecycle"]["state"], "failed"); + + let captured = wait_for_request_count(&requests, 1).await; + assert_eq!( + captured, + vec![( + "/pipe-target".to_string(), + json!({"email": "failure@try.direct"}) + )] + ); + + target_handle.abort(); +} + +#[tokio::test] +async fn given_registered_manual_pipe_when_it_is_triggered_and_deactivated_then_follow_up_trigger_fails_cleanly( +) { + let _g = lock_tests(); + let mut server = Server::new_async().await; + let target = server + .mock("POST", "/pipe-target") + .match_body(Matcher::Exact(r#"{"email":"manual@try.direct"}"#.into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"accepted":true}"#) + .expect(1) + .create_async() + .await; + + let (app, _env) = router_with_env("agent-1", "secret-token", "commands:execute"); + + let (activate_status, activate_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-activate-manual", + "command_id": "cmd-activate-manual", + "name": "activate_pipe", + "params": { + "deployment_hash": "dep-manual", + "pipe_instance_id": "pipe-manual-1", + "target_url": server.url(), + "target_endpoint": "/pipe-target", + "target_method": "POST", + "field_mapping": { "email": "$.user.email" }, + "trigger_type": "manual" + } + }), + None, + ) + .await; + assert_eq!(activate_status, StatusCode::OK); + let activate_payload: serde_json::Value = serde_json::from_slice(&activate_body).unwrap(); + assert_eq!(activate_payload["status"], "success"); + assert_eq!(activate_payload["result"]["active"], true); + assert_eq!(activate_payload["result"]["lifecycle"]["state"], "active"); + + let (trigger_status, trigger_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-trigger-manual", + "command_id": "cmd-trigger-manual", + "name": "trigger_pipe", + "params": { + "deployment_hash": "dep-manual", + "pipe_instance_id": "pipe-manual-1", + "input_data": { + "user": { + "email": "manual@try.direct" + } + } + } + }), + None, + ) + .await; + assert_eq!(trigger_status, StatusCode::OK); + let trigger_payload: serde_json::Value = serde_json::from_slice(&trigger_body).unwrap(); + assert_eq!(trigger_payload["status"], "success"); + assert_eq!(trigger_payload["result"]["success"], true); + assert_eq!( + trigger_payload["result"]["target_response"]["transport"], + "http" + ); + assert_eq!( + trigger_payload["result"]["target_response"]["delivered"], + true + ); + assert_eq!(trigger_payload["result"]["lifecycle"]["state"], "active"); + assert_eq!(trigger_payload["result"]["lifecycle"]["trigger_count"], 1); + + let (deactivate_status, deactivate_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-deactivate-manual", + "command_id": "cmd-deactivate-manual", + "name": "deactivate_pipe", + "params": { + "deployment_hash": "dep-manual", + "pipe_instance_id": "pipe-manual-1" + } + }), + None, + ) + .await; + assert_eq!(deactivate_status, StatusCode::OK); + let deactivate_payload: serde_json::Value = serde_json::from_slice(&deactivate_body).unwrap(); + assert_eq!(deactivate_payload["status"], "success"); + assert_eq!(deactivate_payload["result"]["active"], false); + assert_eq!( + deactivate_payload["result"]["lifecycle"]["state"], + "inactive" + ); + + let (follow_up_status, follow_up_body) = post_with_sig( + &app, + "/api/v1/commands/execute", + "agent-1", + "secret-token", + json!({ + "id": "cmd-trigger-after-deactivate", + "command_id": "cmd-trigger-after-deactivate", + "name": "trigger_pipe", + "params": { + "deployment_hash": "dep-manual", + "pipe_instance_id": "pipe-manual-1", + "input_data": { + "user": { + "email": "manual@try.direct" + } + } + } + }), + None, + ) + .await; + assert_eq!(follow_up_status, StatusCode::OK); + let follow_up_payload: serde_json::Value = serde_json::from_slice(&follow_up_body).unwrap(); + assert_eq!(follow_up_payload["status"], "failed"); + assert_eq!(follow_up_payload["result"]["success"], false); + assert_eq!( + follow_up_payload["result"]["error"], + "trigger_pipe requires target_url or target_container" + ); + assert_eq!(follow_up_payload["result"]["lifecycle"]["state"], "failed"); + + target.assert_async().await; +}