diff --git a/.github/actions/clone-praxis/action.yml b/.github/actions/clone-praxis/action.yml new file mode 100644 index 00000000..12f7f3a1 --- /dev/null +++ b/.github/actions/clone-praxis/action.yml @@ -0,0 +1,20 @@ +name: Clone Praxis core +description: >- + Clone praxis core repo as a sibling directory so + path-based [patch.crates-io] overrides resolve. + +inputs: + ref: + description: "Git ref to checkout (branch, tag, or SHA)" + required: false + default: "main" + +runs: + using: composite + steps: + - name: Clone praxis core + run: >- + git clone --depth 1 --branch "${{ inputs.ref }}" + https://github.com/praxis-proxy/praxis.git + "$GITHUB_WORKSPACE/../praxis" + shell: bash diff --git a/.github/actions/patch-praxis/action.yml b/.github/actions/patch-praxis/action.yml new file mode 100644 index 00000000..63982a18 --- /dev/null +++ b/.github/actions/patch-praxis/action.yml @@ -0,0 +1,20 @@ +name: Patch Praxis dependencies +description: >- + Override crates.io praxis dependencies with local + path dependencies from the cloned praxis repo. + +runs: + using: composite + steps: + - name: Append [patch.crates-io] to Cargo.toml + run: | + cat >> Cargo.toml << 'PATCH' + + [patch.crates-io] + praxis-proxy-core = { path = "../praxis/core" } + praxis-proxy-filter = { path = "../praxis/filter" } + praxis-proxy-protocol = { path = "../praxis/protocol" } + praxis-proxy-tls = { path = "../praxis/tls" } + praxis-proxy = { path = "../praxis/server" } + PATCH + shell: bash diff --git a/.github/workflows/ci-status.yaml b/.github/workflows/ci-status.yaml index 91d0085d..25b728bf 100644 --- a/.github/workflows/ci-status.yaml +++ b/.github/workflows/ci-status.yaml @@ -42,6 +42,7 @@ jobs: const excludedChecks = new Set([ 'ci-status', + 'test-praxis-main', ]); const terminalStatuses = new Set(['completed']); diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index b5881fc1..b7f58e18 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -9,6 +9,7 @@ on: branches: [main] pull_request: branches: [main] + types: [opened, synchronize, reopened] workflow_dispatch: inputs: debug: @@ -16,7 +17,6 @@ on: required: false type: boolean default: false - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/workflows/praxis-compat.yaml b/.github/workflows/praxis-compat.yaml new file mode 100644 index 00000000..c1548ff7 --- /dev/null +++ b/.github/workflows/praxis-compat.yaml @@ -0,0 +1,79 @@ +name: Tests (praxis main) + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ + +on: + push: + branches: [main] + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + inputs: + praxis_ref: + description: "Praxis core git ref to test against" + required: false + type: string + default: "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: {} + +env: + CARGO_TERM_COLOR: always + +jobs: + # ---------------------------------------------------------------------------- + # Build + test against praxis core main + # ---------------------------------------------------------------------------- + + test-praxis-main: + runs-on: ubuntu-24.04 + continue-on-error: ${{ contains(github.event.pull_request.labels.*.name, 'praxis-known-issue') }} + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Clone Praxis core (path dependency) + uses: ./.github/actions/clone-praxis + with: + ref: ${{ inputs.praxis_ref || 'main' }} + + - name: Patch Praxis dependencies + uses: ./.github/actions/patch-praxis + + - name: Update lockfile for patched dependencies + run: cargo update + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@6190aa5fb88a88ee71c12769924bbe63a9ab152e # 1.96.0 + + - name: Cache Cargo registry and build artifacts + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ runner.os }}-rust-1.96.0-praxis-main-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-rust-1.96.0-praxis-main- + + - name: Unit tests + run: | + cargo test -p praxis-ai-apis --features praxis-main + cargo test -p praxis-ai-filters --features praxis-main + cargo test -p praxis-ai-proxy --features praxis-main + + - name: Schema tests + run: cargo test -p praxis-tests-schema --features praxis-main + + - name: Integration tests + run: cargo test -p praxis-tests-integration --features praxis-main diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 3dff8abd..99e174e3 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,6 +9,7 @@ on: branches: [main] pull_request: branches: [main] + types: [opened, synchronize, reopened] merge_group: branches: [main] workflow_dispatch: @@ -18,7 +19,6 @@ on: required: false type: boolean default: false - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} diff --git a/Makefile b/Makefile index 4cda22bb..54437144 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,8 @@ endif lint fmt doc audit coverage-check \ require-container-engine \ container container-run \ - setup-hooks help + setup-hooks help \ + patch-praxis unpatch-praxis # ------------------------------------------------------------------- # All @@ -105,6 +106,35 @@ coverage-check: exit 1; \ fi +# ------------------------------------------------------------------- +# Praxis path override (test against local ../praxis) +# ------------------------------------------------------------------- + +patch-praxis: + @if [ ! -d "../praxis" ]; then \ + echo "ERROR: ../praxis not found — clone praxis core as a sibling directory first"; \ + exit 1; \ + fi + @if grep -q '\[patch\.crates-io\]' Cargo.toml; then \ + echo "Already patched — run 'make unpatch-praxis' first"; \ + exit 1; \ + fi + @printf '\n[patch.crates-io]\n\ + praxis-proxy-core = { path = "../praxis/core" }\n\ + praxis-proxy-filter = { path = "../praxis/filter" }\n\ + praxis-proxy-protocol = { path = "../praxis/protocol" }\n\ + praxis-proxy-tls = { path = "../praxis/tls" }\n\ + praxis-proxy = { path = "../praxis/server" }\n' >> Cargo.toml + @echo "Patched Cargo.toml to use ../praxis path dependencies" + +unpatch-praxis: + @if ! grep -q '\[patch\.crates-io\]' Cargo.toml; then \ + echo "Nothing to unpatch"; \ + exit 0; \ + fi + @sed -i.bak '/^\[patch\.crates-io\]/,$$d' Cargo.toml && rm -f Cargo.toml.bak + @echo "Removed [patch.crates-io] from Cargo.toml" + # ------------------------------------------------------------------- # Dev Setup # ------------------------------------------------------------------- @@ -145,3 +175,7 @@ help: @echo "Container:" @echo " container build praxis-ai container image" @echo " container-run run container in foreground (host network)" + @echo "" + @echo "Praxis override:" + @echo " patch-praxis use ../praxis path deps instead of crates.io" + @echo " unpatch-praxis revert to crates.io praxis deps" diff --git a/apis/Cargo.toml b/apis/Cargo.toml index 2838ebf9..1d5ec575 100644 --- a/apis/Cargo.toml +++ b/apis/Cargo.toml @@ -14,6 +14,7 @@ name = "praxis_ai_apis" [features] default = ["store"] store = ["dep:sqlx", "dep:dashmap"] +praxis-main = [] [lints] workspace = true diff --git a/apis/src/lib.rs b/apis/src/lib.rs index b46eb835..8aa30aa9 100644 --- a/apis/src/lib.rs +++ b/apis/src/lib.rs @@ -91,6 +91,12 @@ pub(crate) mod test_utils { selected_endpoint_index: None, time_source: &praxis_core::time::SystemTimeSource, upstream: None, + #[cfg(feature = "praxis-main")] + peer_identity: None, + #[cfg(feature = "praxis-main")] + pre_read_mutations: Vec::new(), + #[cfg(feature = "praxis-main")] + structured_metadata: std::collections::HashMap::new(), } } diff --git a/filters/Cargo.toml b/filters/Cargo.toml index 035b7bd7..b3252f1a 100644 --- a/filters/Cargo.toml +++ b/filters/Cargo.toml @@ -14,6 +14,7 @@ name = "praxis_ai_filters" [features] default = ["apis"] apis = ["praxis-ai-apis/default"] +praxis-main = [] [lints] workspace = true diff --git a/filters/src/agentic/a2a/mod.rs b/filters/src/agentic/a2a/mod.rs index c0f7320b..c420a103 100644 --- a/filters/src/agentic/a2a/mod.rs +++ b/filters/src/agentic/a2a/mod.rs @@ -674,15 +674,30 @@ fn clear_sse_capture_metadata(ctx: &mut HttpFilterContext<'_>) { fn build_json_rpc_config(max_body_bytes: usize) -> JsonRpcConfig { use praxis_filter::builtins::http::payload_processing::json_rpc::config::{BatchPolicy, JsonRpcHeaders}; - JsonRpcConfig { - batch_policy: BatchPolicy::Reject, - headers: JsonRpcHeaders { - id: None, - kind: None, - method: None, - }, - max_body_bytes, - on_invalid: OnInvalidBehavior::Continue, + let headers = JsonRpcHeaders { + id: None, + kind: None, + method: None, + }; + + #[cfg(feature = "praxis-main")] + { + JsonRpcConfig { + batch_policy: BatchPolicy::Reject, + headers, + max_batch_size: 100, + max_body_bytes, + on_invalid: OnInvalidBehavior::Continue, + } + } + #[cfg(not(feature = "praxis-main"))] + { + JsonRpcConfig { + batch_policy: BatchPolicy::Reject, + headers, + max_body_bytes, + on_invalid: OnInvalidBehavior::Continue, + } } } diff --git a/filters/src/agentic/mcp/broker/mod.rs b/filters/src/agentic/mcp/broker/mod.rs index 787de95e..881b64c5 100644 --- a/filters/src/agentic/mcp/broker/mod.rs +++ b/filters/src/agentic/mcp/broker/mod.rs @@ -919,14 +919,29 @@ fn build_json_rpc_config(max_body_bytes: usize) -> JsonRpcConfig { json_rpc::config::{BatchPolicy, JsonRpcHeaders}, }; - JsonRpcConfig { - batch_policy: BatchPolicy::Reject, - headers: JsonRpcHeaders { - id: None, - kind: None, - method: None, - }, - max_body_bytes, - on_invalid: OnInvalidBehavior::Continue, + let headers = JsonRpcHeaders { + id: None, + kind: None, + method: None, + }; + + #[cfg(feature = "praxis-main")] + { + JsonRpcConfig { + batch_policy: BatchPolicy::Reject, + headers, + max_batch_size: 100, + max_body_bytes, + on_invalid: OnInvalidBehavior::Continue, + } + } + #[cfg(not(feature = "praxis-main"))] + { + JsonRpcConfig { + batch_policy: BatchPolicy::Reject, + headers, + max_body_bytes, + on_invalid: OnInvalidBehavior::Continue, + } } } diff --git a/filters/src/agentic/mcp/mod.rs b/filters/src/agentic/mcp/mod.rs index 679a19a7..758f4ab0 100644 --- a/filters/src/agentic/mcp/mod.rs +++ b/filters/src/agentic/mcp/mod.rs @@ -194,15 +194,30 @@ impl HttpFilter for McpFilter { fn build_json_rpc_config(max_body_bytes: usize) -> JsonRpcConfig { use praxis_filter::builtins::http::payload_processing::json_rpc::config::{BatchPolicy, JsonRpcHeaders}; - JsonRpcConfig { - batch_policy: BatchPolicy::Reject, - headers: JsonRpcHeaders { - id: None, - kind: None, - method: None, - }, - max_body_bytes, - on_invalid: OnInvalidBehavior::Continue, + let headers = JsonRpcHeaders { + id: None, + kind: None, + method: None, + }; + + #[cfg(feature = "praxis-main")] + { + JsonRpcConfig { + batch_policy: BatchPolicy::Reject, + headers, + max_batch_size: 100, + max_body_bytes, + on_invalid: OnInvalidBehavior::Continue, + } + } + #[cfg(not(feature = "praxis-main"))] + { + JsonRpcConfig { + batch_policy: BatchPolicy::Reject, + headers, + max_body_bytes, + on_invalid: OnInvalidBehavior::Continue, + } } } diff --git a/filters/src/lib.rs b/filters/src/lib.rs index db9a7dff..763987bc 100644 --- a/filters/src/lib.rs +++ b/filters/src/lib.rs @@ -86,6 +86,12 @@ pub(crate) mod test_utils { selected_endpoint_index: None, time_source: &praxis_core::time::SystemTimeSource, upstream: None, + #[cfg(feature = "praxis-main")] + peer_identity: None, + #[cfg(feature = "praxis-main")] + pre_read_mutations: Vec::new(), + #[cfg(feature = "praxis-main")] + structured_metadata: std::collections::HashMap::new(), } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 413de663..e2ab058e 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -17,6 +17,9 @@ path = "src/lib.rs" name = "praxis-ai" path = "src/main.rs" +[features] +praxis-main = ["praxis-ai-filters/praxis-main", "praxis-ai-apis/praxis-main"] + [lints] workspace = true diff --git a/server/src/pipelines.rs b/server/src/pipelines.rs index a956a574..2516d428 100644 --- a/server/src/pipelines.rs +++ b/server/src/pipelines.rs @@ -97,6 +97,13 @@ fn validate_pipeline( skip: bool, allow_open_security: bool, ) -> Result<(), Box> { + #[cfg(feature = "praxis-main")] + let errors = pipeline.ordering_errors( + entries, + allow_open_security, + &praxis_core::config::SkipPipelineChecks::default(), + ); + #[cfg(not(feature = "praxis-main"))] let errors = pipeline.ordering_errors(entries, allow_open_security); if skip { diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index 5ddc67f9..a401863c 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -10,6 +10,7 @@ publish = false [features] cpex-policy-engine = ["praxis-filter/cpex-policy-engine"] no-mac-cert-rotation-tests = [] # We use this to disable testing cert rotation on macOS +praxis-main = ["praxis-test-utils/praxis-main"] [lints] workspace = true diff --git a/tests/schema/Cargo.toml b/tests/schema/Cargo.toml index 8089584c..41d41c80 100644 --- a/tests/schema/Cargo.toml +++ b/tests/schema/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true readme = "../../README.md" publish = false +[features] +praxis-main = ["praxis-test-utils/praxis-main"] + [lints] workspace = true diff --git a/tests/schema/tests/suite/cluster.rs b/tests/schema/tests/suite/cluster.rs index fff4379c..c7b90b8f 100644 --- a/tests/schema/tests/suite/cluster.rs +++ b/tests/schema/tests/suite/cluster.rs @@ -103,6 +103,12 @@ clusters: sni: "" "#; let err = Config::from_yaml(yaml).unwrap_err(); + #[cfg(feature = "praxis-main")] + assert!( + err.to_string().contains("sni must be a valid DNS hostname"), + "got: {err}" + ); + #[cfg(not(feature = "praxis-main"))] assert!(err.to_string().contains("sni is empty"), "got: {err}"); } @@ -129,6 +135,12 @@ clusters: "# ); let err = Config::from_yaml(&yaml).unwrap_err(); + #[cfg(feature = "praxis-main")] + assert!( + err.to_string().contains("sni must be a valid DNS hostname"), + "got: {err}" + ); + #[cfg(not(feature = "praxis-main"))] assert!(err.to_string().contains("exceeds 253 characters"), "got: {err}"); } @@ -155,6 +167,12 @@ clusters: "# ); let err = Config::from_yaml(&yaml).unwrap_err(); + #[cfg(feature = "praxis-main")] + assert!( + err.to_string().contains("sni must be a valid DNS hostname"), + "got: {err}" + ); + #[cfg(not(feature = "praxis-main"))] assert!(err.to_string().contains("invalid label length"), "got: {err}"); } @@ -177,6 +195,12 @@ clusters: sni: "api..example.com" "#; let err = Config::from_yaml(yaml).unwrap_err(); + #[cfg(feature = "praxis-main")] + assert!( + err.to_string().contains("sni must be a valid DNS hostname"), + "got: {err}" + ); + #[cfg(not(feature = "praxis-main"))] assert!(err.to_string().contains("invalid label length"), "got: {err}"); } @@ -199,6 +223,12 @@ clusters: sni: "api.exam ple.com" "#; let err = Config::from_yaml(yaml).unwrap_err(); + #[cfg(feature = "praxis-main")] + assert!( + err.to_string().contains("sni must be a valid DNS hostname"), + "got: {err}" + ); + #[cfg(not(feature = "praxis-main"))] assert!(err.to_string().contains("invalid characters"), "got: {err}"); } diff --git a/tests/utils/Cargo.toml b/tests/utils/Cargo.toml index 8f69db63..efc91e3c 100644 --- a/tests/utils/Cargo.toml +++ b/tests/utils/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true readme = "../../README.md" publish = false +[features] +praxis-main = ["praxis-ai-proxy/praxis-main"] + [lints] workspace = true diff --git a/tests/utils/src/net/backend/simple.rs b/tests/utils/src/net/backend/simple.rs index e73d2339..06de82a2 100644 --- a/tests/utils/src/net/backend/simple.rs +++ b/tests/utils/src/net/backend/simple.rs @@ -10,8 +10,7 @@ use std::{ }; use praxis_core::config::{ - AdminConfig, BodyLimitsConfig, Cluster, Config, Endpoint, FailureMode, FilterChainConfig, FilterEntry, - InsecureOptions, Listener, ProtocolKind, RuntimeConfig, + Cluster, Config, Endpoint, FailureMode, FilterChainConfig, FilterEntry, Listener, ProtocolKind, }; use super::specialized::{BackendGuard, read_until_headers_complete, spawn_tcp_server, spawn_tcp_server_with_shutdown}; @@ -401,32 +400,36 @@ fn build_static_response_filter(entry: &RoutedEntry) -> FilterEntry { } /// Assemble a [`Config`] from parts. +/// +/// Uses serde round-trip so new `#[serde(default)]` fields in +/// upstream praxis-core (e.g. `metrics`) are filled automatically, +/// keeping this compatible with both crates.io and path deps. fn build_config(address: &str, clusters: Vec, filters: Vec) -> Config { - Config { - admin: AdminConfig::default(), - body_limits: BodyLimitsConfig::default(), - clusters, - filter_chains: vec![FilterChainConfig { - name: "backend".to_owned(), - filters, - }], - insecure_options: InsecureOptions::default(), - listeners: vec![Listener { - address: address.to_owned(), - cluster: None, - downstream_read_timeout_ms: None, - filter_chains: vec!["backend".to_owned()], - max_connections: None, - name: "backend".to_owned(), - protocol: ProtocolKind::default(), - tcp_session_timeout_ms: None, - tcp_max_duration_secs: None, - tls: None, - upstream: None, - }], - runtime: RuntimeConfig::default(), - shutdown_timeout_secs: 5, - } + let listener = Listener { + address: address.to_owned(), + cluster: None, + downstream_read_timeout_ms: None, + filter_chains: vec!["backend".to_owned()], + max_connections: None, + name: "backend".to_owned(), + protocol: ProtocolKind::default(), + tcp_session_timeout_ms: None, + tcp_max_duration_secs: None, + tls: None, + upstream: None, + }; + + let chain = FilterChainConfig { + name: "backend".to_owned(), + filters, + }; + + let mut map = serde_yaml::Mapping::new(); + map.insert("clusters".into(), serde_yaml::to_value(clusters).unwrap()); + map.insert("filter_chains".into(), serde_yaml::to_value(vec![chain]).unwrap()); + map.insert("listeners".into(), serde_yaml::to_value(vec![listener]).unwrap()); + map.insert("shutdown_timeout_secs".into(), serde_yaml::to_value(5_u64).unwrap()); + serde_yaml::from_value(serde_yaml::Value::Mapping(map)).unwrap() } /// Build a YAML header mapping with `name` and `value` keys. diff --git a/xtask/src/echo.rs b/xtask/src/echo.rs index 450bc289..a5524f37 100644 --- a/xtask/src/echo.rs +++ b/xtask/src/echo.rs @@ -4,10 +4,7 @@ //! `cargo xtask echo` — quick HTTP test server. use clap::Parser; -use praxis_core::config::{ - AdminConfig, BodyLimitsConfig, Config, FailureMode, FilterChainConfig, FilterEntry, InsecureOptions, Listener, - ProtocolKind, RuntimeConfig, -}; +use praxis_core::config::{Config, FailureMode, FilterChainConfig, FilterEntry, Listener, ProtocolKind}; // ----------------------------------------------------------------------------- // CLI Arguments @@ -60,19 +57,21 @@ pub(crate) fn run(mut args: Args) { /// [`Config`]: praxis_core::config::Config fn build_config(args: &Args) -> Config { let entry = build_static_response_entry(args); - Config { - admin: AdminConfig::default(), - body_limits: BodyLimitsConfig::default(), - clusters: vec![], - filter_chains: vec![FilterChainConfig { - name: "echo".into(), - filters: vec![entry], - }], - insecure_options: InsecureOptions::default(), - listeners: vec![echo_listener(&args.address)], - runtime: RuntimeConfig::default(), - shutdown_timeout_secs: 30, - } + + let chain = FilterChainConfig { + name: "echo".into(), + filters: vec![entry], + }; + + let mut map = serde_yaml::Mapping::new(); + map.insert("clusters".into(), serde_yaml::to_value(Vec::<()>::new()).unwrap()); + map.insert("filter_chains".into(), serde_yaml::to_value(vec![chain]).unwrap()); + map.insert( + "listeners".into(), + serde_yaml::to_value(vec![echo_listener(&args.address)]).unwrap(), + ); + map.insert("shutdown_timeout_secs".into(), serde_yaml::to_value(30_u64).unwrap()); + serde_yaml::from_value(serde_yaml::Value::Mapping(map)).unwrap() } /// Build the echo listener bound to `address`.